feat: support inbound files across all channels

This commit is contained in:
xmanrui 2026-08-23 22:39:26 +08:00
parent 29bcb67a64
commit 0a26d7df92
45 changed files with 2651 additions and 343 deletions

View file

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

View file

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