mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue