mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 16:40:45 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
|
|
@ -1,4 +1,7 @@
|
|||
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 {
|
||||
|
|
@ -6,6 +9,12 @@ import {
|
|||
DingtalkHarnessBridge,
|
||||
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -82,6 +91,37 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-dingtalk-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'dingtalk-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const fileContent = fileName.endsWith('.pdf') ? `%PDF-1.7\n${content}` : content;
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), fileContent);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'dingtalk-artifact-call',
|
||||
rootCallId: 'dingtalk-artifact-call',
|
||||
token: Symbol('dingtalk-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
|
||||
const privateFixture = stateFixture();
|
||||
const privateSent = [];
|
||||
|
|
@ -114,6 +154,161 @@ test('DingTalk remembers any private inbound session webhook for connection test
|
|||
assert.equal(connectionTestTarget(groupFixture.state), null);
|
||||
});
|
||||
|
||||
test('DingTalk returns a registered result file through the native robot conversation', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.pdf', 'dingtalk-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'dingtalk-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.deepEqual(request.target, {
|
||||
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
|
||||
});
|
||||
assert.equal(request.file.bytes.toString(), '%PDF-1.7\ndingtalk-result');
|
||||
return { processQueryKey: 'dingtalk-file-query-one' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'dingtalk-artifact',
|
||||
'生成文件',
|
||||
{ robotCode: 'robot-code' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.pdf']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'dingtalk-artifact',
|
||||
presentation: 'dingtalk-text-and-files',
|
||||
providerMessageIds: ['dingtalk-text-one', 'dingtalk-file-query-one'],
|
||||
artifacts: [{ artifactId: 'dingtalk-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'dingtalk-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
clientId: 'ding-client',
|
||||
clientSecret: 'host-secret',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('dingtalk-artifact-text-failed', '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.deepEqual(files, ['dingtalk-text-failed.pdf']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['dingtalk-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: 'sent',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('DingTalk gives safe, actionable file delivery failure guidance', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'uncertain delivery',
|
||||
code: 'artifact-delivery-uncertain',
|
||||
fileName: 'dingtalk-uncertain.pdf',
|
||||
expected: '结果文件「dingtalk-uncertain.pdf」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
},
|
||||
{
|
||||
name: 'missing permission',
|
||||
code: 'artifact-permission-required',
|
||||
fileName: 'dingtalk-permission.pdf',
|
||||
expected: '结果文件「dingtalk-permission.pdf」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。',
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async (subtest) => {
|
||||
const artifact = await committedArtifact(subtest, scenario.fileName, 'dingtalk-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', `session-${scenario.name}`);
|
||||
const sent = [];
|
||||
const bridge = new DingtalkHarnessBridge({
|
||||
api: {
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `dingtalk-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider rejection detail');
|
||||
error.code = scenario.code;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
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-artifact-${scenario.name}`, '生成文件', {
|
||||
robotCode: 'robot-code',
|
||||
}));
|
||||
|
||||
assert.equal(sent.at(-1), scenario.expected);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider rejection detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'dingtalk-artifact-one',
|
||||
outcome: scenario.code === 'artifact-delivery-uncertain' ? 'unknown' : 'rejected',
|
||||
reason: scenario.code,
|
||||
}]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:staff-approved', 'session-image');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue