mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: support inbound files across all channels
This commit is contained in:
parent
29bcb67a64
commit
0a26d7df92
45 changed files with 2651 additions and 343 deletions
|
|
@ -382,6 +382,38 @@ test('DingTalk image downloads exchange downloadCode with the callback robotCode
|
|||
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
|
||||
});
|
||||
|
||||
test('DingTalk downloads ordinary files through the native downloadCode exchange without image limits', async () => {
|
||||
const fileBytes = Buffer.from('ordinary-dingtalk-file');
|
||||
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: 'file-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (href === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
|
||||
return jsonResponse({ downloadUrl: 'https://download.example.test/native-file' });
|
||||
}
|
||||
return new Response(fileBytes);
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
|
||||
assert.deepEqual(await api.downloadFile({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'ordinary-file-code',
|
||||
}), fileBytes);
|
||||
assert.deepEqual(JSON.parse(calls[1].options.body), {
|
||||
downloadCode: 'ordinary-file-code',
|
||||
robotCode: 'robot-from-callback',
|
||||
});
|
||||
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
|
||||
assert.equal(calls[2].options.method, 'GET');
|
||||
assert.equal(calls[2].options.redirect, 'follow');
|
||||
assert.equal(calls[2].options.headers, 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 = [];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import test from 'node:test';
|
|||
|
||||
import {
|
||||
createDingtalkBridgeStatus,
|
||||
dingtalkInboundMessage,
|
||||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
|
@ -122,6 +123,141 @@ async function committedArtifact(t, fileName, content) {
|
|||
return artifact;
|
||||
}
|
||||
|
||||
test('DingTalk normalizes a native file callback into one lazy ordinary file', async () => {
|
||||
const calls = [];
|
||||
const bytes = Buffer.from('dingtalk-native-file');
|
||||
const inbound = dingtalkInboundMessage({
|
||||
msgtype: 'file',
|
||||
robotCode: 'robot-from-callback',
|
||||
content: JSON.stringify({
|
||||
spaceId: 'space-one',
|
||||
fileId: 'file-one',
|
||||
fileName: '钉钉报告.zip',
|
||||
downloadCode: 'opaque-file-code',
|
||||
}),
|
||||
}, {
|
||||
api: {
|
||||
downloadFile: async (request) => {
|
||||
calls.push(request);
|
||||
return bytes;
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
});
|
||||
|
||||
assert.equal(inbound.content, '');
|
||||
assert.deepEqual(inbound.images, []);
|
||||
assert.equal(inbound.files.length, 1);
|
||||
assert.equal(inbound.files[0].name, '钉钉报告.zip');
|
||||
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||
assert.deepEqual(calls, [{
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
robotCode: 'robot-from-callback',
|
||||
downloadCode: 'opaque-file-code',
|
||||
signal: undefined,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('DingTalk bridge hands a native file source to the current Harness turn', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-native-file');
|
||||
const bytes = Buffer.from('dingtalk-bridge-file');
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadFile: async (request) => {
|
||||
downloads.push(request);
|
||||
return bytes;
|
||||
},
|
||||
sendText: async (request) => sent.push(request.text),
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
prompts.push({
|
||||
sessionId,
|
||||
prompt,
|
||||
name: options.files[0].name,
|
||||
bytes: await options.files[0].load({ signal: options.signal }),
|
||||
});
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('dingtalk-native-file', '', {
|
||||
msgtype: 'file',
|
||||
text: undefined,
|
||||
robotCode: 'robot-from-callback',
|
||||
content: {
|
||||
fileName: '钉钉报告.pdf',
|
||||
downloadCode: 'native-file-code',
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(downloads.length, 1);
|
||||
assert.equal(downloads[0].downloadCode, 'native-file-code');
|
||||
assert.deepEqual(prompts, [{
|
||||
sessionId: 'session-native-file',
|
||||
prompt: '',
|
||||
name: '钉钉报告.pdf',
|
||||
bytes,
|
||||
}]);
|
||||
assert.equal(sent.at(-1), '文件已收到');
|
||||
});
|
||||
|
||||
test('DingTalk starts a native-file download before an earlier queued turn finishes', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-prefetch-file');
|
||||
const firstTurn = deferred();
|
||||
const bytes = Buffer.from('dingtalk-prefetched-file');
|
||||
let asks = 0;
|
||||
let downloads = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
downloadFile: async () => {
|
||||
downloads += 1;
|
||||
return bytes;
|
||||
},
|
||||
sendText: async () => {},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _prompt, options) => {
|
||||
asks += 1;
|
||||
if (asks === 1) return firstTurn.promise;
|
||||
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||
return '第二条完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('dingtalk-prefetch-first', '先等待'));
|
||||
await eventually(() => asks === 1);
|
||||
const second = bridge.accept(message('dingtalk-prefetch-second', '', {
|
||||
msgtype: 'file',
|
||||
text: undefined,
|
||||
robotCode: 'robot-from-callback',
|
||||
content: { fileName: 'queued.bin', downloadCode: 'queued-file-code' },
|
||||
}));
|
||||
|
||||
await eventually(() => downloads === 1, 'queued DingTalk file did not start downloading');
|
||||
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||
firstTurn.resolve('第一条完成');
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ test('Discord normalizes DMs and only addressed server messages', () => {
|
|||
assert.equal(unmentionedReply.addressed, false);
|
||||
});
|
||||
|
||||
test('Discord exposes image attachments through bounded CDN loaders', async () => {
|
||||
test('Discord keeps image attachments in images and exposes ordinary attachments as files', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const message = normalizeDiscordMessage({
|
||||
|
|
@ -411,6 +411,19 @@ test('Discord exposes image attachments through bounded CDN loaders', async () =
|
|||
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');
|
||||
assert.equal(message.files.length, 1);
|
||||
assert.deepEqual({
|
||||
name: message.files[0].name,
|
||||
mediaType: message.files[0].mediaType,
|
||||
size: message.files[0].size,
|
||||
}, { name: 'notes.txt', mediaType: 'text/plain', size: 4 });
|
||||
const controller = new AbortController();
|
||||
const loadedFile = await message.files[0].load({ signal: controller.signal });
|
||||
const fileChunks = [];
|
||||
for await (const chunk of loadedFile.stream) fileChunks.push(Buffer.from(chunk));
|
||||
assert.deepEqual(Buffer.concat(fileChunks), png);
|
||||
assert.equal(calls[1].url.pathname.endsWith('/notes.txt'), true);
|
||||
assert.equal(calls[1].options.signal, controller.signal);
|
||||
|
||||
const unsafe = normalizeDiscordMessage({
|
||||
id: '111111111111111121',
|
||||
|
|
|
|||
|
|
@ -505,6 +505,63 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
|
|||
assert.deepEqual(sent, ['看到了一张图片']);
|
||||
});
|
||||
|
||||
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
|
||||
const bytes = Buffer.from('feishu-native-file');
|
||||
const downloads = [];
|
||||
const asked = [];
|
||||
const sent = [];
|
||||
const client = {
|
||||
im: { v1: {
|
||||
messageResource: { get: async (request) => {
|
||||
downloads.push(request);
|
||||
return { getReadableStream: () => Readable.from([bytes]) };
|
||||
} },
|
||||
message: { create: async (request) => {
|
||||
sent.push(JSON.parse(request.data.content).text);
|
||||
return { code: 0, data: { message_id: 'om_file_reply' } };
|
||||
} },
|
||||
} },
|
||||
};
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
asked.push({
|
||||
sessionId,
|
||||
prompt,
|
||||
name: options.files[0].name,
|
||||
bytes: await options.files[0].load({ signal: options.signal }),
|
||||
});
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('om_file_input', '', {
|
||||
message_type: 'file',
|
||||
content: JSON.stringify({ file_key: 'file_input', file_name: '飞书报告.pdf' }),
|
||||
}));
|
||||
await bridge.waitForIdle();
|
||||
|
||||
assert.deepEqual(downloads, [{
|
||||
path: { message_id: 'om_file_input', file_key: 'file_input' },
|
||||
params: { type: 'file' },
|
||||
}]);
|
||||
assert.deepEqual(asked, [{
|
||||
sessionId: 'session-file',
|
||||
prompt: '',
|
||||
name: '飞书报告.pdf',
|
||||
bytes,
|
||||
}]);
|
||||
assert.deepEqual(sent, ['文件已收到']);
|
||||
});
|
||||
|
||||
test('bridge tells users to grant im:message:readonly when Feishu rejects image access', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
|
||||
const sent = [];
|
||||
|
|
|
|||
|
|
@ -644,6 +644,162 @@ test('HarnessClient asks do not control file-return tool availability', async ()
|
|||
}), 'done');
|
||||
});
|
||||
|
||||
test('HarnessClient stages inbound files, appends a neutral manifest, and cleans after turn end', async () => {
|
||||
const inboundSources = [{ name: '用户报告.bin', load: async () => Buffer.from('bytes') }];
|
||||
const ingressCalls = [];
|
||||
let cleanupCalls = 0;
|
||||
let promptPayload;
|
||||
let promptRpcId;
|
||||
let prompted = false;
|
||||
const stagedFiles = [{
|
||||
name: '用户报告.bin',
|
||||
path: '.dsh-im/inbound/turn-abc/01-用户报告.bin',
|
||||
mediaType: 'application/octet-stream',
|
||||
}];
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/must-not-select-the-session-cwd',
|
||||
fileIngressExecutor: async (request) => {
|
||||
ingressCalls.push(request);
|
||||
return {
|
||||
files: stagedFiles,
|
||||
async cleanup() { cleanupCalls += 1; },
|
||||
};
|
||||
},
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method, payload, _timeoutMs, options) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.list') {
|
||||
return { items: [{ sessionId: 'session-inbound-files', cwd: '/tmp/exact-session-cwd' }] };
|
||||
}
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
promptPayload = payload;
|
||||
promptRpcId = options.rpcId;
|
||||
assert.equal(cleanupCalls, 0, 'files remain available while the prompt is running');
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
events: [
|
||||
{ event: { type: 'turn/start', seq: 1, data: { turn: 3 } } },
|
||||
{
|
||||
event: {
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
data: { turn: 3, source: { rpcId: promptRpcId } },
|
||||
},
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: 'assistant/message',
|
||||
seq: 3,
|
||||
data: {
|
||||
turn: 3,
|
||||
step: 1,
|
||||
message: { content: [{ type: 'text', text: '已读取附件。' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ event: { type: 'turn/end', seq: 4, data: { turn: 3, reason: { kind: 'completed' } } } },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(await client.ask('session-inbound-files', '请查看附件', {
|
||||
files: inboundSources,
|
||||
}), '已读取附件。');
|
||||
|
||||
assert.equal(ingressCalls.length, 1);
|
||||
assert.equal(ingressCalls[0].sessionId, 'session-inbound-files');
|
||||
assert.equal(ingressCalls[0].workspace, '/tmp/exact-session-cwd');
|
||||
assert.deepEqual(ingressCalls[0].files, inboundSources);
|
||||
assert.equal(ingressCalls[0].files[0], inboundSources[0]);
|
||||
assert.equal(ingressCalls[0].signal, undefined);
|
||||
assert.equal(promptPayload.sessionId, 'session-inbound-files');
|
||||
assert.equal(promptPayload.mode, 'queue');
|
||||
assert.equal(promptPayload.content.length, 1);
|
||||
assert.equal(promptPayload.content[0].type, 'text');
|
||||
const promptText = promptPayload.content[0].text;
|
||||
assert.match(promptText, /^请查看附件\n\n<dsh_im_files>\n/);
|
||||
assert.match(promptText, /\n<\/dsh_im_files>$/);
|
||||
const manifest = JSON.parse(promptText.match(/<dsh_im_files>\n(.+)\n<\/dsh_im_files>$/s)[1]);
|
||||
assert.deepEqual(manifest, {
|
||||
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
|
||||
files: stagedFiles,
|
||||
});
|
||||
assert.doesNotMatch(promptText, /summari[sz]e|解析|总结|处理这些文件/i);
|
||||
assert.equal(cleanupCalls, 1);
|
||||
});
|
||||
|
||||
test('HarnessClient cleans staged inbound files when session.prompt rejects them', async () => {
|
||||
const promptFailure = new Error('Harness rejected prompt');
|
||||
let cleanupCalls = 0;
|
||||
let prompted = false;
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
fileIngressExecutor: async () => ({
|
||||
files: [{ name: 'rejected.dat', path: '.dsh-im/inbound/turn-rejected/01-rejected.dat' }],
|
||||
async cleanup() { cleanupCalls += 1; },
|
||||
}),
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.list') {
|
||||
return { items: [{ sessionId: 'session-rejected-file', cwd: '/tmp/rejected-cwd' }] };
|
||||
}
|
||||
assert.equal(method, 'session.prompt');
|
||||
prompted = true;
|
||||
throw promptFailure;
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
client.ask('session-rejected-file', '', {
|
||||
files: [{ name: 'rejected.dat', data: Buffer.from('bytes') }],
|
||||
}),
|
||||
(error) => error === promptFailure,
|
||||
);
|
||||
assert.equal(cleanupCalls, 1);
|
||||
});
|
||||
|
||||
test('HarnessClient retains staged files when an accepted turn outcome is unknown', async () => {
|
||||
const uncertainFailure = new Error('history transport failed after prompt acceptance');
|
||||
let cleanupCalls = 0;
|
||||
let prompted = false;
|
||||
const client = new HarnessClient({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
workspace: '/tmp/default-workspace',
|
||||
fileIngressExecutor: async () => ({
|
||||
files: [{ name: 'uncertain.dat', path: '.dsh-im/inbound/turn-unknown/01-uncertain.dat' }],
|
||||
async cleanup() { cleanupCalls += 1; },
|
||||
}),
|
||||
});
|
||||
client.ensureRunning = async () => undefined;
|
||||
client.rpc = async (method) => {
|
||||
if (method === 'session.history' && !prompted) return { events: [] };
|
||||
if (method === 'session.list') {
|
||||
return { items: [{ sessionId: 'session-uncertain-file', cwd: '/tmp/uncertain-cwd' }] };
|
||||
}
|
||||
if (method === 'session.prompt') {
|
||||
prompted = true;
|
||||
return { accepted: true };
|
||||
}
|
||||
assert.equal(method, 'session.history');
|
||||
throw uncertainFailure;
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
client.ask('session-uncertain-file', 'use the attached file', {
|
||||
files: [{ name: 'uncertain.dat', data: Buffer.from('bytes') }],
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
(error) => error === uncertainFailure,
|
||||
);
|
||||
assert.equal(cleanupCalls, 0, 'uncertain accepted turns may still be reading the staged path');
|
||||
});
|
||||
|
||||
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
|
||||
outboundArtifactRegistry.clear();
|
||||
t.after(() => outboundArtifactRegistry.clear());
|
||||
|
|
|
|||
|
|
@ -110,6 +110,36 @@ test('extractInboundMessage preserves visible Feishu post text and every embedde
|
|||
]);
|
||||
});
|
||||
|
||||
test('extractInboundMessage exposes a native Feishu file as a lazy unbounded resource download', async () => {
|
||||
const calls = [];
|
||||
const bytes = Buffer.from('ordinary-file-payload');
|
||||
const event = {
|
||||
message: {
|
||||
message_id: 'om_file',
|
||||
message_type: 'file',
|
||||
content: JSON.stringify({ file_key: 'file_test', file_name: 'report.bin' }),
|
||||
},
|
||||
};
|
||||
const client = { im: { v1: { messageResource: { get: async (request) => {
|
||||
calls.push(request);
|
||||
return {
|
||||
getReadableStream: () => Readable.from([bytes.subarray(0, 4), bytes.subarray(4)]),
|
||||
};
|
||||
} } } } };
|
||||
|
||||
const message = extractInboundMessage(event, client);
|
||||
assert.equal(message.content, '');
|
||||
assert.deepEqual(message.images, []);
|
||||
assert.equal(message.files.length, 1);
|
||||
assert.equal(message.files[0].name, 'report.bin');
|
||||
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||
assert.deepEqual(await message.files[0].load({}), bytes);
|
||||
assert.deepEqual(calls, [{
|
||||
path: { message_id: 'om_file', file_key: 'file_test' },
|
||||
params: { type: 'file' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Feishu image loading rejects declared or streamed data above the caller limit', async () => {
|
||||
for (const resource of [
|
||||
{
|
||||
|
|
@ -182,10 +212,10 @@ test('Feishu image loading leaves unrelated provider failures on the generic pat
|
|||
test('malformed Feishu image content does not create a downloadable image reference', () => {
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'image', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
}, {}), { content: '', images: [], files: [] });
|
||||
assert.deepEqual(extractInboundMessage({
|
||||
message: { message_type: 'post', content: '{not-json' },
|
||||
}, {}), { content: '', images: [] });
|
||||
}, {}), { content: '', images: [], files: [] });
|
||||
});
|
||||
|
||||
test('conversationKey isolates p2p users and groups', () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { qqInboundMessage, QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -96,6 +96,141 @@ const PNG_BYTES = Buffer.from([
|
|||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
test('QQ normalizes protocol-relative ordinary-file URLs and lets the CDN redirect', async () => {
|
||||
const bytes = Buffer.from('qq-native-file');
|
||||
const calls = [];
|
||||
const inbound = qqInboundMessage(message({
|
||||
content: '请读取附件',
|
||||
attachments: [{
|
||||
content_type: 'application/pdf',
|
||||
filename: 'QQ 报告.pdf',
|
||||
size: bytes.length,
|
||||
url: '//provider-issued-download.example/native-file',
|
||||
}],
|
||||
}), {
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return new Response(bytes);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(inbound.content, '请读取附件');
|
||||
assert.deepEqual(inbound.images, []);
|
||||
assert.equal(inbound.files.length, 1);
|
||||
assert.equal(inbound.files[0].name, 'QQ 报告.pdf');
|
||||
assert.equal(inbound.files[0].mediaType, 'application/pdf');
|
||||
assert.equal(inbound.files[0].size, bytes.length);
|
||||
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||
assert.deepEqual(calls, [{
|
||||
url: 'https://provider-issued-download.example/native-file',
|
||||
init: { method: 'GET', signal: undefined, redirect: 'follow' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('QQ does not duplicate image attachments in ordinary files', () => {
|
||||
const inbound = qqInboundMessage(message({
|
||||
attachments: [{
|
||||
content_type: 'file',
|
||||
filename: 'diagram.PNG',
|
||||
url: 'https://multimedia.nt.qq.com.cn/download/opaque',
|
||||
}],
|
||||
}));
|
||||
assert.equal(inbound.images.length, 1);
|
||||
assert.deepEqual(inbound.files, []);
|
||||
});
|
||||
|
||||
test('QQ bridge hands a native non-image attachment to the current Harness turn', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-native-file']]);
|
||||
const bytes = Buffer.from('qq-bridge-file');
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
prompts.push({
|
||||
sessionId,
|
||||
prompt,
|
||||
name: options.files[0].name,
|
||||
bytes: await options.files[0].load({ signal: options.signal }),
|
||||
});
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
fetchImpl: async (url, init) => {
|
||||
downloads.push({ url: url.toString(), init });
|
||||
return new Response(bytes);
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message({
|
||||
messageId: 'qq-native-file',
|
||||
content: '',
|
||||
attachments: [{
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'QQ报告.bin',
|
||||
url: 'https://provider-issued-download.example/qq-file',
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.equal(downloads.length, 1);
|
||||
assert.deepEqual(prompts, [{
|
||||
sessionId: 'session-native-file',
|
||||
prompt: '',
|
||||
name: 'QQ报告.bin',
|
||||
bytes,
|
||||
}]);
|
||||
assert.deepEqual(sent, ['文件已收到']);
|
||||
});
|
||||
|
||||
test('QQ starts an ordinary-file download before an earlier queued turn finishes', async () => {
|
||||
const fixture = stateFixture([['c2c:owner-openid', 'session-prefetch-file']]);
|
||||
const firstTurn = deferred();
|
||||
const bytes = Buffer.from('qq-prefetched-file');
|
||||
let asks = 0;
|
||||
let downloads = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _prompt, options) => {
|
||||
asks += 1;
|
||||
if (asks === 1) return firstTurn.promise;
|
||||
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||
return '第二条完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
fetchImpl: async () => {
|
||||
downloads += 1;
|
||||
return new Response(bytes);
|
||||
},
|
||||
});
|
||||
|
||||
const first = bridge.accept(message({ messageId: 'qq-prefetch-first', content: '先等待' }));
|
||||
await eventually(() => asks === 1);
|
||||
const second = bridge.accept(message({
|
||||
messageId: 'qq-prefetch-second',
|
||||
content: '',
|
||||
attachments: [{
|
||||
content_type: 'application/octet-stream',
|
||||
filename: 'queued.bin',
|
||||
url: '//provider-issued-download.example/queued-file',
|
||||
}],
|
||||
}));
|
||||
|
||||
await eventually(() => downloads === 1, 'queued QQ file did not start downloading');
|
||||
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||
firstTurn.resolve('第一条完成');
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
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 = [];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import test from 'node:test';
|
|||
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
|
|
@ -214,6 +215,162 @@ test('all four shared text channels execute /compact outside the model prompt pa
|
|||
}
|
||||
});
|
||||
|
||||
test('shared text bridge passes a file-only message through askOptions.files', async () => {
|
||||
const fixture = stateFixture();
|
||||
const source = Object.freeze({
|
||||
name: 'report.bin',
|
||||
load: async () => Buffer.from([0x00, 0xff]),
|
||||
});
|
||||
const asks = [];
|
||||
const sent = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-file-only-inbound',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asks.push({ sessionId, text, files: options.files });
|
||||
return '文件已交给 Harness。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('inbound-file-only', '', { files: [source] }));
|
||||
|
||||
assert.deepEqual(asks, [{
|
||||
sessionId: 'session-file-only-inbound',
|
||||
text: '',
|
||||
files: [source],
|
||||
}]);
|
||||
assert.deepEqual(sent, ['文件已交给 Harness。']);
|
||||
});
|
||||
|
||||
test('shared text bridge keeps caption and native files in one Harness ask', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sources = [
|
||||
{ name: 'one.txt', data: Buffer.from('one') },
|
||||
{ name: 'two.zip', load: async () => Buffer.from('two') },
|
||||
];
|
||||
const asks = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async () => undefined },
|
||||
harness: {
|
||||
createSession: async () => 'session-text-and-files',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asks.push({ sessionId, text, files: options.files });
|
||||
return '完成';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('inbound-text-and-files', '请检查这两个文件', { files: sources }));
|
||||
|
||||
assert.deepEqual(asks, [{
|
||||
sessionId: 'session-text-and-files',
|
||||
text: '请检查这两个文件',
|
||||
files: sources,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('a command-looking file caption is an ordinary Harness prompt, not a bridge command', async () => {
|
||||
const fixture = stateFixture({ 'direct:chat-a': 'session-existing' });
|
||||
const source = { name: 'new.txt', data: Buffer.from('not a command') };
|
||||
const asks = [];
|
||||
const sent = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, text, options) => {
|
||||
asks.push({ sessionId, text, files: options.files });
|
||||
return '附件消息已处理';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('file-caption-command', '/new', { files: [source] }));
|
||||
|
||||
assert.equal(fixture.sessions.get('direct:chat-a'), 'session-existing');
|
||||
assert.deepEqual(asks, [{
|
||||
sessionId: 'session-existing',
|
||||
text: '/new',
|
||||
files: [source],
|
||||
}]);
|
||||
assert.deepEqual(sent, ['附件消息已处理']);
|
||||
});
|
||||
|
||||
test('an inbound file cannot claim a pending Harness interaction answer', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const questionAnswered = deferred();
|
||||
let interactionResponses = 0;
|
||||
const asks = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-file-during-question',
|
||||
ask: async (sessionId, text, options) => {
|
||||
asks.push({ sessionId, text, files: options.files });
|
||||
await options.onInteraction(questionInteraction({
|
||||
sessionId,
|
||||
questions: [{ id: 'confirm', question: '是否继续?' }],
|
||||
respond: async (result) => {
|
||||
interactionResponses += 1;
|
||||
questionAnswered.resolve(result);
|
||||
return { accepted: true };
|
||||
},
|
||||
}));
|
||||
await questionAnswered.promise;
|
||||
return '继续执行';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const processing = bridge.accept(message('question-with-file-start', '先询问我'));
|
||||
await eventually(() => sent.some((text) => text.includes('是否继续?')));
|
||||
await bridge.accept(message('question-file-attempt', 'yes', {
|
||||
files: [{ name: 'answer.txt', data: Buffer.from('yes') }],
|
||||
}));
|
||||
|
||||
assert.equal(interactionResponses, 0);
|
||||
assert.equal(asks.length, 1, 'the attachment must not become a sibling ask while interaction is open');
|
||||
assert.equal(sent.at(-1), '请用文字回答当前问题。');
|
||||
|
||||
await bridge.accept(message('question-text-answer', 'yes'));
|
||||
await processing;
|
||||
assert.equal(interactionResponses, 1);
|
||||
assert.equal(sent.at(-1), '继续执行');
|
||||
});
|
||||
|
||||
test('shared text bridge reports a safe native-file download failure', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = createBridge({
|
||||
state: fixture.state,
|
||||
bot: { sendText: async (_target, text) => sent.push(text) },
|
||||
harness: {
|
||||
createSession: async () => 'session-file-download-failure',
|
||||
ask: async () => {
|
||||
throw new InboundFileError(
|
||||
'inbound-file-download-failed',
|
||||
'private channel URL https://secret.example/token failed',
|
||||
'文件下载失败,请重新发送后再试。',
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message('file-download-failure', '', {
|
||||
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
|
||||
}));
|
||||
|
||||
assert.deepEqual(sent, ['文件下载失败,请重新发送后再试。']);
|
||||
assert.doesNotMatch(sent[0], /secret|token|https:/i);
|
||||
});
|
||||
|
||||
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
|
||||
for (const [name, Bridge] of [
|
||||
['slack', SlackHarnessBridge],
|
||||
|
|
|
|||
|
|
@ -419,10 +419,17 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
|
|||
}), png);
|
||||
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
assert.equal(calls[1].url.hostname, 'files.slack.com');
|
||||
const streamed = await api.downloadFileStream({
|
||||
url: 'https://files.slack.com/files-pri/T123-F126/download/notes.txt',
|
||||
});
|
||||
const streamedChunks = [];
|
||||
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
|
||||
assert.deepEqual(Buffer.concat(streamedChunks), png);
|
||||
assert.equal(calls[2].options.signal, undefined);
|
||||
await assert.rejects(() => api.downloadFile({
|
||||
url: 'https://example.com/internal.png', maxBytes: 100,
|
||||
}), /messaging platform/);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls.length, 3);
|
||||
|
||||
const redirectCalls = [];
|
||||
const redirectingApi = new SlackApi({
|
||||
|
|
@ -448,6 +455,29 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
|
|||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
|
||||
});
|
||||
|
||||
test('Slack resolves file_access metadata through files.info', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
file: {
|
||||
id: 'F12345678',
|
||||
name: 'connect.txt',
|
||||
mimetype: 'text/plain',
|
||||
url_private: 'https://files.slack.com/files-pri/T123-F123/connect.txt',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal((await api.fileInfo({ fileId: 'F12345678' })).name, 'connect.txt');
|
||||
assert.equal(calls[0].url.pathname.endsWith('/files.info'), true);
|
||||
assert.equal(calls[0].options.body, 'file=F12345678');
|
||||
assert.equal(calls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
|
||||
});
|
||||
|
||||
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
|
||||
const workspaceCalls = [];
|
||||
const workspaceApi = new SlackApi({
|
||||
|
|
@ -711,7 +741,7 @@ 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 () => {
|
||||
test('Slack keeps image shares in image prompts and exposes ordinary files lazily', async () => {
|
||||
const loads = [];
|
||||
const direct = normalizeSlackEvent({
|
||||
event_id: 'Ev010',
|
||||
|
|
@ -734,14 +764,32 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
|||
},
|
||||
}, 'U12345678', {
|
||||
loadFile: async (url, options) => {
|
||||
loads.push({ url, options });
|
||||
loads.push({ kind: 'image', url, options });
|
||||
return Buffer.from('image');
|
||||
},
|
||||
loadFileStream: async (url, options) => {
|
||||
loads.push({ kind: 'file', url, options });
|
||||
return { stream: (async function* content() { yield Buffer.from('file'); }()) };
|
||||
},
|
||||
});
|
||||
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$/);
|
||||
assert.equal(direct.files.length, 1);
|
||||
assert.deepEqual({
|
||||
name: direct.files[0].name,
|
||||
mediaType: direct.files[0].mediaType,
|
||||
size: direct.files[0].size,
|
||||
}, { name: 'notes.txt', mediaType: 'text/plain', size: 20 });
|
||||
const controller = new AbortController();
|
||||
const loadedFile = await direct.files[0].load({ signal: controller.signal });
|
||||
const chunks = [];
|
||||
for await (const chunk of loadedFile.stream) chunks.push(Buffer.from(chunk));
|
||||
assert.equal(Buffer.concat(chunks).toString(), 'file');
|
||||
assert.match(loads[1].url, /notes\.txt$/);
|
||||
assert.equal(loads[1].kind, 'file');
|
||||
assert.deepEqual(loads[1].options, { signal: controller.signal });
|
||||
|
||||
const group = normalizeSlackEvent({
|
||||
event_id: 'Ev011',
|
||||
|
|
@ -757,6 +805,7 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
|||
}, 'U12345678');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.images.length, 1);
|
||||
assert.deepEqual(group.files, []);
|
||||
|
||||
assert.equal(normalizeSlackEvent({
|
||||
event_id: 'Ev012',
|
||||
|
|
@ -767,6 +816,54 @@ test('Slack accepts image file shares and keeps other files out of image prompts
|
|||
}, 'U12345678'), null);
|
||||
});
|
||||
|
||||
test('Slack Connect file placeholders load metadata before streaming the file', async () => {
|
||||
const calls = [];
|
||||
const message = normalizeSlackEvent({
|
||||
event_id: 'Ev013',
|
||||
event: {
|
||||
type: 'message',
|
||||
subtype: 'file_share',
|
||||
channel_type: 'im',
|
||||
channel: 'D12345678',
|
||||
user: 'U87654321',
|
||||
ts: '1700000000.013',
|
||||
text: '',
|
||||
files: [{
|
||||
id: 'F12345681',
|
||||
mode: 'file_access',
|
||||
file_access: 'check_file_info',
|
||||
}],
|
||||
},
|
||||
}, 'U12345678', {
|
||||
loadFileInfo: async (fileId, options) => {
|
||||
calls.push({ kind: 'info', fileId, options });
|
||||
return {
|
||||
id: fileId,
|
||||
name: 'connect-report.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
url_private_download: 'https://files.slack.com/files-pri/T123-F123/connect-report.pdf',
|
||||
};
|
||||
},
|
||||
loadFileStream: async (url, options) => {
|
||||
calls.push({ kind: 'stream', url, options });
|
||||
return { stream: (async function* content() { yield Buffer.from('connect'); }()) };
|
||||
},
|
||||
});
|
||||
assert.equal(message.files.length, 1);
|
||||
const controller = new AbortController();
|
||||
const loaded = await message.files[0].load({ signal: controller.signal });
|
||||
assert.equal(loaded.name, 'connect-report.pdf');
|
||||
assert.equal(loaded.mediaType, 'application/pdf');
|
||||
const chunks = [];
|
||||
for await (const chunk of loaded.stream) chunks.push(Buffer.from(chunk));
|
||||
assert.equal(Buffer.concat(chunks).toString(), 'connect');
|
||||
assert.deepEqual(calls[0], {
|
||||
kind: 'info', fileId: 'F12345681', options: { signal: controller.signal },
|
||||
});
|
||||
assert.equal(calls[1].kind, 'stream');
|
||||
assert.match(calls[1].url, /connect-report\.pdf$/);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
|
|
|
|||
|
|
@ -139,11 +139,16 @@ test('Telegram API resolves and downloads files without exposing arbitrary paths
|
|||
},
|
||||
});
|
||||
assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png);
|
||||
assert.equal(calls.length, 2);
|
||||
const streamed = await api.downloadFileStream({ fileId: 'AgAC_test-file' });
|
||||
const streamedChunks = [];
|
||||
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
|
||||
assert.deepEqual(Buffer.concat(streamedChunks), png);
|
||||
assert.equal(calls.length, 4);
|
||||
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$/);
|
||||
assert.equal(calls[3].options.signal, undefined);
|
||||
|
||||
const unsafeApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
|
|
@ -799,7 +804,7 @@ test('Telegram compatible mode preserves old routing and private allowlist mode
|
|||
}), false);
|
||||
});
|
||||
|
||||
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
||||
test('Telegram keeps native photos and image documents as images and exposes ordinary documents as files', async () => {
|
||||
const loads = [];
|
||||
const groupPhoto = normalizeTelegramUpdate({
|
||||
update_id: 20,
|
||||
|
|
@ -839,9 +844,23 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
|||
file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000,
|
||||
},
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
}, {
|
||||
botId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
loadFile: async (fileId, options) => {
|
||||
loads.push({ fileId, options });
|
||||
return Buffer.from('document');
|
||||
},
|
||||
});
|
||||
assert.equal(document.images[0].name, 'diagram.png');
|
||||
assert.equal(document.images[0].mediaType, 'image/png');
|
||||
assert.equal(document.images[0].size, 3_000);
|
||||
assert.deepEqual(document.files, []);
|
||||
await document.images[0].load({ maxBytes: 5_000 });
|
||||
assert.deepEqual(loads[1], {
|
||||
fileId: 'png-document',
|
||||
options: { maxBytes: 5_000 },
|
||||
});
|
||||
|
||||
const documentWithoutMime = normalizeTelegramUpdate({
|
||||
update_id: 23,
|
||||
|
|
@ -852,8 +871,11 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
|||
document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 },
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(documentWithoutMime.images[0].name, 'diagram.webp');
|
||||
assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp');
|
||||
assert.deepEqual(documentWithoutMime.files, []);
|
||||
|
||||
const fileLoads = [];
|
||||
const pdf = normalizeTelegramUpdate({
|
||||
update_id: 22,
|
||||
message: {
|
||||
|
|
@ -862,8 +884,26 @@ test('Telegram normalizes photo captions and image documents into one downloadab
|
|||
from: { id: 42, is_bot: false },
|
||||
document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' },
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
}, {
|
||||
botId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
loadFileStream: async (fileId, options) => {
|
||||
fileLoads.push({ fileId, options });
|
||||
return { stream: (async function* content() { yield Buffer.from('pdf'); }()) };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(pdf.images, []);
|
||||
assert.equal(pdf.files[0].name, 'file.pdf');
|
||||
assert.equal(pdf.files[0].mediaType, 'application/pdf');
|
||||
const controller = new AbortController();
|
||||
const loadedPdf = await pdf.files[0].load({ signal: controller.signal });
|
||||
const pdfChunks = [];
|
||||
for await (const chunk of loadedPdf.stream) pdfChunks.push(Buffer.from(chunk));
|
||||
assert.equal(Buffer.concat(pdfChunks).toString(), 'pdf');
|
||||
assert.deepEqual(fileLoads, [{
|
||||
fileId: 'pdf-document',
|
||||
options: { signal: controller.signal },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
|
|
|
|||
|
|
@ -397,6 +397,78 @@ test('Enterprise WeChat preserves mixed-message text and image order', async ()
|
|||
]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat exposes native file callbacks through the SDK downloader without file limits', async () => {
|
||||
const calls = [];
|
||||
const bytes = Buffer.from('wecom-native-file');
|
||||
const message = wecomInboundMessage(frame({
|
||||
msgtype: 'file',
|
||||
text: undefined,
|
||||
file: {
|
||||
url: 'https://wecom.example/encrypted-file',
|
||||
aeskey: 'file-specific-key',
|
||||
},
|
||||
}), {
|
||||
downloadFile: async (url, aeskey) => {
|
||||
calls.push({ url, aeskey });
|
||||
return { buffer: bytes, filename: '企业微信报告.pdf' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(message.content, '');
|
||||
assert.deepEqual(message.images, []);
|
||||
assert.equal(message.files.length, 1);
|
||||
assert.equal(message.files[0].name, 'file');
|
||||
assert.equal(calls.length, 0, 'file download stays lazy at normalization time');
|
||||
assert.deepEqual(await message.files[0].load({}), {
|
||||
data: bytes,
|
||||
name: '企业微信报告.pdf',
|
||||
});
|
||||
assert.deepEqual(calls, [{
|
||||
url: 'https://wecom.example/encrypted-file',
|
||||
aeskey: 'file-specific-key',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
|
||||
const transport = testClient();
|
||||
const bytes = Buffer.from('wecom-bridge-file');
|
||||
const downloads = [];
|
||||
const prompts = [];
|
||||
transport.client.downloadFile = async (url, aeskey) => {
|
||||
downloads.push({ url, aeskey });
|
||||
return { buffer: bytes, filename: '企微报告.docx' };
|
||||
};
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: transport.client,
|
||||
generateStreamId: () => 'file-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
const loaded = await options.files[0].load({ signal: options.signal });
|
||||
prompts.push({ sessionId, prompt, name: options.files[0].name, loaded });
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
await bridge.accept(frame({
|
||||
msgid: 'wecom-native-file',
|
||||
msgtype: 'file',
|
||||
text: undefined,
|
||||
file: { url: 'https://wecom.example/file', aeskey: 'file-key' },
|
||||
}));
|
||||
|
||||
assert.deepEqual(downloads, [{ url: 'https://wecom.example/file', aeskey: 'file-key' }]);
|
||||
assert.deepEqual(prompts, [{
|
||||
sessionId: 'session-existing',
|
||||
prompt: '',
|
||||
name: 'file',
|
||||
loaded: { data: bytes, name: '企微报告.docx' },
|
||||
}]);
|
||||
assert.equal(transport.streamed.at(-1).content, '文件已收到');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat starts image download before an earlier conversation turn finishes', async () => {
|
||||
const transport = testClient();
|
||||
const firstStarted = deferred();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import test from 'node:test';
|
|||
import {
|
||||
createWeixinApi,
|
||||
decryptWeixinImage,
|
||||
extractWeixinFiles,
|
||||
extractWeixinText,
|
||||
normalizeWeixinApiBaseUrl,
|
||||
parseWeixinImageAesKey,
|
||||
|
|
@ -65,6 +66,42 @@ test('iLink image references download lazily from the canonical CDN and decrypt
|
|||
assert.equal(calls[0].init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('iLink native file items download lazily and decrypt without image size or type rules', async () => {
|
||||
const key = randomBytes(16);
|
||||
const plaintext = Buffer.from('weixin-native-file');
|
||||
const ciphertext = encryptImage(plaintext, key);
|
||||
const calls = [];
|
||||
const files = extractWeixinFiles({
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'native-file-ticket',
|
||||
aes_key: Buffer.from(key.toString('hex')).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: '微信报告.zip',
|
||||
len: String(plaintext.length),
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return new Response(ciphertext);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, '微信报告.zip');
|
||||
assert.equal(files[0].size, plaintext.length);
|
||||
assert.equal(calls.length, 0, 'file download stays lazy');
|
||||
assert.deepEqual(await files[0].load({}), plaintext);
|
||||
assert.deepEqual(calls, [{
|
||||
url: 'https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=native-file-ticket',
|
||||
init: { method: 'GET', signal: undefined, redirect: 'manual' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Weixin image keys support both documented CDN encodings and reject unsafe URLs', () => {
|
||||
const key = randomBytes(16);
|
||||
assert.equal(parseWeixinImageAesKey({ aeskey: key.toString('hex') }).equals(key), true);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import {
|
||||
createWeixinBridgeStatus,
|
||||
weixinInboundMessage,
|
||||
WeixinHarnessBridge,
|
||||
} from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -78,6 +82,125 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin bridge normalization keeps native file items separate from images', async () => {
|
||||
const bytes = Buffer.from('weixin-file');
|
||||
const source = { name: '微信文档.docx', load: async () => bytes };
|
||||
const calls = [];
|
||||
const inbound = weixinInboundMessage(message('weixin-file', '', {
|
||||
item_list: [{ type: 4, file_item: { file_name: '微信文档.docx', media: {} } }],
|
||||
}), {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: (value) => {
|
||||
calls.push(value);
|
||||
return [source];
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(inbound.content, '');
|
||||
assert.deepEqual(inbound.images, []);
|
||||
assert.deepEqual(inbound.files, [source]);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(await inbound.files[0].load({}), bytes);
|
||||
});
|
||||
|
||||
test('Weixin bridge hands a native file source to the current Harness turn', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-native-file');
|
||||
const bytes = Buffer.from('weixin-bridge-file');
|
||||
const prompts = [];
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: () => [{ name: '微信报告.zip', load: async () => bytes }],
|
||||
sendText: async ({ text }) => sent.push(text),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, prompt, options) => {
|
||||
prompts.push({
|
||||
sessionId,
|
||||
prompt,
|
||||
name: options.files[0].name,
|
||||
bytes: await options.files[0].load({ signal: options.signal }),
|
||||
});
|
||||
return '文件已收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status: createWeixinBridgeStatus(),
|
||||
});
|
||||
|
||||
await bridge.accept(message('weixin-native-file-turn', '', {
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: { file_name: '微信报告.zip', media: {} },
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.deepEqual(prompts, [{
|
||||
sessionId: 'session-native-file',
|
||||
prompt: '',
|
||||
name: '微信报告.zip',
|
||||
bytes,
|
||||
}]);
|
||||
assert.deepEqual(sent, ['文件已收到']);
|
||||
});
|
||||
|
||||
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
|
||||
const firstTurn = deferred();
|
||||
const bytes = Buffer.from('weixin-prefetched-file');
|
||||
let asks = 0;
|
||||
let downloads = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
inboundFiles: (value) => value.item_list?.some((item) => item.file_item)
|
||||
? [{
|
||||
name: 'queued.bin',
|
||||
load: async () => {
|
||||
downloads += 1;
|
||||
return bytes;
|
||||
},
|
||||
}]
|
||||
: [],
|
||||
sendText: async () => {},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _prompt, options) => {
|
||||
asks += 1;
|
||||
if (asks === 1) return firstTurn.promise;
|
||||
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
|
||||
return '第二条完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const first = bridge.accept(message('weixin-prefetch-first', '先等待'));
|
||||
await eventually(() => asks === 1);
|
||||
const second = bridge.accept(message('weixin-prefetch-second', '', {
|
||||
item_list: [{
|
||||
type: 4,
|
||||
file_item: { file_name: 'queued.bin', media: {} },
|
||||
}],
|
||||
}));
|
||||
|
||||
await eventually(() => downloads === 1, 'queued Weixin file did not start downloading');
|
||||
assert.equal(asks, 1, 'the second Harness turn must remain queued');
|
||||
firstTurn.resolve('第一条完成');
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import {
|
||||
WhatsappRuntime,
|
||||
createWhatsappMediaDownloader,
|
||||
normalizeWhatsappMessage,
|
||||
whatsappInboundAllowed,
|
||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
|
|
@ -255,15 +256,21 @@ test('WhatsApp Web session accepts recent append events without replaying stale
|
|||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
|
||||
const events = new EventEmitter();
|
||||
const received = [];
|
||||
const contexts = [];
|
||||
const socket = {
|
||||
ev: events,
|
||||
updateMediaMessage: async () => {},
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: () => {},
|
||||
onMessage: async (message) => { received.push(message.key.id); },
|
||||
makeSocket: () => ({
|
||||
ev: events,
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
}),
|
||||
onMessage: async (message, context) => {
|
||||
received.push(message.key.id);
|
||||
contexts.push(context);
|
||||
},
|
||||
makeSocket: () => socket,
|
||||
loadAuthState: async () => ({
|
||||
state: {
|
||||
creds: {},
|
||||
|
|
@ -286,9 +293,33 @@ test('WhatsApp Web session accepts recent append events without replaying stale
|
|||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(received, ['recent', 'notify']);
|
||||
assert.equal(contexts[0].socket, socket);
|
||||
assert.equal(contexts[1].socket, socket);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('WhatsApp media downloader supplies Baileys reupload context', async () => {
|
||||
const reuploaded = [];
|
||||
const socket = {
|
||||
updateMediaMessage: async (message) => {
|
||||
reuploaded.push(message);
|
||||
return { ...message, reuploaded: true };
|
||||
},
|
||||
};
|
||||
let receivedContext;
|
||||
const download = createWhatsappMediaDownloader({
|
||||
socket,
|
||||
logger: { info() {} },
|
||||
download: async (_message, _type, _options, context) => {
|
||||
receivedContext = context;
|
||||
return context.reuploadRequest({ key: { id: 'expired-media' } });
|
||||
},
|
||||
});
|
||||
assert.equal((await download({}, 'stream', {})).reuploaded, true);
|
||||
assert.equal(typeof receivedContext.logger.info, 'function');
|
||||
assert.deepEqual(reuploaded, [{ key: { id: 'expired-media' } }]);
|
||||
});
|
||||
|
||||
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
|
||||
const direct = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||
|
|
@ -371,7 +402,7 @@ test('WhatsApp access modes allow self-chat, selected contacts, or the existing
|
|||
}), true);
|
||||
});
|
||||
|
||||
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
|
||||
test('WhatsApp keeps native and document images as images and exposes ordinary documents as files', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const calls = [];
|
||||
const controller = new AbortController();
|
||||
|
|
@ -441,9 +472,52 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
|
|||
url: 'https://mmg.whatsapp.net/document',
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID);
|
||||
}, ACCOUNT_JID, {
|
||||
download: async (raw, type, options) => {
|
||||
calls.push({ raw, type, options });
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() { yield Buffer.from('document'); },
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(document.images[0].name, 'diagram.webp');
|
||||
assert.equal(document.images[0].mediaType, 'image/webp');
|
||||
assert.equal(document.images[0].size, 2_000);
|
||||
assert.deepEqual(document.files, []);
|
||||
const documentController = new AbortController();
|
||||
assert.equal((await document.images[0].load({
|
||||
signal: documentController.signal,
|
||||
maxBytes: 5_000,
|
||||
})).toString(), 'document');
|
||||
assert.equal(calls[1].type, 'stream');
|
||||
assert.equal(calls[1].options.options.signal instanceof AbortSignal, true);
|
||||
|
||||
const ordinaryDocument = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-pdf-1', fromMe: false },
|
||||
message: {
|
||||
documentMessage: {
|
||||
mimetype: 'application/pdf', fileName: 'report.pdf', fileLength: 4_000,
|
||||
url: 'https://mmg.whatsapp.net/document-pdf',
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID, {
|
||||
download: async (raw, type, options) => {
|
||||
calls.push({ raw, type, options });
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() { yield Buffer.from('pdf'); },
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(ordinaryDocument.images, []);
|
||||
assert.equal(ordinaryDocument.files[0].name, 'report.pdf');
|
||||
assert.equal(ordinaryDocument.files[0].mediaType, 'application/pdf');
|
||||
assert.equal(ordinaryDocument.files[0].size, 4_000);
|
||||
const loadedDocument = await ordinaryDocument.files[0].load({ signal: documentController.signal });
|
||||
const documentChunks = [];
|
||||
for await (const chunk of loadedDocument.stream) documentChunks.push(Buffer.from(chunk));
|
||||
assert.equal(Buffer.concat(documentChunks).toString(), 'pdf');
|
||||
assert.equal(calls[2].type, 'stream');
|
||||
assert.equal(calls[2].options.options.signal, documentController.signal);
|
||||
|
||||
for (const [index, wrapper] of [
|
||||
'viewOnceMessage',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
|
|
@ -88,14 +91,16 @@ test('Host control executor refuses idle, replaced, closed, and foreign turns wi
|
|||
});
|
||||
|
||||
test('Host executors preserve HTTP fallback when AgentRegistry or attachment is absent', async () => {
|
||||
assert.deepEqual(createHarnessSessionExecutors({}), {
|
||||
controlExecutor: undefined,
|
||||
sessionMaintenanceExecutor: undefined,
|
||||
});
|
||||
assert.deepEqual(createHarnessSessionExecutors({ get() { throw new Error('not injected'); } }), {
|
||||
controlExecutor: undefined,
|
||||
sessionMaintenanceExecutor: undefined,
|
||||
const withoutRegistry = createHarnessSessionExecutors({});
|
||||
assert.equal(withoutRegistry.controlExecutor, undefined);
|
||||
assert.equal(withoutRegistry.sessionMaintenanceExecutor, undefined);
|
||||
assert.equal(typeof withoutRegistry.fileIngressExecutor, 'function');
|
||||
const inaccessibleRegistry = createHarnessSessionExecutors({
|
||||
get() { throw new Error('not injected'); },
|
||||
});
|
||||
assert.equal(inaccessibleRegistry.controlExecutor, undefined);
|
||||
assert.equal(inaccessibleRegistry.sessionMaintenanceExecutor, undefined);
|
||||
assert.equal(typeof inaccessibleRegistry.fileIngressExecutor, 'function');
|
||||
|
||||
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(
|
||||
contextWith({ get: () => undefined }),
|
||||
|
|
@ -115,6 +120,63 @@ test('Host executors preserve HTTP fallback when AgentRegistry or attachment is
|
|||
assert.equal(operated, true);
|
||||
});
|
||||
|
||||
test('Host file ingress stages bytes in the exact attached Session cwd', async (t) => {
|
||||
const defaultWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-default-cwd-'));
|
||||
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-session-cwd-'));
|
||||
t.after(() => Promise.all([
|
||||
rm(defaultWorkspace, { recursive: true, force: true }),
|
||||
rm(sessionWorkspace, { recursive: true, force: true }),
|
||||
]));
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'session-exact-cwd', cwd: sessionWorkspace },
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
const registry = {
|
||||
get: (sessionId) => sessionId === 'session-exact-cwd' ? agent : undefined,
|
||||
};
|
||||
const { fileIngressExecutor } = createHarnessSessionExecutors(contextWith(registry));
|
||||
|
||||
const staged = await fileIngressExecutor({
|
||||
sessionId: 'session-exact-cwd',
|
||||
workspace: defaultWorkspace,
|
||||
files: [{ name: 'attached.txt', data: Buffer.from('session bytes') }],
|
||||
});
|
||||
|
||||
assert.equal(staged.files.length, 1);
|
||||
assert.equal(staged.files[0].name, 'attached.txt');
|
||||
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-attached\.txt$/);
|
||||
assert.equal(
|
||||
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
|
||||
'session bytes',
|
||||
);
|
||||
await assert.rejects(
|
||||
readFile(resolve(defaultWorkspace, staged.files[0].path)),
|
||||
/ENOENT/,
|
||||
'the plugin/default workspace must not receive another Session attachment',
|
||||
);
|
||||
await staged.cleanup();
|
||||
});
|
||||
|
||||
test('Host file ingress uses session.list cwd while the Session is still cold', async (t) => {
|
||||
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-cold-session-cwd-'));
|
||||
t.after(() => rm(sessionWorkspace, { recursive: true, force: true }));
|
||||
const { fileIngressExecutor } = createHarnessSessionExecutors({});
|
||||
|
||||
const staged = await fileIngressExecutor({
|
||||
sessionId: 'session-cold',
|
||||
workspace: sessionWorkspace,
|
||||
files: [{ name: 'cold.txt', data: Buffer.from('cold session bytes') }],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
|
||||
'cold session bytes',
|
||||
);
|
||||
await staged.cleanup();
|
||||
});
|
||||
|
||||
test('Host maintenance executor claims idle synchronously and reports busy with a stable code', async () => {
|
||||
const maintenanceController = new AbortController();
|
||||
let operationStarted = false;
|
||||
|
|
|
|||
172
test/inbound-file.test.mjs
Normal file
172
test/inbound-file.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
InboundFileError,
|
||||
stageInboundFiles,
|
||||
} from '../src/channels/shared/inbound-file.mjs';
|
||||
|
||||
async function workspace(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-inbound-file-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function absoluteStagedPath(root, file) {
|
||||
assert.equal(isAbsolute(file.path), false, 'Harness receives a workspace-relative path');
|
||||
const path = resolve(root, file.path);
|
||||
assert.equal(path.startsWith(`${resolve(root)}/`), true, 'staged path stays in the Session cwd');
|
||||
return path;
|
||||
}
|
||||
|
||||
test('stageInboundFiles preserves exact bytes, including a zero-byte file', async (t) => {
|
||||
const root = await workspace(t);
|
||||
const binary = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x0a, 0x0d]);
|
||||
const staged = await stageInboundFiles({
|
||||
files: [
|
||||
{ name: 'binary.dat', data: binary, mediaType: 'application/octet-stream' },
|
||||
{ name: 'empty.txt', data: Buffer.alloc(0), mediaType: 'text/plain' },
|
||||
],
|
||||
}, { workspace: root });
|
||||
|
||||
assert.deepEqual(staged.files.map(({ name, mediaType }) => ({ name, mediaType })), [
|
||||
{ name: 'binary.dat', mediaType: 'application/octet-stream' },
|
||||
{ name: 'empty.txt', mediaType: 'text/plain' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await readFile(absoluteStagedPath(root, staged.files[0])),
|
||||
binary,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(absoluteStagedPath(root, staged.files[1])),
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
|
||||
const turnDirectory = resolve(root, staged.files[0].path, '..');
|
||||
await staged.cleanup();
|
||||
await assert.rejects(readFile(resolve(turnDirectory, '01-binary.dat')), /ENOENT/);
|
||||
});
|
||||
|
||||
test('stageInboundFiles writes async streams without changing their bytes', async (t) => {
|
||||
const root = await workspace(t);
|
||||
const chunks = [
|
||||
Buffer.from([0xde, 0xad]),
|
||||
new Uint8Array([0xbe, 0xef]),
|
||||
Buffer.from('stream tail'),
|
||||
];
|
||||
const expected = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
const staged = await stageInboundFiles({
|
||||
files: [{
|
||||
name: 'stream.bin',
|
||||
load: async () => ({
|
||||
stream: Readable.from(chunks),
|
||||
name: 'provider-stream.bin',
|
||||
mimetype: 'application/x-provider-stream',
|
||||
}),
|
||||
}],
|
||||
}, { workspace: root });
|
||||
|
||||
assert.deepEqual(staged.files, [{
|
||||
name: 'provider-stream.bin',
|
||||
path: staged.files[0].path,
|
||||
mediaType: 'application/x-provider-stream',
|
||||
}]);
|
||||
assert.deepEqual(await readFile(absoluteStagedPath(root, staged.files[0])), expected);
|
||||
await staged.cleanup();
|
||||
});
|
||||
|
||||
test('stageInboundFiles reports failures raised while streaming provider bytes as file downloads', async (t) => {
|
||||
const root = await workspace(t);
|
||||
await assert.rejects(stageInboundFiles({
|
||||
files: [{
|
||||
name: 'broken.bin',
|
||||
load: async () => ({
|
||||
stream: Readable.from((async function* brokenStream() {
|
||||
yield Buffer.from('partial');
|
||||
throw new Error('provider stream disconnected');
|
||||
}())),
|
||||
}),
|
||||
}],
|
||||
}, { workspace: root }), (error) => (
|
||||
error instanceof InboundFileError
|
||||
&& error.code === 'inbound-file-download-failed'
|
||||
&& error.userMessage === '文件下载失败,请重新发送后再试。'
|
||||
&& error.cause?.message === 'provider stream disconnected'
|
||||
));
|
||||
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
|
||||
});
|
||||
|
||||
test('stageInboundFiles removes the whole partial batch when any file download fails', async (t) => {
|
||||
const root = await workspace(t);
|
||||
let secondAttempted = false;
|
||||
|
||||
await assert.rejects(
|
||||
stageInboundFiles({
|
||||
files: [
|
||||
{ name: 'first.txt', data: Buffer.from('must be removed') },
|
||||
{
|
||||
name: 'second.txt',
|
||||
async load() {
|
||||
secondAttempted = true;
|
||||
throw new Error('private signed URL expired');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'third.txt',
|
||||
async load() {
|
||||
assert.fail('files after a failed member must not be downloaded');
|
||||
},
|
||||
},
|
||||
],
|
||||
}, { workspace: root }),
|
||||
(error) => error instanceof InboundFileError
|
||||
&& error.code === 'inbound-file-download-failed'
|
||||
&& error.userMessage === '文件下载失败,请重新发送后再试。',
|
||||
);
|
||||
assert.equal(secondAttempted, true);
|
||||
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
|
||||
});
|
||||
|
||||
test('stageInboundFiles keeps display names but makes traversal-like storage names path-safe', async (t) => {
|
||||
const root = await workspace(t);
|
||||
const staged = await stageInboundFiles({
|
||||
files: [{
|
||||
name: '../../private\\folder/..\\report:?*.txt\u0000\n',
|
||||
data: Buffer.from('safe'),
|
||||
}],
|
||||
}, { workspace: root });
|
||||
|
||||
assert.equal(staged.files[0].name, 'report:?*.txt');
|
||||
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-report___\.txt$/);
|
||||
assert.equal(await readFile(absoluteStagedPath(root, staged.files[0]), 'utf8'), 'safe');
|
||||
await staged.cleanup();
|
||||
});
|
||||
|
||||
test('stageInboundFiles adds no count, extension, MIME, or declared-size acceptance limits', async (t) => {
|
||||
const root = await workspace(t);
|
||||
const sources = Array.from({ length: 33 }, (_, index) => ({
|
||||
name: index === 0 ? 'no-extension' : `unknown-${index}.unsupported-${index}`,
|
||||
mediaType: `application/x-unknown-${index}`,
|
||||
size: Number.MAX_SAFE_INTEGER,
|
||||
data: Buffer.from(`file-${index}`),
|
||||
}));
|
||||
|
||||
const staged = await stageInboundFiles({ files: sources }, { workspace: root });
|
||||
assert.equal(staged.files.length, 33);
|
||||
assert.deepEqual(
|
||||
await Promise.all(staged.files.map((file) => readFile(absoluteStagedPath(root, file), 'utf8'))),
|
||||
sources.map((_source, index) => `file-${index}`),
|
||||
);
|
||||
assert.equal(staged.files[0].name, 'no-extension');
|
||||
assert.equal(staged.files.at(-1).mediaType, 'application/x-unknown-32');
|
||||
await staged.cleanup();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue