mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-14 10:50:44 +08:00
feat: support image prompts across IM channels
This commit is contained in:
parent
99a877c640
commit
65c842c045
40 changed files with 3806 additions and 274 deletions
|
|
@ -1,11 +1,15 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createCipheriv, randomBytes } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWeixinApi,
|
||||
decryptWeixinImage,
|
||||
extractWeixinText,
|
||||
normalizeWeixinApiBaseUrl,
|
||||
parseWeixinImageAesKey,
|
||||
splitWeixinText,
|
||||
weixinImageDownloadUrl,
|
||||
WeixinApiError,
|
||||
} from '../../../src/channels/weixin/weixin-api.mjs';
|
||||
|
||||
|
|
@ -17,6 +21,76 @@ function jsonResponse(value, init) {
|
|||
});
|
||||
}
|
||||
|
||||
function encryptImage(plaintext, key) {
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null);
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
}
|
||||
|
||||
test('iLink image references download lazily from the canonical CDN and decrypt AES-128-ECB', async () => {
|
||||
const key = randomBytes(16);
|
||||
const plaintext = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
const ciphertext = encryptImage(plaintext, key);
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return new Response(ciphertext, {
|
||||
headers: { 'content-length': String(ciphertext.length) },
|
||||
});
|
||||
},
|
||||
});
|
||||
const images = api.inboundImages({
|
||||
item_list: [{
|
||||
type: 2,
|
||||
image_item: {
|
||||
aeskey: key.toString('hex'),
|
||||
media: { encrypt_query_param: 'one=two&three=four' },
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(calls.length, 0);
|
||||
const loaded = await images[0].load({ maxBytes: 1_024 });
|
||||
|
||||
assert.equal(loaded.equals(plaintext), true);
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
'https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=one%3Dtwo%26three%3Dfour',
|
||||
);
|
||||
assert.equal(calls[0].init.method, 'GET');
|
||||
assert.equal(calls[0].init.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);
|
||||
assert.equal(parseWeixinImageAesKey({
|
||||
media: { aes_key: key.toString('base64') },
|
||||
}).equals(key), true);
|
||||
assert.equal(parseWeixinImageAesKey({
|
||||
media: { aes_key: Buffer.from(key.toString('hex'), 'ascii').toString('base64') },
|
||||
}).equals(key), true);
|
||||
assert.throws(
|
||||
() => parseWeixinImageAesKey({ aeskey: 'not-a-key' }),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'invalid-image-key',
|
||||
);
|
||||
assert.equal(
|
||||
weixinImageDownloadUrl({ full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/download?id=one#fragment' }),
|
||||
'https://novac2c.cdn.weixin.qq.com/c2c/download?id=one',
|
||||
);
|
||||
assert.throws(
|
||||
() => weixinImageDownloadUrl({ full_url: 'https://attacker.example/c2c/download?id=one' }),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'untrusted-image-url',
|
||||
);
|
||||
|
||||
const encrypted = encryptImage(Buffer.from('image payload'), key);
|
||||
assert.equal(decryptWeixinImage(encrypted, key).toString(), 'image payload');
|
||||
});
|
||||
|
||||
test('QR login uses the Tencent iLink headers and keeps local tokens out of the URL', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
|
|
|
|||
|
|
@ -63,6 +63,101 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
const PNG_BYTES = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [{ name: 'image', data: PNG_BYTES }],
|
||||
sendText: async (request) => sent.push(request),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
prompts.push({ sessionId, content });
|
||||
return '微信图片已识别';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-image', '', {
|
||||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.equal(prompts[0].sessionId, 'session-image');
|
||||
assert.deepEqual(prompts[0].content.map(({ type }) => type), ['text', 'image']);
|
||||
assert.equal(prompts[0].content[0].text, '请分析这张图片。');
|
||||
assert.equal(prompts[0].content[1].mediaType, 'image/png');
|
||||
assert.equal(Buffer.from(prompts[0].content[1].data, 'base64').equals(PNG_BYTES), true);
|
||||
assert.equal(sent.at(-1).text, '微信图片已识别');
|
||||
assert.equal(sent.at(-1).contextToken, 'context-weixin-image');
|
||||
assert.equal(fixture.seen.has('weixin-image'), true);
|
||||
});
|
||||
|
||||
test('Weixin authorizes the sender before resolving encrypted image references', async () => {
|
||||
let imageExtractions = 0;
|
||||
let asks = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => { imageExtractions += 1; return [{ data: PNG_BYTES }]; },
|
||||
sendText: async () => assert.fail('an unauthorized sender must not receive a reply'),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ask: async () => { asks += 1; return 'unexpected'; } },
|
||||
state: stateFixture().state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-image-other', '', {
|
||||
from_user_id: 'other-user',
|
||||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
|
||||
assert.equal(imageExtractions, 0);
|
||||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('Weixin returns a specific retry message when encrypted image loading fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [{ load: async () => { throw new Error('CDN unavailable'); } }],
|
||||
sendText: async (request) => sent.push(request),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => assert.fail('a failed image must not reach Harness'),
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-image-error', '', {
|
||||
item_list: [{ type: 2, image_item: { media: {} } }],
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
|
||||
assert.equal(fixture.seen.has('weixin-image-error'), true);
|
||||
});
|
||||
|
||||
test('Weixin executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-compact');
|
||||
|
|
@ -536,7 +631,10 @@ test('Weixin serializes an invalid pending reply before the following valid answ
|
|||
await eventually(() => sent.some(({ text }) => text.includes('请给出有效文字答案')));
|
||||
const invalid = bridge.accept(message('invalid-image', '', {
|
||||
message_type: 3,
|
||||
item_list: [{ type: 2 }],
|
||||
item_list: [
|
||||
{ type: 1, text_item: { text: '伪装成答案的图片说明' } },
|
||||
{ type: 2, image_item: { media: {} } },
|
||||
],
|
||||
}));
|
||||
await invalidNoticeStarted.promise;
|
||||
const valid = bridge.accept(message('invalid-valid', '真正的答案'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue