feat: support image prompts across IM channels

This commit is contained in:
xmanrui 2026-08-19 02:56:14 +08:00
parent 99a877c640
commit 65c842c045
40 changed files with 3806 additions and 274 deletions

View file

@ -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'),

View file

@ -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');

View file

@ -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 = [];

View file

@ -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', '真正的答案'));

View file

@ -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' } },

View file

@ -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 });

View file

@ -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 = [];

View file

@ -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 = [];

View file

@ -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 = [];

View file

@ -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 = {

View file

@ -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({

View file

@ -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', '真正的答案'));

View file

@ -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
View 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
View 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');
});

View file

@ -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();