feat: add native image delivery across channels

This commit is contained in:
xmanrui 2026-08-24 03:25:38 +08:00
parent dbc0a4b730
commit 3ec5f1eca7
31 changed files with 2754 additions and 846 deletions

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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