mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 07:30:44 +08:00
Merge upstream/main: inbound files across channels + v1.3.0
Resolve qq-bridge.mjs by keeping the inbound-file support (hasFiles, files passthrough) and dropping the upstream stream session, which this branch replaces with the discrete turn-feed notices.
This commit is contained in:
commit
46acf5a425
48 changed files with 2670 additions and 349 deletions
|
|
@ -5,6 +5,7 @@ import test from 'node:test';
|
|||
import {
|
||||
createWeixinApi,
|
||||
decryptWeixinImage,
|
||||
extractWeixinFiles,
|
||||
extractWeixinText,
|
||||
normalizeWeixinApiBaseUrl,
|
||||
parseWeixinImageAesKey,
|
||||
|
|
@ -65,6 +66,42 @@ test('iLink image references download lazily from the canonical CDN and decrypt
|
|||
assert.equal(calls[0].init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('iLink native file items download lazily and decrypt without image size or type rules', async () => {
|
||||
const key = randomBytes(16);
|
||||
const plaintext = Buffer.from('weixin-native-file');
|
||||
const ciphertext = encryptImage(plaintext, key);
|
||||
const calls = [];
|
||||
const files = extractWeixinFiles({
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'native-file-ticket',
|
||||
aes_key: Buffer.from(key.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: '微信报告.zip',
|
||||
len: String(plaintext.length),
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return new Response(ciphertext);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, '微信报告.zip');
|
||||
assert.equal(files[0].size, plaintext.length);
|
||||
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||
assert.deepEqual(await files[0].load({}), plaintext);
|
||||
assert.deepEqual(calls, [{
|
||||
url: 'https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=native-file-ticket',
|
||||
init: { method: 'GET', signal: undefined, redirect: 'manual' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Weixin image keys support both documented CDN encodings and reject unsafe URLs', () => {
|
||||
const key = randomBytes(16);
|
||||
assert.equal(parseWeixinImageAesKey({ aeskey: key.toString('hex') }).equals(key), true);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import {
|
||||
createWeixinBridgeStatus,
|
||||
weixinInboundMessage,
|
||||
WeixinHarnessBridge,
|
||||
} from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -78,6 +82,125 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin bridge normalization keeps native file items separate from images', async () => {
|
||||
const bytes = Buffer.from('weixin-file');
|
||||
const source = { name: '微信文档.docx', load: async () => bytes };
|
||||
const calls = [];
|
||||
const inbound = weixinInboundMessage(message('weixin-file', '', {
|
||||
item_list: [{ type: 4, file_item: { file_name: '微信文档.docx', media: {} } }],
|
||||
}), {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: (value) => {
|
||||
calls.push(value);
|
||||
return [source];
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(inbound.content, '');
|
||||
assert.deepEqual(inbound.images, []);
|
||||
assert.deepEqual(inbound.files, [source]);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||
});
|
||||
|
||||
test('Weixin bridge hands a native file source to the current Harness turn', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-native-file');
|
||||
const bytes = Buffer.from('weixin-bridge-file');
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: () => [{ name: '微信报告.zip', load: async () => bytes }],
|
||||
sendText: async ({ text }) => sent.push(text),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
prompts.push({
|
||||
sessionId,
|
||||
prompt,
|
||||
name: options.files[0].name,
|
||||
bytes: await options.files[0].load({ signal: options.signal }),
|
||||
});
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: createWeixinBridgeStatus(),
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-native-file-turn', '', {
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: { file_name: '微信报告.zip', media: {} },
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.deepEqual(prompts, [{
|
||||
sessionId: 'session-native-file',
|
||||
prompt: '',
|
||||
name: '微信报告.zip',
|
||||
bytes,
|
||||
}]);
|
||||
assert.deepEqual(sent, ['文件已收到']);
|
||||
});
|
||||
|
||||
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
|
||||
const firstTurn = deferred();
|
||||
const bytes = Buffer.from('weixin-prefetched-file');
|
||||
let asks = 0;
|
||||
let downloads = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: (value) => value.item_list?.some((item) => item.file_item)
|
||||
? [{
|
||||
name: 'queued.bin',
|
||||
load: async () => {
|
||||
downloads += 1;
|
||||
return bytes;
|
||||
},
|
||||
}]
|
||||
: [],
|
||||
sendText: async () => {},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _prompt, options) => {
|
||||
asks += 1;
|
||||
if (asks === 1) return firstTurn.promise;
|
||||
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||
return '第二条完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('weixin-prefetch-first', '先等待'));
|
||||
await eventually(() => asks === 1);
|
||||
const second = bridge.accept(message('weixin-prefetch-second', '', {
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: { file_name: 'queued.bin', media: {} },
|
||||
}],
|
||||
}));
|
||||
|
||||
await eventually(() => downloads === 1, 'queued Weixin file did not start downloading');
|
||||
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||
firstTurn.resolve('第一条完成');
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue