mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 10:30:47 +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
|
|
@ -113,6 +113,145 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk image downloads exchange downloadCode with the callback robotCode and do not forward auth', async () => {
|
||||
const imageBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02,
|
||||
]);
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
const href = url.toString();
|
||||
calls.push({ url: href, options });
|
||||
if (href === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (href === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
|
||||
return jsonResponse({ downloadUrl: 'https://download.oss-cn-hangzhou.aliyuncs.com/opaque-image' });
|
||||
}
|
||||
return new Response(imageBytes, {
|
||||
headers: { 'content-length': String(imageBytes.length) },
|
||||
});
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
|
||||
const loaded = await api.downloadImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'opaque-download-code',
|
||||
maxBytes: 1_024,
|
||||
});
|
||||
|
||||
assert.equal(loaded.equals(imageBytes), true);
|
||||
assert.deepEqual(JSON.parse(calls[1].options.body), {
|
||||
downloadCode: 'opaque-download-code',
|
||||
robotCode: 'robot-from-callback',
|
||||
});
|
||||
assert.equal(
|
||||
calls[1].options.headers['x-acs-dingtalk-access-token'],
|
||||
'image-access-token',
|
||||
);
|
||||
assert.equal(calls[2].options.method, 'GET');
|
||||
assert.equal(calls[2].options.redirect, 'manual');
|
||||
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
|
||||
});
|
||||
|
||||
test('DingTalk upgrades its HTTP temporary image URL to HTTPS without changing the signed request', async () => {
|
||||
const imageBytes = Buffer.from([0xff, 0xd8, 0xff, 0x01]);
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
|
||||
return jsonResponse({
|
||||
downloadUrl: 'http://download.example.test:80/private/image?signature=opaque%2Bvalue',
|
||||
});
|
||||
}
|
||||
return new Response(imageBytes, { status: 200 });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
|
||||
assert.deepEqual(await api.downloadImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'opaque-download-code',
|
||||
maxBytes: 1_024,
|
||||
}), imageBytes);
|
||||
|
||||
assert.equal(
|
||||
calls[2].url,
|
||||
'https://download.example.test/private/image?signature=opaque%2Bvalue',
|
||||
);
|
||||
assert.equal(calls[2].options.headers?.['content-type'], undefined);
|
||||
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
|
||||
});
|
||||
|
||||
test('DingTalk image exchange errors preserve the provider code without exposing its message', async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
return jsonResponse({
|
||||
code: 'invalidParameter.robotCode.auth',
|
||||
message: 'response may contain platform details',
|
||||
}, { status: 400 });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
api.downloadImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'opaque-download-code',
|
||||
maxBytes: 1_024,
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'image-download-address-failed');
|
||||
assert.equal(error.status, 400);
|
||||
assert.equal(error.providerCode, 'invalidParameter.robotCode.auth');
|
||||
assert.equal(error.cause?.code, 'http-error');
|
||||
assert.doesNotMatch(error.message, /platform details/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk image content failures do not expose network or signed-URL details', async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
|
||||
return jsonResponse({ downloadUrl: 'https://download.example.test/private?signature=hidden' });
|
||||
}
|
||||
const cause = new Error('socket details must stay private');
|
||||
cause.code = 'ECONNRESET';
|
||||
throw new TypeError('fetch failed', { cause });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
api.downloadImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'opaque-download-code',
|
||||
maxBytes: 1_024,
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'image-content-download-failed');
|
||||
assert.equal(error.providerCode, undefined);
|
||||
assert.equal(error.cause?.cause?.code, 'ECONNRESET');
|
||||
assert.doesNotMatch(error.message, /private|signature|socket details|hidden/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('session webhook validation accepts only HTTPS DingTalk hosts on the default port', () => {
|
||||
assert.equal(
|
||||
normalizeDingtalkSessionWebhook('https://dingtalk.com/reply?ticket=one'),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,193 @@ function stateFixture() {
|
|||
};
|
||||
}
|
||||
|
||||
const PNG_BYTES = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async (request) => {
|
||||
downloads.push(request);
|
||||
return PNG_BYTES;
|
||||
},
|
||||
sendText: async (request) => sent.push(request),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
prompts.push({ sessionId, content });
|
||||
return '钉钉图片已识别';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-picture', '', {
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
content: JSON.stringify({ downloadCode: 'opaque-picture-code' }),
|
||||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
|
||||
assert.equal(downloads.length, 1);
|
||||
assert.equal(downloads[0].clientId, 'ding-client');
|
||||
assert.equal(downloads[0].clientSecret, 'host-secret');
|
||||
assert.equal(downloads[0].robotCode, 'robot-from-callback');
|
||||
assert.equal(downloads[0].downloadCode, 'opaque-picture-code');
|
||||
assert.equal(downloads[0].maxBytes, 5 * 1024 * 1024);
|
||||
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, '钉钉图片已识别');
|
||||
});
|
||||
|
||||
test('DingTalk richText preserves its caption and all picture download codes', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-rich-image');
|
||||
const codes = [];
|
||||
let prompt;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async ({ downloadCode }) => {
|
||||
codes.push(downloadCode);
|
||||
return PNG_BYTES;
|
||||
},
|
||||
sendText: async () => {},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, content) => { prompt = content; return '完成'; },
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-rich-picture', '', {
|
||||
msgtype: 'richText',
|
||||
text: undefined,
|
||||
robotCode: 'robot-from-callback',
|
||||
content: {
|
||||
richText: [
|
||||
{ type: 'text', text: '请对比' },
|
||||
{ type: 'picture', pictureDownloadCode: 'picture-one' },
|
||||
{ type: 'text', text: { content: '这两张图' } },
|
||||
{ type: 'picture', downloadCode: 'picture-two' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
assert.deepEqual(codes, ['picture-one', 'picture-two']);
|
||||
assert.deepEqual(prompt.map(({ type }) => type), ['text', 'image', 'image']);
|
||||
assert.equal(prompt[0].text, '请对比\n这两张图');
|
||||
});
|
||||
|
||||
test('DingTalk checks the group mention before downloading a picture', async () => {
|
||||
let downloads = 0;
|
||||
let asks = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async () => { downloads += 1; return PNG_BYTES; },
|
||||
sendText: async () => {},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ask: async () => { asks += 1; return 'unexpected'; } },
|
||||
state: stateFixture().state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-unmentioned-picture', '', {
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
content: { downloadCode: 'private-picture' },
|
||||
robotCode: 'robot-from-callback',
|
||||
conversationType: '2',
|
||||
conversationId: 'group-image',
|
||||
isInAtList: false,
|
||||
}));
|
||||
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('DingTalk returns a specific retry message when picture download fails', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async () => { throw new Error('temporary URL expired'); },
|
||||
sendText: async (request) => sent.push(request),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => assert.fail('a failed image must not reach Harness'),
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-picture-error', '', {
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
content: { downloadCode: 'expired-picture' },
|
||||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
|
||||
});
|
||||
|
||||
test('DingTalk distinguishes download-address failures from temporary-file failures', async () => {
|
||||
for (const [code, expected] of [
|
||||
[
|
||||
'image-download-address-failed',
|
||||
'钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。',
|
||||
],
|
||||
['image-content-download-failed', '钉钉返回的图片临时地址无法读取,请重新发送。'],
|
||||
]) {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadImage: async () => {
|
||||
const error = new Error('safe stage marker');
|
||||
error.code = code;
|
||||
throw error;
|
||||
},
|
||||
sendText: async (request) => sent.push(request),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: { ask: async () => assert.fail('a failed image must not reach Harness') },
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message(`dingtalk-${code}`, '', {
|
||||
msgtype: 'picture',
|
||||
text: undefined,
|
||||
content: { downloadCode: 'opaque-picture-code' },
|
||||
robotCode: 'robot-from-callback',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1).text, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-compact');
|
||||
|
|
|
|||
|
|
@ -210,6 +210,63 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
assert.equal(unmentionedReply.addressed, false);
|
||||
});
|
||||
|
||||
test('Discord exposes image attachments through bounded CDN loaders', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const message = normalizeDiscordMessage({
|
||||
id: '111111111111111120',
|
||||
channel_id: '222222222222222220',
|
||||
author: { id: '333333333333333330', bot: false },
|
||||
content: '',
|
||||
attachments: [{
|
||||
id: '555555555555555550',
|
||||
filename: 'screen.png',
|
||||
content_type: 'image/png',
|
||||
size: png.length,
|
||||
url: 'https://cdn.discordapp.com/attachments/222/555/screen.png?ex=test',
|
||||
}, {
|
||||
id: '555555555555555551',
|
||||
filename: 'notes.txt',
|
||||
content_type: 'text/plain',
|
||||
size: 4,
|
||||
url: 'https://cdn.discordapp.com/attachments/222/555/notes.txt',
|
||||
}, {
|
||||
id: '555555555555555552',
|
||||
filename: 'camera.jpg',
|
||||
size: png.length,
|
||||
url: 'https://cdn.discordapp.com/attachments/222/555/camera.jpg',
|
||||
}],
|
||||
}, '1234567890123456789', {
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(png, { status: 200, headers: { 'content-length': String(png.length) } });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(message.content, '');
|
||||
assert.equal(message.images.length, 2);
|
||||
assert.deepEqual({
|
||||
name: message.images[0].name,
|
||||
mediaType: message.images[0].mediaType,
|
||||
size: message.images[0].size,
|
||||
}, { name: 'screen.png', mediaType: 'image/png', size: png.length });
|
||||
assert.equal(message.images[1].mediaType, 'image/jpeg');
|
||||
assert.deepEqual(await message.images[0].load({ maxBytes: 100 }), png);
|
||||
assert.equal(calls[0].url.hostname, 'cdn.discordapp.com');
|
||||
assert.equal(calls[0].options.redirect, 'manual');
|
||||
|
||||
const unsafe = normalizeDiscordMessage({
|
||||
id: '111111111111111121',
|
||||
channel_id: '222222222222222220',
|
||||
author: { id: '333333333333333330', bot: false },
|
||||
attachments: [{
|
||||
filename: 'screen.png', content_type: 'image/png', size: 8,
|
||||
url: 'https://example.com/internal.png',
|
||||
}],
|
||||
}, '1234567890123456789', { fetchImpl: async () => assert.fail('must not fetch') });
|
||||
await assert.rejects(() => unsafe.images[0].load({ maxBytes: 100 }), /messaging platform/);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -206,6 +213,177 @@ test('bridge maps a Feishu conversation to a persistent Harness session and repl
|
|||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('bridge downloads an inbound Feishu image once and submits structured Harness content', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-image']]);
|
||||
const downloaded = [];
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const client = {
|
||||
im: { v1: {
|
||||
messageResource: { get: async (request) => {
|
||||
downloaded.push(request);
|
||||
return {
|
||||
headers: { 'content-length': String(PNG_1X1.length) },
|
||||
getReadableStream: () => Readable.from([PNG_1X1]),
|
||||
};
|
||||
} },
|
||||
message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0, data: { message_id: 'om_image_reply' } };
|
||||
} },
|
||||
} },
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
asked.push({ sessionId, content });
|
||||
return '看到了一张图片';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
const imageEvent = event('om_image_input', '', {
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_input' }),
|
||||
});
|
||||
|
||||
await bridge.accept(imageEvent);
|
||||
await bridge.accept(imageEvent);
|
||||
await bridge.accept({
|
||||
...event('om_image_unauthorized', '', {
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_unauthorized' }),
|
||||
}),
|
||||
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
|
||||
});
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(downloaded, [{
|
||||
path: { message_id: 'om_image_input', file_key: 'img_input' },
|
||||
params: { type: 'image' },
|
||||
}]);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-image',
|
||||
content: [
|
||||
{ type: 'text', text: DEFAULT_IMAGE_PROMPT },
|
||||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||||
],
|
||||
}]);
|
||||
assert.deepEqual(sent, ['看到了一张图片']);
|
||||
});
|
||||
|
||||
test('bridge sends Feishu post text and all embedded images as one structured prompt', async () => {
|
||||
const fixture = stateFixture([['group:oc_post_group', 'session-post']]);
|
||||
const downloaded = [];
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const client = {
|
||||
im: { v1: {
|
||||
messageResource: { get: async (request) => {
|
||||
downloaded.push(request);
|
||||
return {
|
||||
headers: { 'content-length': String(PNG_1X1.length) },
|
||||
getReadableStream: () => Readable.from([PNG_1X1]),
|
||||
};
|
||||
} },
|
||||
message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0, data: { message_id: 'om_post_reply' } };
|
||||
} },
|
||||
} },
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
asked.push({ sessionId, content });
|
||||
return '两张图片都已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
const postEvent = event('om_post_input', '', {
|
||||
message_type: 'post',
|
||||
chat_type: 'group',
|
||||
chat_id: 'oc_post_group',
|
||||
mentions: [{ key: '@_bot_1' }],
|
||||
content: JSON.stringify({
|
||||
title: '比较截图',
|
||||
content: [
|
||||
[
|
||||
{ tag: 'at', user_id: '@_bot_1', user_name: '机器人' },
|
||||
{ tag: 'text', text: '@_bot_1 请比较 ' },
|
||||
{ tag: 'a', text: '这两张图', href: 'https://example.com' },
|
||||
],
|
||||
[{ tag: 'img', image_key: 'img_post_first' }],
|
||||
[{ tag: 'img', image_key: 'img_post_second' }],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await bridge.accept(postEvent);
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(downloaded, [
|
||||
{
|
||||
path: { message_id: 'om_post_input', file_key: 'img_post_first' },
|
||||
params: { type: 'image' },
|
||||
},
|
||||
{
|
||||
path: { message_id: 'om_post_input', file_key: 'img_post_second' },
|
||||
params: { type: 'image' },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-post',
|
||||
content: [
|
||||
{ type: 'text', text: '比较截图\n请比较 这两张图' },
|
||||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||||
],
|
||||
}]);
|
||||
assert.deepEqual(sent, ['两张图片都已收到']);
|
||||
});
|
||||
|
||||
test('text inside a Feishu post is a model prompt rather than a local command', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-post-command']]);
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async ({ text }) => sent.push(text)),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
asked.push({ sessionId, content });
|
||||
return '按普通内容处理';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('om_post_command', '', {
|
||||
message_type: 'post',
|
||||
content: JSON.stringify({ content: [[{ tag: 'text', text: '/new' }]] }),
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.equal(fixture.sessions.get('p2p:ou_user'), 'session-post-command');
|
||||
assert.deepEqual(asked, [{ sessionId: 'session-post-command', content: '/new' }]);
|
||||
assert.deepEqual(sent, ['按普通内容处理']);
|
||||
});
|
||||
|
||||
test('a threaded Feishu reply answers a pending Harness question before the original turn queue', async () => {
|
||||
const sent = [];
|
||||
const streamed = [];
|
||||
|
|
@ -676,7 +854,7 @@ test('a queued next prompt stays separate while a failed interaction response is
|
|||
assert.deepEqual(sent.slice(-2).map(({ text }) => text), ['第一轮完成', '第二轮完成']);
|
||||
});
|
||||
|
||||
test('a non-text pending reply does not block the valid answer behind it', async () => {
|
||||
test('a rich-post pending reply does not block the valid text answer behind it', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-invalid-reply']]);
|
||||
const sent = [];
|
||||
const invalidNoticeStarted = deferred();
|
||||
|
|
@ -722,9 +900,14 @@ test('a non-text pending reply does not block the valid answer behind it', async
|
|||
|
||||
const first = bridge.accept(event('invalid-reply-start', '启动交互'));
|
||||
await eventually(() => sent.some(({ text }) => text.includes('请给出有效文字答案')));
|
||||
const invalid = bridge.accept(event('invalid-reply-image', '', {
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img-test' }),
|
||||
const invalid = bridge.accept(event('invalid-reply-post', '', {
|
||||
message_type: 'post',
|
||||
content: JSON.stringify({
|
||||
content: [
|
||||
[{ tag: 'text', text: '这不是文字回答' }],
|
||||
[{ tag: 'img', image_key: 'img-test' }],
|
||||
],
|
||||
}),
|
||||
}));
|
||||
await invalidNoticeStarted.promise;
|
||||
const valid = bridge.accept(event('invalid-reply-valid', '真正的答案'));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
conversationKey,
|
||||
extractInboundMessage,
|
||||
extractText,
|
||||
isAllowedSender,
|
||||
isBotSender,
|
||||
splitText,
|
||||
} from '../../../src/channels/feishu/message-utils.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
test('extractText removes bot mentions', () => {
|
||||
const event = {
|
||||
message: {
|
||||
|
|
@ -19,6 +26,121 @@ test('extractText removes bot mentions', () => {
|
|||
assert.equal(extractText(event), '你好');
|
||||
});
|
||||
|
||||
test('extractInboundMessage lazily downloads a Feishu image resource as a bounded stream', async () => {
|
||||
const calls = [];
|
||||
const event = {
|
||||
message: {
|
||||
message_id: 'om_image',
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_test' }),
|
||||
},
|
||||
};
|
||||
const client = { im: { v1: { messageResource: { get: async (request) => {
|
||||
calls.push(request);
|
||||
return {
|
||||
headers: { 'content-length': String(PNG_1X1.length) },
|
||||
getReadableStream: () => Readable.from([
|
||||
PNG_1X1.subarray(0, 12),
|
||||
PNG_1X1.subarray(12),
|
||||
]),
|
||||
};
|
||||
} } } } };
|
||||
|
||||
const message = extractInboundMessage(event, client);
|
||||
assert.equal(message.content, '');
|
||||
assert.equal(message.images.length, 1);
|
||||
assert.deepEqual(await message.images[0].load({ maxBytes: 1024 }), PNG_1X1);
|
||||
assert.deepEqual(calls, [{
|
||||
path: { message_id: 'om_image', file_key: 'img_test' },
|
||||
params: { type: 'image' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('extractInboundMessage preserves visible Feishu post text and every embedded image', async () => {
|
||||
const calls = [];
|
||||
const event = {
|
||||
message: {
|
||||
message_id: 'om_post',
|
||||
message_type: 'post',
|
||||
mentions: [{ key: '@_bot_1' }],
|
||||
content: JSON.stringify({
|
||||
title: '截图比较',
|
||||
content: [
|
||||
[
|
||||
{ tag: 'at', user_id: '@_bot_1', user_name: '机器人' },
|
||||
{ tag: 'text', text: '@_bot_1 请查看 ' },
|
||||
{ tag: 'a', text: '第一处', href: 'https://example.com/one' },
|
||||
{ tag: 'link', text: ' 和第二处', href: 'https://example.com/two' },
|
||||
],
|
||||
[{ tag: 'img', image_key: 'img_first' }],
|
||||
[{ tag: 'text', text: '补充说明' }],
|
||||
[
|
||||
{ tag: 'img', image_key: 'img_second' },
|
||||
{ tag: 'img', image_key: ' ' },
|
||||
],
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const client = { im: { v1: { messageResource: { get: async (request) => {
|
||||
calls.push(request);
|
||||
return {
|
||||
headers: { 'content-length': String(PNG_1X1.length) },
|
||||
getReadableStream: () => Readable.from([PNG_1X1]),
|
||||
};
|
||||
} } } } };
|
||||
|
||||
const message = extractInboundMessage(event, client);
|
||||
assert.equal(extractText(event), null, 'rich posts must not become interaction replies');
|
||||
assert.equal(message.content, '截图比较\n请查看 第一处 和第二处\n补充说明');
|
||||
assert.equal(message.images.length, 2);
|
||||
assert.deepEqual(await Promise.all(message.images.map((image) => image.load({ maxBytes: 1024 }))), [
|
||||
PNG_1X1,
|
||||
PNG_1X1,
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
path: { message_id: 'om_post', file_key: 'img_first' },
|
||||
params: { type: 'image' },
|
||||
},
|
||||
{
|
||||
path: { message_id: 'om_post', file_key: 'img_second' },
|
||||
params: { type: 'image' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Feishu image loading rejects declared or streamed data above the caller limit', async () => {
|
||||
for (const resource of [
|
||||
{
|
||||
headers: { 'content-length': '5' },
|
||||
getReadableStream: () => Readable.from([Buffer.alloc(5)]),
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
getReadableStream: () => Readable.from([Buffer.alloc(2), Buffer.alloc(3)]),
|
||||
},
|
||||
]) {
|
||||
const message = extractInboundMessage({
|
||||
message: {
|
||||
message_id: 'om_large',
|
||||
message_type: 'image',
|
||||
content: JSON.stringify({ image_key: 'img_large' }),
|
||||
},
|
||||
}, { im: { v1: { messageResource: { get: async () => resource } } } });
|
||||
await assert.rejects(message.images[0].load({ maxBytes: 4 }), /limit|exceeds/);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed Feishu image content does not create a downloadable image reference', () => {
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'image', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'post', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
});
|
||||
|
||||
test('conversationKey isolates p2p users and groups', () => {
|
||||
assert.equal(conversationKey({
|
||||
sender: { sender_id: { open_id: 'ou_test' } },
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
assert.equal(fx.getSdkOptions().addons.preset, false);
|
||||
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
|
|
|
|||
|
|
@ -51,6 +51,189 @@ function message(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
const PNG_BYTES = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
test('QQ sends image-only attachments to Harness and accepts the SDK file MIME fallback', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
|
||||
const prompts = [];
|
||||
const downloads = [];
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
prompts.push({ sessionId, content });
|
||||
return '看到图片了';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
fetchImpl: async (url, init) => {
|
||||
downloads.push({ url: url.toString(), init });
|
||||
return new Response(PNG_BYTES, { headers: { 'content-type': 'application/octet-stream' } });
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-image',
|
||||
content: '',
|
||||
attachments: [{
|
||||
content_type: 'file',
|
||||
filename: 'diagram.PNG',
|
||||
size: PNG_BYTES.length,
|
||||
url: 'https://multimedia.nt.qq.com.cn/download/opaque',
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.equal(downloads.length, 1);
|
||||
assert.equal(downloads[0].init.method, 'GET');
|
||||
assert.equal(downloads[0].init.redirect, 'manual');
|
||||
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(prompts[0].content[1].name, 'diagram.PNG');
|
||||
assert.equal(Buffer.from(prompts[0].content[1].data, 'base64').equals(PNG_BYTES), true);
|
||||
assert.deepEqual(sent, ['看到图片了']);
|
||||
assert.equal(fixture.seen.has('qq-image'), true);
|
||||
});
|
||||
|
||||
test('QQ checks sender and group mention before downloading image attachments', async () => {
|
||||
let downloads = 0;
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { asks += 1; return 'unexpected'; },
|
||||
},
|
||||
state: stateFixture().state,
|
||||
fetchImpl: async () => { downloads += 1; return new Response(PNG_BYTES); },
|
||||
});
|
||||
const attachment = {
|
||||
content_type: 'image/png',
|
||||
filename: 'private.png',
|
||||
url: 'https://multimedia.nt.qq.com.cn/download/private',
|
||||
};
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-image-other',
|
||||
senderId: 'other-openid',
|
||||
content: '',
|
||||
attachments: [attachment],
|
||||
}));
|
||||
await bridge.accept(message({
|
||||
kind: 'group',
|
||||
rawEventType: 'GROUP_MESSAGE_CREATE',
|
||||
groupOpenid: 'group-1',
|
||||
messageId: 'qq-image-unmentioned',
|
||||
content: '',
|
||||
attachments: [attachment],
|
||||
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'qq-image-unmentioned' },
|
||||
}));
|
||||
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(asks, 0);
|
||||
});
|
||||
|
||||
test('QQ rejects non-platform image URLs without fetching and returns a retryable image error', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
|
||||
const sent = [];
|
||||
let downloads = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => assert.fail('an untrusted image must not reach Harness'),
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
fetchImpl: async () => { downloads += 1; return new Response(PNG_BYTES); },
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-image-untrusted',
|
||||
content: '这是什么',
|
||||
attachments: [{
|
||||
content_type: 'image/png',
|
||||
filename: 'photo.png',
|
||||
url: 'https://attacker.example/photo.png',
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.equal(downloads, 0);
|
||||
assert.deepEqual(sent, ['图片下载失败,请重新发送后再试。']);
|
||||
assert.equal(fixture.seen.has('qq-image-untrusted'), true);
|
||||
});
|
||||
|
||||
test('QQ does not use an image caption as a pending Harness answer', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-question-image']]);
|
||||
const sent = [];
|
||||
const answered = deferred();
|
||||
let submitted;
|
||||
let downloads = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
async ask(sessionId, _text, options) {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'qq-question-image',
|
||||
rpcId: 'qq-question-image',
|
||||
sessionId,
|
||||
payload: {
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
question: '请选择环境',
|
||||
options: [{ label: '生产环境' }],
|
||||
}],
|
||||
},
|
||||
async respond(result) {
|
||||
submitted = result;
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answered.promise;
|
||||
return '已完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
fetchImpl: async () => { downloads += 1; return new Response(PNG_BYTES); },
|
||||
});
|
||||
|
||||
const first = bridge.accept(message({ messageId: 'qq-question-start', content: '开始提问' }));
|
||||
await eventually(() => sent.some((text) => text.includes('请选择环境')));
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-question-image-answer',
|
||||
content: '生产环境',
|
||||
attachments: [{
|
||||
content_type: 'image/png',
|
||||
filename: 'answer.png',
|
||||
url: 'https://multimedia.nt.qq.com.cn/download/answer',
|
||||
}],
|
||||
}));
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(submitted, undefined);
|
||||
assert.equal(sent.at(-1), '请用文字回答当前问题。');
|
||||
|
||||
await bridge.accept(message({ messageId: 'qq-question-text-answer', content: '生产环境' }));
|
||||
await first;
|
||||
assert.deepEqual(submitted.value.answer.answers, [{
|
||||
id: 'environment',
|
||||
selected: ['生产环境'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('QQ executes /compact for the bound Session without prompting the model', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-compact']]);
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SlackRuntime,
|
||||
normalizeSlackEvent,
|
||||
} from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import { SLACK_APP_MANIFEST_YAML } from '../../../src/channels/slack/manifest.mjs';
|
||||
import {
|
||||
SLACK_ENDPOINTS,
|
||||
createSlackRpcHandler,
|
||||
|
|
@ -133,6 +134,146 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
|
|||
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
|
||||
});
|
||||
|
||||
test('Slack downloads private files with the bot token from Slack hosts only', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(png, { status: 200, headers: { 'content-length': String(png.length) } });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await api.downloadFile({
|
||||
url: 'https://files.slack.com/files-pri/T123-F123/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), png);
|
||||
assert.equal(calls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
assert.equal(calls[0].options.redirect, 'manual');
|
||||
assert.deepEqual(await api.downloadFile({
|
||||
url: 'https://slack.com/files-pri/T123-F124/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), png);
|
||||
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
assert.equal(calls[1].url.hostname, 'files.slack.com');
|
||||
await assert.rejects(() => api.downloadFile({
|
||||
url: 'https://example.com/internal.png', maxBytes: 100,
|
||||
}), /messaging platform/);
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
const redirectCalls = [];
|
||||
const redirectingApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
redirectCalls.push({ url, options });
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://example.com/leak-token' },
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => redirectingApi.downloadFile({
|
||||
url: 'https://slack.com/files-pri/T123-F125/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), (error) => {
|
||||
assert.equal(error.code, 'image-redirect-blocked');
|
||||
return true;
|
||||
});
|
||||
assert.equal(redirectCalls.length, 1);
|
||||
assert.equal(redirectCalls[0].url.hostname, 'files.slack.com');
|
||||
assert.equal(redirectCalls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
|
||||
});
|
||||
|
||||
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
|
||||
const workspaceCalls = [];
|
||||
const workspaceApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
workspaceCalls.push({ url, options });
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://workspace-name.slack.com/?redir=%2Ffiles-pri%2Fsecret' },
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => workspaceApi.downloadFile({
|
||||
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), (error) => {
|
||||
assert.equal(error.code, 'slack-file-access-required');
|
||||
assert.match(error.userMessage, /files:read/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(workspaceCalls.length, 1);
|
||||
|
||||
const missingScopeCalls = [];
|
||||
const missingScopeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
missingScopeCalls.push({ url, options });
|
||||
if (url.pathname.endsWith('/auth.test')) {
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
team_id: 'T12345678',
|
||||
user_id: 'U12345678',
|
||||
bot_id: 'B12345678',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-oauth-scopes': 'app_mentions:read,chat:write,im:history',
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'https://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await missingScopeApi.authTest();
|
||||
await assert.rejects(() => missingScopeApi.downloadFile({
|
||||
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), (error) => {
|
||||
assert.equal(error.code, 'slack-file-access-required');
|
||||
assert.match(error.userMessage, /files:read/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(missingScopeCalls.length, 2);
|
||||
assert.deepEqual(missingScopeCalls.map((call) => call.url.hostname), [
|
||||
'slack.com', 'files.slack.com',
|
||||
]);
|
||||
|
||||
for (const location of [
|
||||
'https://example.com/leak-token',
|
||||
'http://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
||||
'https://files-origin.slack.com/files-pri/T123-F126/download/image.png',
|
||||
'https://files-origin.slack.com/files-pri/T123-F999/download/image.png',
|
||||
'https://files-origin.slack.com/files-pri/T123-F126/download/image.png?changed=1',
|
||||
]) {
|
||||
const unsafeCalls = [];
|
||||
const unsafeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
unsafeCalls.push({ url, options });
|
||||
return new Response(null, { status: 302, headers: { location } });
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => unsafeApi.downloadFile({
|
||||
url: 'https://files.slack.com/files-pri/T123-F126/download/image.png',
|
||||
maxBytes: 100,
|
||||
}), (error) => {
|
||||
assert.equal(error.code, 'image-redirect-blocked');
|
||||
return true;
|
||||
});
|
||||
assert.equal(unsafeCalls.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack controller stores two protected credential references and exposes neither token', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-slack-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
@ -260,6 +401,62 @@ test('Slack normalizes direct messages and addressed channel events', () => {
|
|||
assert.equal(botMessage, null);
|
||||
});
|
||||
|
||||
test('Slack accepts image file shares and keeps other files out of image prompts', async () => {
|
||||
const loads = [];
|
||||
const direct = normalizeSlackEvent({
|
||||
event_id: 'Ev010',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'message',
|
||||
subtype: 'file_share',
|
||||
channel_type: 'im',
|
||||
channel: 'D12345678',
|
||||
user: 'U87654321',
|
||||
ts: '1700000000.010',
|
||||
text: '识别一下',
|
||||
files: [{
|
||||
id: 'F12345678', name: 'screen.png', mimetype: 'image/png', size: 2_000,
|
||||
url_private_download: 'https://files.slack.com/files-pri/T123-F123/download/screen.png',
|
||||
}, {
|
||||
id: 'F12345679', name: 'notes.txt', mimetype: 'text/plain', size: 20,
|
||||
url_private: 'https://files.slack.com/files-pri/T123-F124/notes.txt',
|
||||
}],
|
||||
},
|
||||
}, 'U12345678', {
|
||||
loadFile: async (url, options) => {
|
||||
loads.push({ url, options });
|
||||
return Buffer.from('image');
|
||||
},
|
||||
});
|
||||
assert.equal(direct.images.length, 1);
|
||||
assert.equal(direct.images[0].name, 'screen.png');
|
||||
await direct.images[0].load({ maxBytes: 5_000 });
|
||||
assert.match(loads[0].url, /screen\.png$/);
|
||||
|
||||
const group = normalizeSlackEvent({
|
||||
event_id: 'Ev011',
|
||||
team_id: 'T12345678',
|
||||
event: {
|
||||
type: 'app_mention', channel: 'C12345678', user: 'U87654321', ts: '1700000000.011',
|
||||
text: '<@U12345678>',
|
||||
files: [{
|
||||
id: 'F12345680', name: 'photo.jpg', mimetype: 'image/jpeg', size: 1_000,
|
||||
url_private: 'https://files.slack.com/files-pri/T123-F125/photo.jpg',
|
||||
}],
|
||||
},
|
||||
}, 'U12345678');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.images.length, 1);
|
||||
|
||||
assert.equal(normalizeSlackEvent({
|
||||
event_id: 'Ev012',
|
||||
event: {
|
||||
type: 'message', subtype: 'message_changed', channel_type: 'im', channel: 'D12345678',
|
||||
user: 'U87654321', ts: '1700000000.012', text: '', files: [],
|
||||
},
|
||||
}, 'U12345678'), null);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
|
|
|
|||
|
|
@ -120,6 +120,37 @@ test('Telegram API validates a Bot Token without exposing it in requests or erro
|
|||
});
|
||||
});
|
||||
|
||||
test('Telegram API resolves and downloads files without exposing arbitrary paths', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/getFile')) {
|
||||
return jsonResponse({ ok: true, result: { file_path: 'photos/file_1.png' } });
|
||||
}
|
||||
return new Response(png, { status: 200, headers: { 'content-length': String(png.length) } });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].options.method, 'POST');
|
||||
assert.equal(calls[1].options.method, 'GET');
|
||||
assert.equal(calls[1].url.hostname, 'api.telegram.org');
|
||||
assert.match(calls[1].url.pathname, /\/file\/bot.+\/photos\/file_1\.png$/);
|
||||
|
||||
const unsafeApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: true, result: { file_path: '../secret' } }),
|
||||
});
|
||||
await assert.rejects(() => unsafeApi.downloadFile({ fileId: 'AgAC_test-file' }), (error) => {
|
||||
assert.match(error.message, /invalid file path/);
|
||||
assert.doesNotMatch(error.message, new RegExp(TOKEN.replaceAll(':', '\\:')));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram config and controller store only a credential reference in bot data', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
|
@ -252,6 +283,73 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
|||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||
});
|
||||
|
||||
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
||||
const loads = [];
|
||||
const groupPhoto = normalizeTelegramUpdate({
|
||||
update_id: 20,
|
||||
message: {
|
||||
message_id: 10,
|
||||
chat: { id: -1001, type: 'supergroup' },
|
||||
from: { id: 43, is_bot: false },
|
||||
caption: '@HarnessBot 看看这张图',
|
||||
caption_entities: [{ type: 'mention', offset: 0, length: 11 }],
|
||||
photo: [
|
||||
{ file_id: 'small', file_unique_id: 'photo', width: 90, height: 90, file_size: 500 },
|
||||
{ file_id: 'large', file_unique_id: 'photo', width: 1280, height: 720, file_size: 2_000 },
|
||||
],
|
||||
},
|
||||
}, {
|
||||
botId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
loadFile: async (fileId, options) => {
|
||||
loads.push({ fileId, options });
|
||||
return Buffer.from('image');
|
||||
},
|
||||
});
|
||||
assert.equal(groupPhoto.addressed, true);
|
||||
assert.equal(groupPhoto.content, '看看这张图');
|
||||
assert.equal(groupPhoto.images.length, 1);
|
||||
assert.equal(groupPhoto.images[0].size, 2_000);
|
||||
await groupPhoto.images[0].load({ maxBytes: 5_000 });
|
||||
assert.equal(loads[0].fileId, 'large');
|
||||
|
||||
const document = normalizeTelegramUpdate({
|
||||
update_id: 21,
|
||||
message: {
|
||||
message_id: 11,
|
||||
chat: { id: 88, type: 'private' },
|
||||
from: { id: 42, is_bot: false },
|
||||
document: {
|
||||
file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000,
|
||||
},
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(document.images[0].name, 'diagram.png');
|
||||
assert.equal(document.images[0].mediaType, 'image/png');
|
||||
|
||||
const documentWithoutMime = normalizeTelegramUpdate({
|
||||
update_id: 23,
|
||||
message: {
|
||||
message_id: 13,
|
||||
chat: { id: 88, type: 'private' },
|
||||
from: { id: 42, is_bot: false },
|
||||
document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 },
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp');
|
||||
|
||||
const pdf = normalizeTelegramUpdate({
|
||||
update_id: 22,
|
||||
message: {
|
||||
message_id: 12,
|
||||
chat: { id: 88, type: 'private' },
|
||||
from: { id: 42, is_bot: false },
|
||||
document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' },
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.deepEqual(pdf.images, []);
|
||||
});
|
||||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
const sent = [];
|
||||
const updates = [];
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import {
|
||||
WecomHarnessBridge,
|
||||
wecomInboundMessage,
|
||||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function frame(overrides = {}) {
|
||||
return {
|
||||
|
|
@ -152,6 +161,204 @@ test('Enterprise WeChat messages stream Harness progress and finalize once', asy
|
|||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat downloads an image with its AES key and submits structured content once', async () => {
|
||||
const transport = testClient();
|
||||
const downloads = [];
|
||||
const asked = [];
|
||||
transport.client.downloadFile = async (url, aeskey) => {
|
||||
downloads.push({ url, aeskey });
|
||||
return { buffer: PNG_1X1, filename: 'photo.png' };
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'stream-image',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, content) => {
|
||||
asked.push({ sessionId, content });
|
||||
return '图片识别完成';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
const imageFrame = frame({
|
||||
msgid: 'image-1',
|
||||
msgtype: 'image',
|
||||
text: undefined,
|
||||
image: { url: 'https://wecom.example/image', aeskey: 'aes-image' },
|
||||
});
|
||||
|
||||
await bridge.accept(imageFrame);
|
||||
await bridge.accept(imageFrame);
|
||||
|
||||
assert.deepEqual(downloads, [{
|
||||
url: 'https://wecom.example/image',
|
||||
aeskey: 'aes-image',
|
||||
}]);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-existing',
|
||||
content: [
|
||||
{ type: 'text', text: DEFAULT_IMAGE_PROMPT },
|
||||
{
|
||||
type: 'image',
|
||||
mediaType: 'image/png',
|
||||
data: PNG_1X1.toString('base64'),
|
||||
name: 'photo.png',
|
||||
},
|
||||
],
|
||||
}]);
|
||||
assert.equal(transport.streamed.at(-1).content, '图片识别完成');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat preserves mixed-message text and image order', async () => {
|
||||
const transport = testClient();
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const gif = Buffer.from('GIF89a payload');
|
||||
transport.client.downloadFile = async (url) => ({
|
||||
buffer: url.endsWith('/one') ? jpeg : gif,
|
||||
});
|
||||
let prompt;
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'stream-mixed',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, content) => { prompt = content; return 'ok'; },
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({
|
||||
msgid: 'mixed-image',
|
||||
msgtype: 'mixed',
|
||||
text: undefined,
|
||||
mixed: { msg_item: [
|
||||
{ msgtype: 'text', text: { content: '比较这两张图' } },
|
||||
{ msgtype: 'image', image: { url: 'https://wecom.example/one', aeskey: 'one' } },
|
||||
{ msgtype: 'image', image: { url: 'https://wecom.example/two', aeskey: 'two' } },
|
||||
] },
|
||||
}));
|
||||
|
||||
assert.deepEqual(prompt, [
|
||||
{ type: 'text', text: '比较这两张图' },
|
||||
{ type: 'image', mediaType: 'image/jpeg', data: jpeg.toString('base64') },
|
||||
{ type: 'image', mediaType: 'image/gif', data: gif.toString('base64') },
|
||||
]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat starts image download before an earlier conversation turn finishes', async () => {
|
||||
const transport = testClient();
|
||||
const firstStarted = deferred();
|
||||
const releaseFirst = deferred();
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
transport.client.downloadFile = async (url, aeskey) => {
|
||||
downloads.push({ url, aeskey });
|
||||
return { buffer: PNG_1X1, filename: 'queued.png' };
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: (() => { let index = 0; return () => `queued-${++index}`; })(),
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
async ask(_sessionId, prompt) {
|
||||
prompts.push(prompt);
|
||||
if (prompt === '先处理这个慢任务') {
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
const first = bridge.accept(frame({
|
||||
msgid: 'queued-text',
|
||||
text: { content: '先处理这个慢任务' },
|
||||
}));
|
||||
await firstStarted.promise;
|
||||
const second = bridge.accept(frame({
|
||||
msgid: 'queued-image',
|
||||
msgtype: 'image',
|
||||
text: undefined,
|
||||
image: { url: 'https://wecom.example/expiring', aeskey: 'queued-key' },
|
||||
}));
|
||||
|
||||
await eventually(() => downloads.length === 1);
|
||||
assert.deepEqual(prompts, ['先处理这个慢任务']);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(downloads, [{
|
||||
url: 'https://wecom.example/expiring',
|
||||
aeskey: 'queued-key',
|
||||
}]);
|
||||
assert.equal(Array.isArray(prompts[1]), true);
|
||||
assert.equal(prompts[1][1].mediaType, 'image/png');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat bounds prefetched image memory while a conversation is queued', async () => {
|
||||
const transport = testClient();
|
||||
const firstStarted = deferred();
|
||||
const releaseFirst = deferred();
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
transport.client.downloadFile = async (url) => {
|
||||
downloads.push(url);
|
||||
return { buffer: PNG_1X1 };
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: (() => { let index = 0; return () => `bounded-${++index}`; })(),
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
async ask(_sessionId, prompt) {
|
||||
prompts.push(prompt);
|
||||
if (prompt === '阻塞队列') {
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
|
||||
const pending = [bridge.accept(frame({
|
||||
msgid: 'bounded-start',
|
||||
text: { content: '阻塞队列' },
|
||||
}))];
|
||||
await firstStarted.promise;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
pending.push(bridge.accept(frame({
|
||||
msgid: `bounded-image-${index}`,
|
||||
msgtype: 'image',
|
||||
text: undefined,
|
||||
image: { url: `https://wecom.example/bounded-${index}`, aeskey: `key-${index}` },
|
||||
})));
|
||||
}
|
||||
|
||||
await eventually(() => downloads.length === 4);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all(pending);
|
||||
assert.equal(downloads.length, 4);
|
||||
assert.equal(prompts.length, 5);
|
||||
assert.equal(transport.streamed.some(({ content }) => (
|
||||
content === '当前待处理图片较多,请稍后重新发送。'
|
||||
)), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat image references enforce the caller byte limit after SDK decryption', async () => {
|
||||
const message = wecomInboundMessage(frame({
|
||||
msgtype: 'image',
|
||||
image: { url: 'https://wecom.example/large', aeskey: 'large-key' },
|
||||
}), {
|
||||
downloadFile: async () => ({ buffer: Buffer.alloc(5) }),
|
||||
});
|
||||
await assert.rejects(message.images[0].load({ maxBytes: 4 }), /exceeds/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat visibility scope accepts direct and group conversations without local approval', async () => {
|
||||
let asks = 0;
|
||||
const client = {
|
||||
|
|
|
|||
|
|
@ -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', '真正的答案'));
|
||||
|
|
|
|||
|
|
@ -207,6 +207,127 @@ test('WhatsApp normalizes direct and explicitly mentioned group messages', () =>
|
|||
}, ACCOUNT_JID), null);
|
||||
});
|
||||
|
||||
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const controller = new AbortController();
|
||||
const group = normalizeWhatsappMessage({
|
||||
key: {
|
||||
remoteJid: '120363000000000000@g.us',
|
||||
participant: '16505550999@s.whatsapp.net',
|
||||
id: 'group-image-1',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
imageMessage: {
|
||||
mimetype: 'image/png',
|
||||
caption: '看看这张图',
|
||||
fileLength: { toString: () => String(png.length) },
|
||||
url: 'https://mmg.whatsapp.net/image',
|
||||
contextInfo: { mentionedJid: [ACCOUNT_JID] },
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID, {
|
||||
download: async (raw, type, options) => {
|
||||
calls.push({ raw, type, options });
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() { yield png; },
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.content, '看看这张图');
|
||||
assert.equal(group.images.length, 1);
|
||||
assert.equal(group.images[0].size, png.length);
|
||||
assert.deepEqual(await group.images[0].load({ signal: controller.signal, maxBytes: 100 }), png);
|
||||
assert.equal(calls[0].type, 'stream');
|
||||
assert.equal(calls[0].options.options.signal instanceof AbortSignal, true);
|
||||
|
||||
const downloadStarted = Promise.withResolvers();
|
||||
const lateStream = Promise.withResolvers();
|
||||
let lateStreamDestroyed = false;
|
||||
const cancelled = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancelled-1', fromMe: false },
|
||||
message: {
|
||||
imageMessage: { mimetype: 'image/png', url: 'https://mmg.whatsapp.net/cancelled' },
|
||||
},
|
||||
}, ACCOUNT_JID, {
|
||||
download: async () => {
|
||||
downloadStarted.resolve();
|
||||
return lateStream.promise;
|
||||
},
|
||||
});
|
||||
const cancelledController = new AbortController();
|
||||
const cancelledLoad = cancelled.images[0].load({
|
||||
signal: cancelledController.signal,
|
||||
maxBytes: 100,
|
||||
});
|
||||
await downloadStarted.promise;
|
||||
cancelledController.abort(new DOMException('Stopped', 'AbortError'));
|
||||
await assert.rejects(cancelledLoad, { name: 'AbortError' });
|
||||
lateStream.resolve({ destroy() { lateStreamDestroyed = true; } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(lateStreamDestroyed, true);
|
||||
|
||||
const document = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-image-1', fromMe: false },
|
||||
message: {
|
||||
documentMessage: {
|
||||
mimetype: 'image/webp', fileName: 'diagram.webp', fileLength: 2_000,
|
||||
url: 'https://mmg.whatsapp.net/document',
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(document.images[0].name, 'diagram.webp');
|
||||
assert.equal(document.images[0].mediaType, 'image/webp');
|
||||
|
||||
for (const [index, wrapper] of [
|
||||
'viewOnceMessage',
|
||||
'viewOnceMessageV2',
|
||||
'viewOnceMessageV2Extension',
|
||||
].entries()) {
|
||||
const wrappedMessage = {
|
||||
[wrapper]: {
|
||||
message: {
|
||||
imageMessage: {
|
||||
mimetype: 'image/jpeg', url: `https://mmg.whatsapp.net/view-once-${index}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const viewOnce = normalizeWhatsappMessage({
|
||||
key: {
|
||||
remoteJid: '16505550999@s.whatsapp.net',
|
||||
id: `view-once-${index}`,
|
||||
fromMe: false,
|
||||
},
|
||||
message: index === 1
|
||||
? { ephemeralMessage: { message: wrappedMessage } }
|
||||
: wrappedMessage,
|
||||
}, ACCOUNT_JID);
|
||||
assert.deepEqual(viewOnce.images, []);
|
||||
}
|
||||
|
||||
const oversized = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'oversized-1', fromMe: false },
|
||||
message: {
|
||||
imageMessage: { mimetype: 'image/jpeg', url: 'https://mmg.whatsapp.net/oversized' },
|
||||
},
|
||||
}, ACCOUNT_JID, {
|
||||
download: async () => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield Buffer.alloc(4);
|
||||
yield Buffer.alloc(4);
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(() => oversized.images[0].load({ maxBytes: 5 }), (error) => {
|
||||
assert.equal(error.code, 'image-too-large');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
|
|
|
|||
183
test/image-bridge.test.mjs
Normal file
183
test/image-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function memoryState() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, value) => sessions.set(key, value),
|
||||
clearSession: async (key) => sessions.delete(key),
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeFixture() {
|
||||
const sent = [];
|
||||
const prompts = [];
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-image',
|
||||
ask: async (_sessionId, prompt) => {
|
||||
prompts.push(prompt);
|
||||
return '识别成功';
|
||||
},
|
||||
},
|
||||
state: memoryState(),
|
||||
});
|
||||
return { bridge, sent, prompts };
|
||||
}
|
||||
|
||||
test('the shared bridge sends image and caption content to Harness', async () => {
|
||||
const { bridge, sent, prompts } = bridgeFixture();
|
||||
let loads = 0;
|
||||
await bridge.accept({
|
||||
messageId: 'image-1',
|
||||
senderId: 'user-1',
|
||||
kind: 'direct',
|
||||
conversationId: 'user-1',
|
||||
content: '图中是什么?',
|
||||
images: [{ async load() { loads += 1; return PNG_1X1; } }],
|
||||
replyTarget: {},
|
||||
});
|
||||
|
||||
assert.equal(loads, 1);
|
||||
assert.deepEqual(prompts, [[
|
||||
{ type: 'text', text: '图中是什么?' },
|
||||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||||
]]);
|
||||
assert.deepEqual(sent, ['识别成功']);
|
||||
});
|
||||
|
||||
test('unaddressed group images are rejected before their bytes are downloaded', async () => {
|
||||
const { bridge, sent, prompts } = bridgeFixture();
|
||||
let loads = 0;
|
||||
await bridge.accept({
|
||||
messageId: 'image-2',
|
||||
senderId: 'user-1',
|
||||
kind: 'group',
|
||||
conversationId: 'group-1',
|
||||
content: '',
|
||||
addressed: false,
|
||||
images: [{ async load() { loads += 1; return PNG_1X1; } }],
|
||||
replyTarget: {},
|
||||
});
|
||||
|
||||
assert.equal(loads, 0);
|
||||
assert.deepEqual(prompts, []);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('image validation failures receive a specific safe reply', async () => {
|
||||
const { bridge, sent, prompts } = bridgeFixture();
|
||||
await bridge.accept({
|
||||
messageId: 'image-3',
|
||||
senderId: 'user-1',
|
||||
kind: 'direct',
|
||||
conversationId: 'user-1',
|
||||
content: '',
|
||||
images: [{ data: Buffer.from('not an image') }],
|
||||
replyTarget: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(prompts, []);
|
||||
assert.deepEqual(sent, ['暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。']);
|
||||
});
|
||||
|
||||
test('an image caption cannot answer a pending Harness question', async () => {
|
||||
const sent = [];
|
||||
const questionSent = Promise.withResolvers();
|
||||
const answered = Promise.withResolvers();
|
||||
let interactionResult;
|
||||
let imageLoads = 0;
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot: {
|
||||
async sendText(_target, text) {
|
||||
sent.push(text);
|
||||
if (text.includes('请选择环境')) questionSent.resolve();
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-question',
|
||||
async ask(_sessionId, prompt, options) {
|
||||
assert.equal(prompt, '开始测试');
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'question-1',
|
||||
rpcId: 'question-1',
|
||||
sessionId: 'session-question',
|
||||
payload: {
|
||||
questions: [{
|
||||
id: 'environment',
|
||||
question: '请选择环境',
|
||||
options: [{ label: '生产环境' }],
|
||||
}],
|
||||
},
|
||||
async respond(result) {
|
||||
interactionResult = result;
|
||||
answered.resolve();
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
await answered.promise;
|
||||
return '已完成';
|
||||
},
|
||||
},
|
||||
state: memoryState(),
|
||||
});
|
||||
|
||||
const original = bridge.accept({
|
||||
messageId: 'question-start',
|
||||
senderId: 'user-1',
|
||||
kind: 'direct',
|
||||
conversationId: 'user-1',
|
||||
content: '开始测试',
|
||||
replyTarget: {},
|
||||
});
|
||||
await questionSent.promise;
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'question-image',
|
||||
senderId: 'user-1',
|
||||
kind: 'direct',
|
||||
conversationId: 'user-1',
|
||||
content: '生产环境',
|
||||
images: [{ async load() { imageLoads += 1; return PNG_1X1; } }],
|
||||
replyTarget: {},
|
||||
});
|
||||
assert.equal(imageLoads, 0);
|
||||
assert.equal(interactionResult, undefined);
|
||||
assert.equal(sent.at(-1), '请用文字回答当前问题。');
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'question-answer',
|
||||
senderId: 'user-1',
|
||||
kind: 'direct',
|
||||
conversationId: 'user-1',
|
||||
content: '生产环境',
|
||||
replyTarget: {},
|
||||
});
|
||||
await original;
|
||||
assert.deepEqual(interactionResult, {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: 'session-question',
|
||||
answer: { answers: [{ id: 'environment', selected: ['生产环境'] }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
233
test/image-prompt.test.mjs
Normal file
233
test/image-prompt.test.mjs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_IMAGE_PROMPT,
|
||||
ImagePromptError,
|
||||
fetchImageBuffer,
|
||||
hasInboundPrompt,
|
||||
promptContentForMessage,
|
||||
} from '../src/channels/shared/image-prompt.mjs';
|
||||
import { HarnessClient } from '../src/channels/shared/harness-client.mjs';
|
||||
|
||||
const PNG_1X1 = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
test('text-only messages retain the existing Harness content shape', async () => {
|
||||
assert.equal(hasInboundPrompt({ content: ' hello ' }), true);
|
||||
assert.deepEqual(await promptContentForMessage({ content: ' hello ' }), [
|
||||
{ type: 'text', text: 'hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('image-only messages lazily load bytes and receive a useful default instruction', async () => {
|
||||
let loadOptions;
|
||||
const content = await promptContentForMessage({
|
||||
content: '',
|
||||
images: [{
|
||||
name: '../photo.png',
|
||||
async load(options) {
|
||||
loadOptions = options;
|
||||
return PNG_1X1;
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(loadOptions.maxBytes, 5 * 1024 * 1024);
|
||||
assert.deepEqual(content, [
|
||||
{ type: 'text', text: DEFAULT_IMAGE_PROMPT },
|
||||
{
|
||||
type: 'image',
|
||||
mediaType: 'image/png',
|
||||
data: PNG_1X1.toString('base64'),
|
||||
name: 'photo.png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('captions and multiple image parts preserve their input order', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const gif = Buffer.from('GIF89a payload');
|
||||
assert.deepEqual(await promptContentForMessage({
|
||||
content: 'compare these',
|
||||
images: [{ data: jpeg }, { data: gif }],
|
||||
}), [
|
||||
{ type: 'text', text: 'compare these' },
|
||||
{ type: 'image', mediaType: 'image/jpeg', data: jpeg.toString('base64') },
|
||||
{ type: 'image', mediaType: 'image/gif', data: gif.toString('base64') },
|
||||
]);
|
||||
});
|
||||
|
||||
test('declared oversized images are rejected without downloading them', async () => {
|
||||
let loaded = false;
|
||||
await assert.rejects(
|
||||
promptContentForMessage({
|
||||
images: [{
|
||||
size: 5 * 1024 * 1024 + 1,
|
||||
async load() { loaded = true; return PNG_1X1; },
|
||||
}],
|
||||
}),
|
||||
(error) => error instanceof ImagePromptError && error.code === 'image-too-large',
|
||||
);
|
||||
assert.equal(loaded, false);
|
||||
});
|
||||
|
||||
test('multiple images share an aggregate byte limit', async () => {
|
||||
let secondLoaded = false;
|
||||
await assert.rejects(
|
||||
promptContentForMessage({
|
||||
images: [
|
||||
{ size: PNG_1X1.length, async load() { return PNG_1X1; } },
|
||||
{
|
||||
size: PNG_1X1.length,
|
||||
async load() { secondLoaded = true; return PNG_1X1; },
|
||||
},
|
||||
],
|
||||
}, { maxTotalImageBytes: PNG_1X1.length + 1 }),
|
||||
(error) => error instanceof ImagePromptError && error.code === 'images-too-large',
|
||||
);
|
||||
assert.equal(secondLoaded, false);
|
||||
});
|
||||
|
||||
test('unsupported image bytes receive a channel-safe error', async () => {
|
||||
await assert.rejects(
|
||||
promptContentForMessage({ images: [{ data: Buffer.from('not an image') }] }),
|
||||
(error) => error instanceof ImagePromptError
|
||||
&& error.code === 'unsupported-image-type'
|
||||
&& /JPEG/.test(error.userMessage),
|
||||
);
|
||||
});
|
||||
|
||||
test('bounded HTTPS downloads enforce response size before buffering', async () => {
|
||||
let cancelled = false;
|
||||
const fetchImpl = async (url, options) => {
|
||||
assert.equal(url.href, 'https://files.example/image.png');
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (name) => (name === 'content-length' ? '9' : null) },
|
||||
body: { async cancel() { cancelled = true; } },
|
||||
async arrayBuffer() { throw new Error('must not buffer'); },
|
||||
};
|
||||
};
|
||||
await assert.rejects(
|
||||
fetchImageBuffer('https://files.example/image.png', { fetchImpl, maxBytes: 8 }),
|
||||
(error) => error instanceof ImagePromptError && error.code === 'image-too-large',
|
||||
);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('image downloads reject insecure platform URLs', async () => {
|
||||
await assert.rejects(
|
||||
fetchImageBuffer('http://files.example/image.png'),
|
||||
/must use HTTPS/,
|
||||
);
|
||||
});
|
||||
|
||||
test('image downloads enforce messaging-platform host allowlists', async () => {
|
||||
await assert.rejects(
|
||||
fetchImageBuffer('https://cdn.attacker.example/image.png', {
|
||||
allowedHosts: ['cdn.discordapp.com', '.slack.com'],
|
||||
}),
|
||||
/not hosted by the messaging platform/,
|
||||
);
|
||||
const data = await fetchImageBuffer('https://files.slack.com/image.png', {
|
||||
allowedHosts: ['.slack.com'],
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: { get: () => null },
|
||||
async arrayBuffer() { return PNG_1X1; },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(data, PNG_1X1);
|
||||
});
|
||||
|
||||
test('image downloads report redirects without following or exposing the target', async () => {
|
||||
let options;
|
||||
let cancelled = false;
|
||||
await assert.rejects(
|
||||
fetchImageBuffer('https://files.example.test/image', {
|
||||
fetchImpl: async (_url, requestOptions) => {
|
||||
options = requestOptions;
|
||||
return {
|
||||
ok: false,
|
||||
status: 302,
|
||||
headers: { get: () => null },
|
||||
body: { async cancel() { cancelled = true; } },
|
||||
};
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'image-redirect-blocked');
|
||||
assert.doesNotMatch(error.message, /private|token|hidden/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(options.redirect, 'manual');
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('image downloads report the safe HTTP status for non-success responses', async () => {
|
||||
let cancelled = false;
|
||||
await assert.rejects(
|
||||
fetchImageBuffer('https://files.example.test/image', {
|
||||
fetchImpl: async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: { get: () => null },
|
||||
body: { async cancel() { cancelled = true; } },
|
||||
}),
|
||||
}),
|
||||
(error) => error.code === 'image-http-error'
|
||||
&& error.userMessage === '图片下载失败(HTTP 403),请重新发送后再试。',
|
||||
);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('HarnessClient sends structured image content without rewriting it', async () => {
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/image-prompt-test',
|
||||
});
|
||||
client.ensureRunning = async () => true;
|
||||
let promptPayload;
|
||||
let promptRpcId;
|
||||
let historyCalls = 0;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.prompt') {
|
||||
promptPayload = payload;
|
||||
promptRpcId = options.rpcId;
|
||||
return {};
|
||||
}
|
||||
assert.equal(method, 'session.history');
|
||||
historyCalls += 1;
|
||||
if (historyCalls === 1) return { events: [] };
|
||||
return {
|
||||
events: [
|
||||
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
|
||||
{ event: {
|
||||
seq: 2,
|
||||
type: 'user/message',
|
||||
data: { turn: 1, source: { rpcId: promptRpcId } },
|
||||
} },
|
||||
{ event: {
|
||||
seq: 3,
|
||||
type: 'assistant/message',
|
||||
data: { turn: 1, message: { content: [{ type: 'text', text: 'a cat' }] } },
|
||||
} },
|
||||
{ event: { seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
const content = [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
|
||||
];
|
||||
|
||||
assert.equal(await client.ask('session-image', content, { timeoutMs: 2_000 }), 'a cat');
|
||||
assert.deepEqual(promptPayload.content, content);
|
||||
assert.equal(promptPayload.sessionId, 'session-image');
|
||||
});
|
||||
|
|
@ -188,6 +188,33 @@ test('the next message continues the bound Session without creating a new one',
|
|||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test('workspace sessions forward structured multimodal prompt content unchanged', async (t) => {
|
||||
const { path, defaultWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
await workspaces.ensure('bot_multimodal');
|
||||
const state = memoryState({ conversation: 'session-image' });
|
||||
const prompts = [];
|
||||
const content = [
|
||||
{ type: 'text', text: '这是什么?' },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
];
|
||||
const scope = createBotWorkspaceScope({
|
||||
async sessionExists() { return true; },
|
||||
async ask(sessionId, prompt) {
|
||||
prompts.push({ sessionId, prompt });
|
||||
return 'image answer';
|
||||
},
|
||||
}, { botId: 'bot_multimodal', workspaces, state });
|
||||
|
||||
assert.deepEqual(await askInWorkspaceSession({
|
||||
harness: scope.harness,
|
||||
state: scope.state,
|
||||
key: 'conversation',
|
||||
content,
|
||||
}), { sessionId: 'session-image', answer: 'image answer' });
|
||||
assert.deepEqual(prompts, [{ sessionId: 'session-image', prompt: content }]);
|
||||
});
|
||||
|
||||
test('adoption errors and invalid adoption responses leave local state unchanged', async (t) => {
|
||||
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
|
||||
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue