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

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