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

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