mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 11:40:45 +08:00
feat: add native image delivery across channels
This commit is contained in:
parent
dbc0a4b730
commit
3ec5f1eca7
31 changed files with 2754 additions and 846 deletions
332
test/artifact-delivery.test.mjs
Normal file
332
test/artifact-delivery.test.mjs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
materializeOutboundArtifact,
|
||||
} from '../src/channels/shared/semantic/artifact.mjs';
|
||||
import { deliverOutboundArtifacts } from '../src/channels/shared/semantic/artifact-delivery.mjs';
|
||||
import { createDeliveryReceipt } from '../src/channels/shared/semantic/delivery.mjs';
|
||||
|
||||
let fixtureId = 0;
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
fixtureId += 1;
|
||||
const suffix = String(fixtureId);
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-delivery-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => `artifact-${suffix}` });
|
||||
t.after(() => registry.clear());
|
||||
const sessionId = `session-${suffix}`;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const execution = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, execution);
|
||||
tool.onResult(execution, { isError: false });
|
||||
return registry.take(sessionId, 1)[0];
|
||||
}
|
||||
|
||||
function rejected(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function assertReleased(artifact) {
|
||||
await assert.rejects(
|
||||
materializeOutboundArtifact(artifact),
|
||||
(error) => error?.code === 'artifact-invalid',
|
||||
);
|
||||
}
|
||||
|
||||
test('non-image artifacts use the existing file sender once', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'report.txt', 'report');
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
deliveryId: 'reply-1',
|
||||
channelKey: 'test',
|
||||
sendImage: async () => calls.push('image'),
|
||||
sendFile: async (file) => {
|
||||
calls.push(`file:${file.fileName}`);
|
||||
return { messageId: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['file:report.txt']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['file-1']);
|
||||
assert.equal(delivery.artifactsSent, 1);
|
||||
assert.equal(delivery.artifactSendErrors, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('image artifacts prefer the native image sender', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1, 2, 3]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async (file) => {
|
||||
calls.push(`image:${file.mediaType}`);
|
||||
return { id: 'image-1' };
|
||||
},
|
||||
sendFile: async () => calls.push('file'),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image:image/png']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-image');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['image-1']);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('an image uses file delivery when the channel has no image sender', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.jpg', Buffer.from([1]));
|
||||
let fileCalls = 0;
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
return { id: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fileCalls, 1);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a definitive native-image rejection falls back to file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
calls.push('image');
|
||||
throw rejected('artifact-provider-rejected');
|
||||
},
|
||||
sendFile: async () => {
|
||||
calls.push('file');
|
||||
return { id: 'file-after-image' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image', 'file']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-file');
|
||||
assert.equal(delivery.artifactsSent, 1);
|
||||
assert.equal(delivery.artifactSendErrors, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a failed file fallback produces one failure receipt and one safe notice', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
|
||||
const calls = [];
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
calls.push('image');
|
||||
throw rejected('artifact-provider-rejected');
|
||||
},
|
||||
sendFile: async () => {
|
||||
calls.push('file');
|
||||
throw rejected('artifact-provider-failed');
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
calls.push('notice');
|
||||
return { messageId: 'notice-after-fallback' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['image', 'file', 'notice']);
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['notice-after-fallback']);
|
||||
assert.deepEqual(delivery.receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'failed',
|
||||
reason: 'artifact-provider-failed',
|
||||
}]);
|
||||
assert.equal(delivery.artifactsSent, 0);
|
||||
assert.equal(delivery.artifactSendErrors, 1);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('an uncertain native-image result never sends a duplicate file', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.gif', Buffer.from([1]));
|
||||
let fileCalls = 0;
|
||||
let notices = 0;
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
sendImage: async () => {
|
||||
throw rejected('artifact-delivery-uncertain');
|
||||
},
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
notices += 1;
|
||||
return { messageId: 'notice-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(fileCalls, 0);
|
||||
assert.equal(notices, 1);
|
||||
assert.deepEqual(delivery.receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
assert.equal(delivery.userVisible, true);
|
||||
assert.equal(delivery.artifactSendErrors, 1);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('abort during native image delivery stops without fallback or notice', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1]));
|
||||
const controller = new AbortController();
|
||||
let fileCalls = 0;
|
||||
let notices = 0;
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendImage: async () => {
|
||||
controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
},
|
||||
sendFile: async () => {
|
||||
fileCalls += 1;
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
notices += 1;
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.equal(fileCalls, 0);
|
||||
assert.equal(notices, 0);
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('a pre-aborted delivery releases every unprocessed artifact', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second');
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let sends = 0;
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [first, second],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async () => { sends += 1; },
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.equal(sends, 0);
|
||||
await assertReleased(first);
|
||||
await assertReleased(second);
|
||||
});
|
||||
|
||||
test('an abort while processing multiple artifacts releases the remaining artifacts', async (t) => {
|
||||
const first = await committedArtifact(t, 'first.txt', 'first');
|
||||
const second = await committedArtifact(t, 'second.txt', 'second');
|
||||
const controller = new AbortController();
|
||||
const calls = [];
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [first, second],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async (file) => {
|
||||
calls.push(file.fileName);
|
||||
controller.abort();
|
||||
return { id: 'provider-may-have-sent' };
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
assert.deepEqual(calls, ['first.txt']);
|
||||
await assertReleased(first);
|
||||
await assertReleased(second);
|
||||
});
|
||||
|
||||
test('abort during a failure notice remains terminal and releases the artifact', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.txt', 'result');
|
||||
const controller = new AbortController();
|
||||
|
||||
await assert.rejects(deliverOutboundArtifacts({
|
||||
artifacts: [artifact],
|
||||
channelKey: 'test',
|
||||
signal: controller.signal,
|
||||
sendFile: async () => {
|
||||
throw rejected('artifact-provider-failed');
|
||||
},
|
||||
sendFailureNotice: async () => {
|
||||
controller.abort();
|
||||
return { messageId: 'must-not-be-receipted' };
|
||||
},
|
||||
}), (error) => error?.name === 'AbortError');
|
||||
|
||||
await assertReleased(artifact);
|
||||
});
|
||||
|
||||
test('mixed artifacts preserve order and merge existing receipt semantics', async (t) => {
|
||||
const image = await committedArtifact(t, 'first.png', Buffer.from([1]));
|
||||
const file = await committedArtifact(t, 'second.txt', 'two');
|
||||
const calls = [];
|
||||
const baseReceipt = createDeliveryReceipt({
|
||||
deliveryId: 'reply-mixed',
|
||||
presentation: 'test-text',
|
||||
providerMessageIds: ['text-1'],
|
||||
});
|
||||
|
||||
const delivery = await deliverOutboundArtifacts({
|
||||
artifacts: [image, file],
|
||||
baseReceipt,
|
||||
deliveryId: 'reply-mixed',
|
||||
channelKey: 'test',
|
||||
sendImage: async (materialized) => {
|
||||
calls.push(materialized.fileName);
|
||||
return { id: 'image-1' };
|
||||
},
|
||||
sendFile: async (materialized) => {
|
||||
calls.push(materialized.fileName);
|
||||
return { id: 'file-1' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['first.png', 'second.txt']);
|
||||
assert.equal(delivery.receipt.presentation, 'test-text-and-files');
|
||||
assert.deepEqual(delivery.receipt.providerMessageIds, ['text-1', 'image-1', 'file-1']);
|
||||
assert.deepEqual(delivery.receipt.artifacts.map(({ outcome }) => outcome), ['sent', 'sent']);
|
||||
assert.equal(delivery.artifactsSent, 2);
|
||||
await assertReleased(image);
|
||||
await assertReleased(file);
|
||||
});
|
||||
|
|
@ -173,6 +173,58 @@ test('DingTalk uploads and sends a native file message to the exact robot conver
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk uploads and sends a native image message to the exact robot user', async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: url.toString(), options });
|
||||
if (url.pathname.endsWith('/oauth2/accessToken')) {
|
||||
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
|
||||
}
|
||||
if (url.pathname.endsWith('/media/upload')) {
|
||||
return jsonResponse({ errcode: 0, media_id: '@image-one', type: 'image' });
|
||||
}
|
||||
return jsonResponse({ processQueryKey: 'image-query-one' });
|
||||
};
|
||||
const api = createDingtalkApi({ fetchImpl });
|
||||
const response = await api.sendImage({
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-only-secret',
|
||||
target: {
|
||||
type: 'user',
|
||||
robotCode: 'robot-code',
|
||||
userId: 'user-one',
|
||||
},
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('dingtalk-image'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.processQueryKey, 'image-query-one');
|
||||
const uploadUrl = new URL(calls[1].url);
|
||||
assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com');
|
||||
assert.equal(uploadUrl.pathname, '/media/upload');
|
||||
assert.equal(uploadUrl.searchParams.get('access_token'), 'image-access-token');
|
||||
assert.equal(uploadUrl.searchParams.get('type'), 'image');
|
||||
const media = calls[1].options.body.get('media');
|
||||
assert.equal(media.name, 'result.png');
|
||||
assert.equal(media.type, 'image/png');
|
||||
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-image');
|
||||
|
||||
assert.equal(
|
||||
calls[2].url,
|
||||
`${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`,
|
||||
);
|
||||
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'image-access-token');
|
||||
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||
robotCode: 'robot-code',
|
||||
msgKey: 'sampleImageMsg',
|
||||
msgParam: JSON.stringify({ photoURL: '@image-one' }),
|
||||
userIds: ['user-one'],
|
||||
});
|
||||
});
|
||||
|
||||
function dingtalkFileRequest(overrides = {}) {
|
||||
return {
|
||||
clientId: 'ding-client',
|
||||
|
|
@ -243,6 +295,24 @@ test('DingTalk marks every ambiguous robot file send result as uncertain', async
|
|||
}
|
||||
});
|
||||
|
||||
test('DingTalk keeps ambiguous native image sends in the uncertain bucket', async () => {
|
||||
const api = createDingtalkApi({
|
||||
fetchImpl: dingtalkFileFetch(async () => { throw new TypeError('private socket detail'); }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.sendImage(dingtalkFileRequest({
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('dingtalk-image-error'),
|
||||
},
|
||||
})),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
|
||||
test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
|
|
|
|||
|
|
@ -339,6 +339,110 @@ test('DingTalk returns a registered result file through the native robot convers
|
|||
});
|
||||
});
|
||||
|
||||
test('DingTalk routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_BYTES,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'dingtalk-image',
|
||||
expectedProviderIds: ['dingtalk-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'dingtalk-file',
|
||||
expectedProviderIds: ['dingtalk-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'dingtalk-file',
|
||||
expectedProviderIds: ['dingtalk-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', `session-image-route-${index}`);
|
||||
const calls = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => { throw new Error('text intentionally unavailable'); },
|
||||
sendImage: async ({ file, target }) => {
|
||||
calls.push('image');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
assert.deepEqual(target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return { processQueryKey: 'dingtalk-native-image' };
|
||||
},
|
||||
sendFile: async ({ file, target }) => {
|
||||
calls.push('file');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.deepEqual(target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
return { processQueryKey: 'dingtalk-native-file' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`dingtalk-image-route-${index}`, '生成产物', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'dingtalk-text-failed.pdf', 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
|
|
|
|||
|
|
@ -153,6 +153,45 @@ test('Discord API uploads a result file as a native attachment and preserves the
|
|||
assert.equal(Buffer.from(await attachment.arrayBuffer()).toString(), '<p>discord-result</p>');
|
||||
});
|
||||
|
||||
test('Discord sends PNG and JPEG artifacts as one native inline-preview attachment each', async () => {
|
||||
const requests = [];
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return jsonResponse({ id: '987654321012345681', attachments: [{ id: '1' }] });
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, image] of [{
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
}, {
|
||||
fileName: 'result.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
bytes: Buffer.from([0xff, 0xd8, 0xff]),
|
||||
}].entries()) {
|
||||
await api.createFileMessage({
|
||||
channelId: '123456789012345678',
|
||||
file: {
|
||||
artifactId: `artifact-discord-image-${index}`,
|
||||
deliveryKey: `session:turn:artifact-discord-image-${index}`,
|
||||
...image,
|
||||
},
|
||||
});
|
||||
|
||||
const request = requests[index];
|
||||
assert.match(request.url.pathname, /channels\/123456789012345678\/messages$/);
|
||||
const payload = JSON.parse(request.options.body.get('payload_json'));
|
||||
assert.deepEqual(payload.attachments, [{ id: 0, filename: image.fileName }]);
|
||||
const attachment = request.options.body.get('files[0]');
|
||||
assert.equal(attachment.name, image.fileName);
|
||||
assert.equal(attachment.type, image.mediaType);
|
||||
}
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
|
||||
test('Discord attachment retry reuses one FormData body and one stable nonce', async () => {
|
||||
const bodies = [];
|
||||
const nonces = [];
|
||||
|
|
|
|||
|
|
@ -1977,6 +1977,121 @@ test('reaction failures do not block streaming replies', async () => {
|
|||
assert.equal(status.streamResponses, 1);
|
||||
});
|
||||
|
||||
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_1X1,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'feishu-image',
|
||||
expectedProviderIds: ['om-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'feishu-file',
|
||||
expectedProviderIds: ['om-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_1X1,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(
|
||||
subtest,
|
||||
scenario.fileName,
|
||||
scenario.content,
|
||||
`bridge-image-route-${index}`,
|
||||
);
|
||||
const calls = [];
|
||||
const status = bridgeStatus();
|
||||
const resultFor = (file, presentation, providerMessageId) => ({
|
||||
schemaVersion: 1,
|
||||
deliveryId: file.deliveryKey,
|
||||
presentation,
|
||||
providerMessageIds: [providerMessageId],
|
||||
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
|
||||
});
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => { throw new Error('text intentionally unavailable'); }),
|
||||
channel: {
|
||||
addReaction: async () => 'reaction',
|
||||
removeReaction: async () => undefined,
|
||||
sendImage: async (chatId, file, options) => {
|
||||
calls.push('image');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return resultFor(file, 'feishu-image', 'om-native-image');
|
||||
},
|
||||
sendFile: async (chatId, file, options) => {
|
||||
calls.push('file');
|
||||
assert.equal(chatId, 'oc_chat');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
|
||||
return resultFor(file, 'feishu-file', 'om-native-file');
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: stateFixture([
|
||||
['p2p:ou_user', `session-feishu-image-route-${index}`],
|
||||
]).state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(event(`om-feishu-image-route-${index}`, '生成产物'));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Feishu finalizes the answer card before delivering registered result files and reports partial failure', async (t) => {
|
||||
const html = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'html');
|
||||
const generic = await committedArtifact(t, 'notes.txt', 'notes', 'notes');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function fakeClient(overrides = {}) {
|
|||
reactionsAdded: [],
|
||||
reactionsRemoved: [],
|
||||
fileUploads: [],
|
||||
imageUploads: [],
|
||||
};
|
||||
const client = {
|
||||
cardkit: { v1: {
|
||||
|
|
@ -35,6 +36,12 @@ function fakeClient(overrides = {}) {
|
|||
return { file_key: 'file-key-test' };
|
||||
},
|
||||
},
|
||||
image: {
|
||||
create: async (request) => {
|
||||
calls.imageUploads.push(request);
|
||||
return { data: { image_key: 'image-key-test' } };
|
||||
},
|
||||
},
|
||||
message: {
|
||||
reply: async (request) => {
|
||||
calls.replies.push(request);
|
||||
|
|
@ -62,6 +69,7 @@ function fakeClient(overrides = {}) {
|
|||
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||
if (overrides.uploadFile) client.im.v1.file.create = overrides.uploadFile;
|
||||
if (overrides.uploadImage) client.im.v1.image.create = overrides.uploadImage;
|
||||
if (overrides.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
|
||||
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
|
||||
return { client, calls };
|
||||
|
|
@ -172,6 +180,40 @@ test('VerifiedFeishuChannel uploads a materialized result and replies with a nat
|
|||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel uploads an image and replies with a native image message', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const channel = new VerifiedFeishuChannel({ client });
|
||||
const file = {
|
||||
artifactId: 'artifact-image',
|
||||
deliveryKey: 'delivery-image',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
size: 12,
|
||||
bytes: Buffer.from('png contents'),
|
||||
};
|
||||
|
||||
const receipt = await channel.sendImage('oc_chat', file, { replyTo: 'om_user' });
|
||||
|
||||
assert.equal(calls.fileUploads.length, 0);
|
||||
assert.equal(calls.imageUploads.length, 1);
|
||||
assert.deepEqual(calls.imageUploads[0].data, {
|
||||
image_type: 'message',
|
||||
image: file.bytes,
|
||||
});
|
||||
assert.equal(calls.replies.length, 1);
|
||||
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||
assert.equal(calls.replies[0].data.msg_type, 'image');
|
||||
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { image_key: 'image-key-test' });
|
||||
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'delivery-image',
|
||||
presentation: 'feishu-image',
|
||||
providerMessageIds: ['om-stream'],
|
||||
artifacts: [{ artifactId: 'artifact-image', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
createMessage: async (request) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { qqInboundMessage, QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import {
|
||||
qqInboundMessage,
|
||||
QqHarnessBridge,
|
||||
sendQqImage,
|
||||
} from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
|
|
@ -96,6 +100,158 @@ const PNG_BYTES = Buffer.from([
|
|||
0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
test('QQ native image adapter delegates to the SDK sendImage method', async () => {
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'image-request' };
|
||||
const calls = [];
|
||||
const result = { message: { id: 'qq-image-1' } };
|
||||
const returned = await sendQqImage({
|
||||
sendImage: async (replyTarget, source, options) => {
|
||||
calls.push({ replyTarget, source, options });
|
||||
return result;
|
||||
},
|
||||
}, target, {
|
||||
artifactId: 'image-artifact',
|
||||
deliveryKey: 'image-delivery',
|
||||
fileName: 'diagram.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: PNG_BYTES,
|
||||
});
|
||||
|
||||
assert.equal(returned, result);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].replyTarget, target);
|
||||
assert.deepEqual(calls[0].source.buffer, PNG_BYTES);
|
||||
assert.equal(typeof calls[0].options.onProgress, 'function');
|
||||
});
|
||||
|
||||
test('QQ native image adapter preserves definite SDK rejections', async () => {
|
||||
await assert.rejects(sendQqImage({
|
||||
sendImage: async () => {
|
||||
const error = new Error('unsupported image');
|
||||
error.httpStatus = 415;
|
||||
throw error;
|
||||
},
|
||||
}, { scope: 'c2c', targetId: 'owner-openid' }, {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_BYTES,
|
||||
}), (error) => error.code === 'artifact-provider-rejected');
|
||||
});
|
||||
|
||||
test('QQ native image adapter marks an unacknowledged SDK send as uncertain', async () => {
|
||||
await assert.rejects(sendQqImage({
|
||||
sendImage: async () => new Promise(() => {}),
|
||||
}, { scope: 'c2c', targetId: 'owner-openid' }, {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_BYTES,
|
||||
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('QQ sends an image artifact with the native SDK image method', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'native.png', PNG_BYTES, 'native-image');
|
||||
const images = [];
|
||||
let files = 0;
|
||||
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-native-image' };
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => ({ id: 'qq-text' }),
|
||||
sendImage: async (replyTarget, source, options) => {
|
||||
images.push({ replyTarget, bytes: Buffer.from(source.buffer), options });
|
||||
return { message: { id: 'qq-image' } };
|
||||
},
|
||||
sendFile: async () => { files += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-native-image']]).state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({
|
||||
messageId: 'qq-native-image',
|
||||
replyTarget: target,
|
||||
}));
|
||||
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(images[0].replyTarget, target);
|
||||
assert.deepEqual(images[0].bytes, PNG_BYTES);
|
||||
assert.equal(typeof images[0].options.onProgress, 'function');
|
||||
assert.equal(files, 0);
|
||||
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('QQ falls back to a native file after a definite native-image rejection', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.png', PNG_BYTES, 'image-fallback');
|
||||
const order = [];
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async () => ({ id: 'qq-text' }),
|
||||
sendImage: async () => {
|
||||
order.push('image');
|
||||
const error = new Error('unsupported image');
|
||||
error.httpStatus = 422;
|
||||
throw error;
|
||||
},
|
||||
sendFile: async (_target, source, options) => {
|
||||
order.push(`file:${options.fileName}:${source.buffer.length}`);
|
||||
return { message: { id: 'qq-file' } };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-image-fallback']]).state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-image-fallback' }));
|
||||
|
||||
assert.deepEqual(order, [`image`, `file:fallback.png:${PNG_BYTES.length}`]);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('QQ does not file-fallback after an uncertain native image send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.png', PNG_BYTES, 'image-uncertain');
|
||||
const texts = [];
|
||||
let files = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => {
|
||||
texts.push(text);
|
||||
return { id: `qq-text-${texts.length}` };
|
||||
},
|
||||
sendImage: async () => new Promise(() => {}),
|
||||
sendFile: async () => { files += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: stateFixture([['c2c:owner-openid', 'session-image-uncertain']]).state,
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message({ messageId: 'qq-image-uncertain' }));
|
||||
|
||||
assert.equal(files, 0);
|
||||
assert.match(texts.at(-1), /发送结果未能确认.*不要立即重试/);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'unknown');
|
||||
});
|
||||
|
||||
test('QQ normalizes protocol-relative ordinary-file URLs and lets the CDN redirect', async () => {
|
||||
const bytes = Buffer.from('qq-native-file');
|
||||
const calls = [];
|
||||
|
|
|
|||
|
|
@ -184,6 +184,51 @@ test('Slack API completes the native external-upload flow in the original thread
|
|||
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:write\n/);
|
||||
});
|
||||
|
||||
test('Slack sends PNG and JPEG artifacts through one native image-preview upload flow each', async () => {
|
||||
const calls = [];
|
||||
const api = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
appToken: APP_TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
upload_url: 'https://files.slack.com/upload/v1/IMAGE',
|
||||
file_id: `FIMAGE${calls.length}`,
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
|
||||
const completed = JSON.parse(options.body);
|
||||
return jsonResponse({ ok: true, files: completed.files });
|
||||
},
|
||||
});
|
||||
|
||||
for (const image of [{
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
}, {
|
||||
fileName: 'result.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
bytes: Buffer.from([0xff, 0xd8, 0xff]),
|
||||
}]) {
|
||||
const offset = calls.length;
|
||||
await api.uploadFile({ channelId: 'C12345678', file: image });
|
||||
|
||||
assert.equal(calls.length, offset + 3);
|
||||
assert.equal(
|
||||
calls[offset].options.body,
|
||||
`filename=${image.fileName}&length=${image.bytes.length}`,
|
||||
);
|
||||
assert.equal(calls[offset + 1].options.headers['content-type'], image.mediaType);
|
||||
assert.deepEqual(JSON.parse(calls[offset + 2].options.body).files, [{
|
||||
id: `FIMAGE${offset + 1}`,
|
||||
title: image.fileName,
|
||||
}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Slack file preparation maps missing scope and pre-delivery failures without claiming uncertainty', async () => {
|
||||
const missingScopeApi = new SlackApi({
|
||||
botToken: BOT_TOKEN,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
import {
|
||||
TELEGRAM_ACCESS_MODES,
|
||||
TelegramConfigStore,
|
||||
|
|
@ -19,6 +24,7 @@ import {
|
|||
} from '../../../src/channels/telegram/telegram-api.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import {
|
||||
TelegramBotClient,
|
||||
TelegramRuntime,
|
||||
TELEGRAM_COMMAND_MENU,
|
||||
normalizeTelegramUpdate,
|
||||
|
|
@ -87,6 +93,42 @@ async function bounded(promise, message, timeoutMs = 1_000) {
|
|||
}
|
||||
}
|
||||
|
||||
async function committedTelegramArtifact(t, {
|
||||
suffix,
|
||||
fileName,
|
||||
content,
|
||||
}) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-telegram-artifact-${suffix}-`));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const sessionId = `session-telegram-artifact-${suffix}`;
|
||||
let nextId = 0;
|
||||
const registry = new OutboundArtifactRegistry({
|
||||
uuid: () => `${suffix}-${++nextId}`,
|
||||
});
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: sessionId, cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const execution = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: `call-${suffix}`,
|
||||
rootCallId: `call-${suffix}`,
|
||||
token: Symbol(`call-${suffix}`),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, execution);
|
||||
tool.onResult(execution, { isError: false });
|
||||
return registry.take(sessionId, 1)[0];
|
||||
}
|
||||
|
||||
test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => {
|
||||
assert.equal(validTelegramToken(TOKEN), true);
|
||||
assert.equal(validTelegramToken('short'), false);
|
||||
|
|
@ -242,6 +284,99 @@ test('Telegram API uploads a result file as a native document in the same topic
|
|||
assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result');
|
||||
});
|
||||
|
||||
test('Telegram API uploads a result image as a native photo in the same topic and reply chain', async () => {
|
||||
let request;
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url, options };
|
||||
return jsonResponse({ ok: true, result: { message_id: 902 } });
|
||||
},
|
||||
});
|
||||
const result = await api.sendPhoto({
|
||||
chatId: -100123,
|
||||
replyToMessageId: 44,
|
||||
messageThreadId: 55,
|
||||
file: {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('telegram-image'),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.message_id, 902);
|
||||
assert.match(request.url.pathname, /sendPhoto$/);
|
||||
assert.equal(request.options.method, 'POST');
|
||||
assert.equal(request.options.headers, undefined);
|
||||
assert.ok(request.options.body instanceof FormData);
|
||||
assert.equal(request.options.body.get('chat_id'), '-100123');
|
||||
assert.equal(request.options.body.get('message_thread_id'), '55');
|
||||
assert.deepEqual(JSON.parse(request.options.body.get('reply_parameters')), {
|
||||
message_id: 44,
|
||||
allow_sending_without_reply: true,
|
||||
});
|
||||
const photo = request.options.body.get('photo');
|
||||
assert.equal(photo.name, 'result.png');
|
||||
assert.equal(photo.type, 'image/png');
|
||||
assert.equal(Buffer.from(await photo.arrayBuffer()).toString(), 'telegram-image');
|
||||
});
|
||||
|
||||
test('Telegram bot client routes images through sendPhoto with the existing reply context', async () => {
|
||||
let request;
|
||||
const controller = new AbortController();
|
||||
const client = new TelegramBotClient({
|
||||
api: {
|
||||
sendPhoto: async (value) => {
|
||||
request = value;
|
||||
return { message_id: 903 };
|
||||
},
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const file = {
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('telegram-image'),
|
||||
};
|
||||
|
||||
assert.deepEqual(await client.sendImage({
|
||||
chatId: -100456,
|
||||
replyToMessageId: 66,
|
||||
messageThreadId: 77,
|
||||
}, file), { message_id: 903 });
|
||||
assert.equal(request.chatId, -100456);
|
||||
assert.equal(request.file, file);
|
||||
assert.equal(request.replyToMessageId, 66);
|
||||
assert.equal(request.messageThreadId, 77);
|
||||
assert.equal(request.signal, controller.signal);
|
||||
});
|
||||
|
||||
test('Telegram photo delivery reuses stable artifact error mapping', async () => {
|
||||
const rejectedApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({
|
||||
ok: false,
|
||||
error_code: 400,
|
||||
description: 'Bad Request: unsupported photo',
|
||||
}, 400),
|
||||
});
|
||||
await assert.rejects(() => rejectedApi.sendPhoto({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.webp', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === 400
|
||||
&& error.status === 400);
|
||||
|
||||
const uncertainApi = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => new Response('not-json', { status: 200 }),
|
||||
});
|
||||
await assert.rejects(() => uncertainApi.sendPhoto({
|
||||
chatId: 123,
|
||||
file: { fileName: 'result.png', bytes: Buffer.from('result') },
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Telegram document errors retain provider details and use stable artifact reasons', async () => {
|
||||
const cases = [{
|
||||
body: { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
|
||||
|
|
@ -958,6 +1093,86 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
|
|||
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
|
||||
});
|
||||
|
||||
test('Telegram bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
|
||||
const scenarios = [{
|
||||
name: 'native image',
|
||||
suffix: 'native-image',
|
||||
fileName: 'result.png',
|
||||
content: Buffer.from([1, 2, 3]),
|
||||
expectedCalls: ['image:result.png:image/png'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'ordinary file',
|
||||
suffix: 'ordinary-file',
|
||||
fileName: 'result.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file:result.txt:text/plain'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'definitive image rejection',
|
||||
suffix: 'image-fallback',
|
||||
fileName: 'result.webp',
|
||||
content: Buffer.from([4, 5, 6]),
|
||||
imageErrorCode: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'uncertain image result',
|
||||
suffix: 'image-uncertain',
|
||||
fileName: 'result.gif',
|
||||
content: Buffer.from([7, 8, 9]),
|
||||
imageErrorCode: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image:result.gif:image/gif'],
|
||||
expectedOutcome: 'unknown',
|
||||
}];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const artifact = await committedTelegramArtifact(t, scenario);
|
||||
const calls = [];
|
||||
const bot = {
|
||||
sendText: async () => ({ message_id: `text-${scenario.suffix}` }),
|
||||
sendImage: async (_target, file) => {
|
||||
calls.push(`image:${file.fileName}:${file.mediaType}`);
|
||||
if (scenario.imageErrorCode) {
|
||||
const error = new Error(scenario.imageErrorCode);
|
||||
error.code = scenario.imageErrorCode;
|
||||
throw error;
|
||||
}
|
||||
return { message_id: `image-${scenario.suffix}` };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
calls.push(`file:${file.fileName}:${file.mediaType}`);
|
||||
return { message_id: `file-${scenario.suffix}` };
|
||||
},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({
|
||||
bot,
|
||||
state: memoryState(),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
createSession: async () => `session-${scenario.suffix}`,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: `telegram-artifact-${scenario.suffix}`,
|
||||
senderId: '42',
|
||||
kind: 'direct',
|
||||
conversationId: `telegram-artifact-${scenario.suffix}`,
|
||||
content: '生成结果',
|
||||
addressed: true,
|
||||
replyTarget: { chatId: 88, replyToMessageId: 7, messageThreadId: 9 },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
|
||||
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
sendWecomImage,
|
||||
WecomHarnessBridge,
|
||||
wecomInboundMessage,
|
||||
} from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
|
|
@ -21,6 +22,190 @@ const PNG_1X1 = Buffer.from(
|
|||
'base64',
|
||||
);
|
||||
|
||||
test('Enterprise WeChat native image adapter uploads and sends an image media message', async () => {
|
||||
const calls = [];
|
||||
const result = { body: { msgid: 'wecom-image-1', errcode: 0 } };
|
||||
const file = {
|
||||
artifactId: 'image-artifact',
|
||||
deliveryKey: 'image-delivery',
|
||||
fileName: 'diagram.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: PNG_1X1,
|
||||
};
|
||||
const returned = await sendWecomImage({
|
||||
uploadMedia: async (bytes, options) => {
|
||||
calls.push({ operation: 'upload', bytes: Buffer.from(bytes), options });
|
||||
return { media_id: 'image-media-1' };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
calls.push({ operation: 'send', chatId, type, mediaId });
|
||||
return result;
|
||||
},
|
||||
}, 'chat-1', file);
|
||||
|
||||
assert.equal(returned, result);
|
||||
assert.deepEqual(calls, [{
|
||||
operation: 'upload',
|
||||
bytes: PNG_1X1,
|
||||
options: { type: 'image', filename: 'diagram.png' },
|
||||
}, {
|
||||
operation: 'send',
|
||||
chatId: 'chat-1',
|
||||
type: 'image',
|
||||
mediaId: 'image-media-1',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat native image adapter preserves definite upload errors', async () => {
|
||||
let sends = 0;
|
||||
await assert.rejects(sendWecomImage({
|
||||
uploadMedia: async () => {
|
||||
const error = new Error('forbidden');
|
||||
error.httpStatus = 403;
|
||||
throw error;
|
||||
},
|
||||
sendMediaMessage: async () => { sends += 1; },
|
||||
}, 'chat-1', {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_1X1,
|
||||
}), (error) => error.code === 'artifact-permission-required');
|
||||
assert.equal(sends, 0);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat native image adapter marks an unacknowledged message as uncertain', async () => {
|
||||
await assert.rejects(sendWecomImage({
|
||||
uploadMedia: async () => ({ media_id: 'image-media-uncertain' }),
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
}, 'chat-1', {
|
||||
fileName: 'diagram.png',
|
||||
bytes: PNG_1X1,
|
||||
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat sends an image artifact as a native image message', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'native.png', PNG_1X1, 'native-image');
|
||||
const uploads = [];
|
||||
const messages = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async (_source, _streamId, _content, finish) => (
|
||||
finish ? { body: { msgid: 'wecom-text' } } : undefined
|
||||
),
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async (bytes, options) => {
|
||||
uploads.push({ bytes: Buffer.from(bytes), options });
|
||||
return { media_id: 'native-image-media' };
|
||||
},
|
||||
sendMediaMessage: async (chatId, type, mediaId) => {
|
||||
messages.push({ chatId, type, mediaId });
|
||||
return { body: { msgid: 'wecom-image', errcode: 0 } };
|
||||
},
|
||||
},
|
||||
generateStreamId: () => 'native-image-stream',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-native-image' }));
|
||||
|
||||
assert.deepEqual(uploads, [{
|
||||
bytes: PNG_1X1,
|
||||
options: { type: 'image', filename: 'native.png' },
|
||||
}]);
|
||||
assert.deepEqual(messages, [{
|
||||
chatId: 'member-1',
|
||||
type: 'image',
|
||||
mediaId: 'native-image-media',
|
||||
}]);
|
||||
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
assert.equal(receipt.providerMessageIds.includes('wecom-image'), true);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat falls back to a file after a definite native-image rejection', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'fallback.png', PNG_1X1, 'image-fallback');
|
||||
const uploads = [];
|
||||
const messages = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async () => {},
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.type);
|
||||
if (options.type === 'image') {
|
||||
const error = new Error('image format rejected');
|
||||
error.providerCode = 40014;
|
||||
throw error;
|
||||
}
|
||||
return { media_id: 'fallback-file-media' };
|
||||
},
|
||||
sendMediaMessage: async (_chatId, type, mediaId) => {
|
||||
messages.push({ type, mediaId });
|
||||
return { body: { msgid: 'wecom-file', errcode: 0 } };
|
||||
},
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-fallback' }));
|
||||
|
||||
assert.deepEqual(uploads, ['image', 'file']);
|
||||
assert.deepEqual(messages, [{ type: 'file', mediaId: 'fallback-file-media' }]);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'sent');
|
||||
});
|
||||
|
||||
test('Enterprise WeChat does not file-fallback after an uncertain native image send', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'uncertain.png', PNG_1X1, 'image-uncertain');
|
||||
const uploads = [];
|
||||
const notices = [];
|
||||
const bridge = new WecomHarnessBridge({
|
||||
client: {
|
||||
replyStream: async () => {},
|
||||
replyStreamNonBlocking: async () => {},
|
||||
sendMessage: async (_chatId, body) => {
|
||||
notices.push(body.markdown.content);
|
||||
return { body: { msgid: 'wecom-uncertain-notice' } };
|
||||
},
|
||||
uploadMedia: async (_bytes, options) => {
|
||||
uploads.push(options.type);
|
||||
return { media_id: 'uncertain-image-media' };
|
||||
},
|
||||
sendMediaMessage: async () => new Promise(() => {}),
|
||||
},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
state: state(),
|
||||
fileUploadTimeoutMs: 20,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-uncertain' }));
|
||||
|
||||
assert.deepEqual(uploads, ['image']);
|
||||
assert.match(notices[0], /发送结果未能确认.*不要立即重试/);
|
||||
assert.equal(receipt.artifacts[0].outcome, 'unknown');
|
||||
});
|
||||
|
||||
function frame(overrides = {}) {
|
||||
return {
|
||||
headers: { req_id: 'req-1' },
|
||||
|
|
|
|||
|
|
@ -281,6 +281,76 @@ test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file i
|
|||
}]);
|
||||
});
|
||||
|
||||
test('sendImage uses the encrypted CDN flow and sends a native image item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=image-one',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-image-ticket' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
const plaintext = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
const result = await api.sendImage({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-image',
|
||||
file: {
|
||||
artifactId: 'artifact-image',
|
||||
deliveryKey: 'session:turn:artifact-image',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: plaintext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
const ticket = JSON.parse(calls[0].init.body);
|
||||
assert.equal(ticket.media_type, 1);
|
||||
assert.equal(ticket.to_user_id, 'wx-user');
|
||||
assert.equal(ticket.rawsize, plaintext.length);
|
||||
assert.equal(ticket.filesize, 16);
|
||||
assert.equal(ticket.no_need_thumb, true);
|
||||
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
|
||||
assert.equal(
|
||||
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
|
||||
true,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(calls[2].init.body).msg;
|
||||
assert.equal(sent.context_token, 'message-context');
|
||||
assert.equal(sent.run_id, 'run-image');
|
||||
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
|
||||
assert.equal(result.messageId, sent.client_id);
|
||||
assert.deepEqual(sent.item_list, [{
|
||||
type: 2,
|
||||
image_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-image-ticket',
|
||||
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
mid_size: 16,
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
function weixinFileRequest(overrides = {}) {
|
||||
return {
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
|
|
@ -351,6 +421,26 @@ test('sendFile marks every ambiguous sendmessage result as uncertain', async (t)
|
|||
}
|
||||
});
|
||||
|
||||
test('sendImage preserves the uncertain final-delivery boundary', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => { throw new TypeError('private socket detail'); }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.sendImage(weixinFileRequest({
|
||||
file: {
|
||||
artifactId: 'artifact-image-error',
|
||||
deliveryKey: 'session:turn:artifact-image-error',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('image-error-case'),
|
||||
},
|
||||
})),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
|
|
|
|||
|
|
@ -311,6 +311,104 @@ test('Weixin returns a registered result file with native context after its exis
|
|||
});
|
||||
});
|
||||
|
||||
test('Weixin routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'native image',
|
||||
fileName: 'native.png',
|
||||
content: PNG_BYTES,
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'weixin-image',
|
||||
expectedProviderIds: ['weixin-native-image'],
|
||||
},
|
||||
{
|
||||
name: 'ordinary file',
|
||||
fileName: 'ordinary.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file'],
|
||||
expectedPresentation: 'weixin-file',
|
||||
expectedProviderIds: ['weixin-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'definite image rejection falls back',
|
||||
fileName: 'fallback.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image', 'file'],
|
||||
expectedPresentation: 'weixin-file',
|
||||
expectedProviderIds: ['weixin-native-file'],
|
||||
},
|
||||
{
|
||||
name: 'uncertain image never falls back',
|
||||
fileName: 'uncertain.png',
|
||||
content: PNG_BYTES,
|
||||
imageError: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image'],
|
||||
expectedPresentation: 'text-fallback',
|
||||
expectedProviderIds: [],
|
||||
expectedOutcome: 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', `session-image-route-${index}`);
|
||||
const calls = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async () => { throw new Error('text intentionally unavailable'); },
|
||||
sendImage: async ({ file }) => {
|
||||
calls.push('image');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
assert.equal(file.mediaType, 'image/png');
|
||||
if (scenario.imageError) {
|
||||
const error = new Error('private image result');
|
||||
error.code = scenario.imageError;
|
||||
throw error;
|
||||
}
|
||||
return { messageId: 'weixin-native-image' };
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
calls.push('file');
|
||||
assert.equal(file.fileName, scenario.fileName);
|
||||
return { messageId: 'weixin-native-file' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(`weixin-image-route-${index}`, '生成产物'));
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls);
|
||||
assert.equal(receipt.presentation, scenario.expectedPresentation);
|
||||
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: artifact.artifactId,
|
||||
outcome: scenario.expectedOutcome ?? 'sent',
|
||||
...(scenario.imageError === 'artifact-delivery-uncertain'
|
||||
? { reason: 'artifact-delivery-uncertain' }
|
||||
: {}),
|
||||
}]);
|
||||
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
|
||||
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ import {
|
|||
normalizeWhatsappAccessPolicy,
|
||||
} from '../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
import {
|
||||
WhatsappBotClient,
|
||||
WhatsappRuntime,
|
||||
createWhatsappMediaDownloader,
|
||||
normalizeWhatsappMessage,
|
||||
|
|
@ -687,6 +689,192 @@ test('WhatsApp runtime sends result files with native metadata, quote, stable id
|
|||
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
|
||||
});
|
||||
|
||||
test('WhatsApp bot client sends native images with stable id and early echo suppression', async () => {
|
||||
const remembered = [];
|
||||
const calls = [];
|
||||
const outboundIds = {
|
||||
remember: (id) => remembered.push(id),
|
||||
};
|
||||
const quoted = { key: { id: 'quoted-image-message' } };
|
||||
const deliveryKey = 'whatsapp-native-image-delivery';
|
||||
const expectedMessageId = createHash('sha256')
|
||||
.update(`${deliveryKey}:image`)
|
||||
.digest('hex')
|
||||
.slice(0, 20)
|
||||
.toUpperCase();
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async (jid, content, options) => {
|
||||
assert.equal(remembered.includes(options.messageId), true);
|
||||
calls.push({ jid, content, options });
|
||||
return { key: { id: 'provider-image-message' } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, outboundIds);
|
||||
const bytes = Buffer.from('whatsapp-image');
|
||||
|
||||
assert.deepEqual(await client.sendImage({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
quoted,
|
||||
}, {
|
||||
deliveryKey,
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes,
|
||||
}), { key: { id: 'provider-image-message' } });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].jid, '16505550999@s.whatsapp.net');
|
||||
assert.equal(calls[0].content.image, bytes);
|
||||
assert.equal(calls[0].content.mimetype, 'image/png');
|
||||
assert.equal(calls[0].content.document, undefined);
|
||||
assert.equal(calls[0].options.quoted, quoted);
|
||||
assert.equal(calls[0].options.messageId, expectedMessageId);
|
||||
assert.equal(calls[0].options.mediaUploadTimeoutMs, 120_000);
|
||||
assert.deepEqual(remembered, [expectedMessageId, 'provider-image-message']);
|
||||
});
|
||||
|
||||
test('WhatsApp classifies a definite image rejection and uses a distinct fallback file id', async () => {
|
||||
const calls = [];
|
||||
const deliveryKey = 'whatsapp-image-fallback-delivery';
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async (_jid, content, options) => {
|
||||
calls.push({ content, options });
|
||||
if (content.image) {
|
||||
const error = new Error('unsupported image');
|
||||
error.output = { statusCode: 415 };
|
||||
throw error;
|
||||
}
|
||||
return { key: { id: 'provider-file-fallback' } };
|
||||
},
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, { remember() {} });
|
||||
const file = {
|
||||
artifactId: 'whatsapp-image-fallback',
|
||||
deliveryKey,
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('whatsapp-image'),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => client.sendImage({ jid: '16505550999@s.whatsapp.net' }, file),
|
||||
(error) => error.code === 'artifact-provider-rejected',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await client.sendFile({ jid: '16505550999@s.whatsapp.net' }, file),
|
||||
{ key: { id: 'provider-file-fallback' } },
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].options.messageId, createHash('sha256')
|
||||
.update(`${deliveryKey}:image`).digest('hex').slice(0, 20).toUpperCase());
|
||||
assert.equal(calls[1].options.messageId, createHash('sha256')
|
||||
.update(deliveryKey).digest('hex').slice(0, 20).toUpperCase());
|
||||
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
|
||||
});
|
||||
|
||||
test('WhatsApp native image timeout remains an uncertain artifact delivery', async () => {
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
sendMessage: async () => new Promise(() => {}),
|
||||
};
|
||||
const client = new WhatsappBotClient(socket, { remember() {} }, {
|
||||
mediaUploadTimeoutMs: 10,
|
||||
});
|
||||
|
||||
await assert.rejects(() => client.sendImage({
|
||||
jid: '16505550999@s.whatsapp.net',
|
||||
}, {
|
||||
artifactId: 'whatsapp-image-timeout',
|
||||
fileName: 'result.png',
|
||||
mediaType: 'image/png',
|
||||
bytes: Buffer.from('whatsapp-image'),
|
||||
}), (error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& error.cause?.name === 'TimeoutError');
|
||||
});
|
||||
|
||||
test('WhatsApp bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
|
||||
const scenarios = [{
|
||||
name: 'native image',
|
||||
suffix: 'native-image-route',
|
||||
fileName: 'result.png',
|
||||
content: Buffer.from([1, 2, 3]),
|
||||
expectedCalls: ['image:result.png:image/png'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'ordinary file',
|
||||
suffix: 'ordinary-file-route',
|
||||
fileName: 'result.txt',
|
||||
content: 'ordinary file',
|
||||
expectedCalls: ['file:result.txt:text/plain'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'definitive image rejection',
|
||||
suffix: 'image-fallback-route',
|
||||
fileName: 'result.webp',
|
||||
content: Buffer.from([4, 5, 6]),
|
||||
imageErrorCode: 'artifact-provider-rejected',
|
||||
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
|
||||
expectedOutcome: 'sent',
|
||||
}, {
|
||||
name: 'uncertain image result',
|
||||
suffix: 'image-uncertain-route',
|
||||
fileName: 'result.gif',
|
||||
content: Buffer.from([7, 8, 9]),
|
||||
imageErrorCode: 'artifact-delivery-uncertain',
|
||||
expectedCalls: ['image:result.gif:image/gif'],
|
||||
expectedOutcome: 'unknown',
|
||||
}];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { artifact } = await committedArtifact(t, scenario);
|
||||
const calls = [];
|
||||
const bot = {
|
||||
sendText: async () => ({ key: { id: `text-${scenario.suffix}` } }),
|
||||
sendImage: async (_target, file) => {
|
||||
calls.push(`image:${file.fileName}:${file.mediaType}`);
|
||||
if (scenario.imageErrorCode) {
|
||||
const error = new Error(scenario.imageErrorCode);
|
||||
error.code = scenario.imageErrorCode;
|
||||
throw error;
|
||||
}
|
||||
return { key: { id: `image-${scenario.suffix}` } };
|
||||
},
|
||||
sendFile: async (_target, file) => {
|
||||
calls.push(`file:${file.fileName}:${file.mediaType}`);
|
||||
return { key: { id: `file-${scenario.suffix}` } };
|
||||
},
|
||||
};
|
||||
const bridge = new WhatsappHarnessBridge({
|
||||
bot,
|
||||
state: artifactState(`session-${scenario.suffix}`),
|
||||
logger: { warn() {}, error() {} },
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '图片已生成。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept({
|
||||
messageId: `whatsapp:artifact-${scenario.suffix}`,
|
||||
senderId: '16505550999@s.whatsapp.net',
|
||||
kind: 'direct',
|
||||
conversationId: `whatsapp-artifact-${scenario.suffix}`,
|
||||
content: '生成结果',
|
||||
addressed: true,
|
||||
replyTarget: { jid: '16505550999@s.whatsapp.net' },
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
|
||||
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('WhatsApp suppresses a self-chat file echo that arrives before the provider ACK', async (t) => {
|
||||
const { artifact } = await committedArtifact(t, {
|
||||
suffix: 'early-self-file-echo',
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ test('provider message ids are collected only from explicit message-id fields',
|
|||
assert.deepEqual(providerMessageIdsFor({ message_id: 42 }), ['42']);
|
||||
assert.deepEqual(providerMessageIdsFor({ key: { id: 'wamid-one' } }), ['wamid-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({ ts: '123.456' }), ['123.456']);
|
||||
assert.deepEqual(providerMessageIdsFor({ body: { msgid: 'wecom-one' } }), ['wecom-one']);
|
||||
assert.deepEqual(providerMessageIdsFor({
|
||||
providerMessageIds: ['first', 'second', 'first', '', null],
|
||||
}), ['first', 'second']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue