feat: add native result-file delivery

This commit is contained in:
xmanrui 2026-08-23 20:02:06 +08:00
parent 2803bbcbab
commit d472dd5a75
65 changed files with 10205 additions and 255 deletions

View file

@ -5,6 +5,7 @@ import {
createDingtalkApi,
DINGTALK_AI_CARD_TEMPLATE_ID,
DINGTALK_API_BASE_URL,
DingtalkApiError,
normalizeDingtalkCardMarkdown,
normalizeDingtalkSessionWebhook,
splitDingtalkText,
@ -113,6 +114,231 @@ test('session replies use the fixed token endpoint, reject redirects, and cache
});
});
test('DingTalk uploads and sends a native file message to the exact robot conversation', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
}
if (url.pathname.endsWith('/media/upload')) {
return jsonResponse({ errcode: 0, media_id: '@media-one', type: 'file' });
}
return jsonResponse({ processQueryKey: 'query-one' });
};
const api = createDingtalkApi({ fetchImpl });
const response = await api.sendFile({
clientId: 'ding-client',
clientSecret: 'host-only-secret',
target: {
type: 'group',
robotCode: 'robot-code',
openConversationId: 'cid-one',
},
file: {
fileName: 'result.pdf',
mediaType: 'application/pdf',
bytes: Buffer.from('dingtalk-result'),
},
});
assert.equal(response.processQueryKey, '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'), 'file-access-token');
assert.equal(uploadUrl.searchParams.get('type'), 'file');
assert.ok(calls[1].options.body instanceof FormData);
const media = calls[1].options.body.get('media');
assert.equal(media.name, 'result.pdf');
assert.equal(media.type, 'application/pdf');
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-result');
assert.equal(calls[1].options.headers, undefined);
assert.equal(
calls[2].url,
`${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`,
);
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
const sent = JSON.parse(calls[2].options.body);
assert.deepEqual(sent, {
robotCode: 'robot-code',
msgKey: 'sampleFile',
msgParam: JSON.stringify({
mediaId: '@media-one',
fileName: 'result.pdf',
fileType: 'pdf',
}),
openConversationId: 'cid-one',
});
});
function dingtalkFileRequest(overrides = {}) {
return {
clientId: 'ding-client',
clientSecret: 'host-only-secret',
target: {
type: 'group',
robotCode: 'robot-code',
openConversationId: 'cid-error-case',
},
file: {
fileName: 'result.pdf',
mediaType: 'application/pdf',
bytes: Buffer.from('dingtalk-error-case'),
},
...overrides,
};
}
function dingtalkFileFetch(finalResponse) {
return async (url, options) => {
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
}
if (url.pathname.endsWith('/media/upload')) {
return jsonResponse({ errcode: 0, media_id: '@media-error-case', type: 'file' });
}
return finalResponse(url, options);
};
}
test('DingTalk marks every ambiguous robot file send result as uncertain', async (t) => {
const cases = [
{
name: 'network failure',
finalResponse: async () => { throw new TypeError('private socket detail'); },
},
{
name: 'timeout',
finalResponse: async () => {
throw new DingtalkApiError('timeout', 'private timeout detail');
},
},
{
name: 'invalid JSON',
finalResponse: async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('private invalid JSON detail'); },
}),
},
{
name: 'HTTP 5xx',
finalResponse: async () => jsonResponse({ code: 'InternalError' }, { status: 503 }),
},
];
for (const scenario of cases) {
await t.test(scenario.name, async () => {
const api = createDingtalkApi({
fetchImpl: dingtalkFileFetch(scenario.finalResponse),
});
await assert.rejects(
api.sendFile(dingtalkFileRequest()),
(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' },
{ name: 'too large', status: 413, code: 'artifact-too-large' },
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
];
for (const scenario of cases) {
await t.test(scenario.name, async () => {
const api = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => jsonResponse(
{ code: scenario.status },
{ status: scenario.status },
)),
});
await assert.rejects(
api.sendFile(dingtalkFileRequest()),
(error) => error.code === scenario.code,
);
});
}
const permissionApi = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => jsonResponse({
code: 'Forbidden.AccessDenied',
})),
});
await assert.rejects(
permissionApi.sendFile(dingtalkFileRequest()),
(error) => error.code === 'artifact-permission-required'
&& error.providerCode === 'Forbidden.AccessDenied',
);
const rejectedApi = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => jsonResponse({ code: 'InvalidParameter' })),
});
await assert.rejects(
rejectedApi.sendFile(dingtalkFileRequest()),
(error) => error.code === 'artifact-provider-rejected'
&& error.providerCode === 'InvalidParameter',
);
});
test('DingTalk preserves caller abort and never marks a pre-send upload failure uncertain', async () => {
let uploadCalls = 0;
const uploadApi = createDingtalkApi({
fetchImpl: async (url) => {
uploadCalls += 1;
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
}
throw new TypeError('private upload transport failure');
},
});
await assert.rejects(
uploadApi.sendFile(dingtalkFileRequest()),
(error) => error.code === 'artifact-provider-failed'
&& error.code !== 'artifact-delivery-uncertain',
);
assert.equal(uploadCalls, 2);
const controller = new AbortController();
const reason = new Error('caller stopped the turn');
const abortApi = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => {
controller.abort(reason);
throw new DOMException('Aborted', 'AbortError');
}),
});
await assert.rejects(
abortApi.sendFile(dingtalkFileRequest({ signal: controller.signal })),
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
);
});
test('DingTalk lets the provider decide whether a robot file type is supported', async () => {
let providerCalls = 0;
const api = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => {
providerCalls += 1;
return jsonResponse({ code: 400 }, { status: 400 });
}),
});
await assert.rejects(
api.sendFile({
clientId: 'ding-client',
clientSecret: 'host-only-secret',
target: { type: 'user', robotCode: 'robot-code', userId: 'user-one' },
file: { fileName: 'result.html', bytes: Buffer.from('<h1>result</h1>') },
}),
(error) => error.code === 'artifact-provider-rejected',
);
assert.equal(providerCalls, 1);
});
test('DingTalk image downloads exchange downloadCode with the callback robotCode and do not forward auth', async () => {
const imageBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,

View file

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

View file

@ -66,7 +66,14 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
});
assert.ok(runtime instanceof Runtime);
assert.equal(seen.runtimeOptions.clientSecret, 'host-only-secret');
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
assert.match(seen.statePath, /dt_abc\/state\.json$/);
await seen.controllerOptions.createRuntime({
botId: 'dt_disabled',
config: { botId: 'dt_disabled', clientId: 'dingdisabled' },
clientSecret: 'host-only-secret',
});
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
await production.close();
assert.equal(seen.supervisorClosed, true);
@ -93,6 +100,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
config: { botId: 'dt_new', clientId: 'dingnew' },
clientSecret: 'host-only-secret',
});
assert.equal(Object.hasOwn(seen.runtimeOptions, 'outboundArtifactsEnabled'), false);
const stored = JSON.parse(await readFile(join(directory, 'workspaces.json'), 'utf8'));
assert.equal(stored.agentPresets.dt_new, 'router-standard');
assert.equal(Object.hasOwn(stored.agentPresets, 'dt_abc'), false);

View file

@ -116,6 +116,162 @@ test('Discord API retries one rate-limited message request', async () => {
assert.equal(attempts, 2);
});
test('Discord API uploads a result file as a native attachment and preserves the reply', async () => {
let request;
const api = new DiscordApi({
token: TOKEN,
fetchImpl: async (url, options) => {
request = { url, options };
return jsonResponse({ id: '987654321012345679', attachments: [{ id: '1' }] });
},
});
const result = await api.createFileMessage({
channelId: '123456789012345678',
replyToMessageId: '123456789012345679',
file: {
artifactId: 'artifact-discord-one',
deliveryKey: 'session:turn:artifact-discord-one',
fileName: 'result.html',
mediaType: 'text/html',
bytes: Buffer.from('<p>discord-result</p>'),
},
});
assert.equal(result.id, '987654321012345679');
assert.match(request.url.pathname, /channels\/123456789012345678\/messages$/);
assert.equal(request.options.headers['content-type'], undefined);
assert.ok(request.options.body instanceof FormData);
const payload = JSON.parse(request.options.body.get('payload_json'));
assert.deepEqual(payload.attachments, [{ id: 0, filename: 'result.html' }]);
assert.match(payload.nonce, /^[0-9a-f]{25}$/);
assert.equal(payload.enforce_nonce, true);
assert.equal(payload.message_reference.message_id, '123456789012345679');
assert.deepEqual(payload.allowed_mentions, { parse: [], replied_user: false });
const attachment = request.options.body.get('files[0]');
assert.equal(attachment.name, 'result.html');
assert.equal(attachment.type, 'text/html');
assert.equal(Buffer.from(await attachment.arrayBuffer()).toString(), '<p>discord-result</p>');
});
test('Discord attachment retry reuses one FormData body and one stable nonce', async () => {
const bodies = [];
const nonces = [];
let attempts = 0;
const api = new DiscordApi({
token: TOKEN,
fetchImpl: async (_url, options) => {
attempts += 1;
bodies.push(options.body);
nonces.push(JSON.parse(options.body.get('payload_json')).nonce);
return attempts === 1
? jsonResponse({ code: 20028, message: 'rate limited', retry_after: 0.001 }, 429)
: jsonResponse({ id: '987654321012345680', attachments: [{ id: '1' }] });
},
});
const result = await api.createFileMessage({
channelId: '123456789012345678',
file: {
deliveryKey: 'session:turn:artifact-retry',
fileName: 'result.txt',
bytes: Buffer.from('retry-safe'),
},
});
assert.equal(result.id, '987654321012345680');
assert.equal(attempts, 2);
assert.equal(bodies[0], bodies[1]);
assert.equal(nonces[0], nonces[1]);
assert.match(nonces[0], /^[0-9a-f]{25}$/);
});
test('Discord attachment errors retain provider details and use stable artifact reasons', async () => {
const cases = [{
body: { code: 50013, message: 'Missing Permissions' },
status: 403,
code: 'artifact-permission-required',
}, {
body: { code: 40005, message: 'Request entity too large' },
status: 413,
code: 'artifact-too-large',
}, {
body: { code: 20028, message: 'rate limited', retry_after: 0.001 },
status: 429,
code: 'artifact-rate-limited',
retryAfter: 0.001,
}, {
body: { code: 50035, message: 'Invalid Form Body' },
status: 400,
code: 'artifact-provider-rejected',
}, {
body: { code: 0, message: 'Internal Server Error' },
status: 500,
code: 'artifact-delivery-uncertain',
}];
for (const entry of cases) {
const api = new DiscordApi({
token: TOKEN,
fetchImpl: async () => jsonResponse(entry.body, entry.status),
});
await assert.rejects(() => api.createFileMessage({
channelId: '123456789012345678',
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => {
assert.equal(error.code, entry.code);
assert.equal(error.providerCode, entry.body.code);
assert.equal(error.status, entry.status);
assert.equal(error.retry_after, entry.retryAfter);
assert.equal(error.retryAfter, entry.retryAfter);
return true;
});
}
});
test('Discord attachment delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
for (const fetchImpl of [
async () => { throw new TypeError('socket reset'); },
async () => new Response('not-json', { status: 200 }),
]) {
const api = new DiscordApi({ token: TOKEN, fetchImpl });
await assert.rejects(() => api.createFileMessage({
channelId: '123456789012345678',
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain');
}
const timeoutApi = new DiscordApi({
token: TOKEN,
fileUploadTimeoutMs: 10,
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
if (signal.aborted) reject(signal.reason);
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
});
await assert.rejects(() => timeoutApi.createFileMessage({
channelId: '123456789012345678',
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.name === 'TimeoutError');
const caller = new AbortController();
const reason = new DOMException('caller stopped', 'AbortError');
caller.abort(reason);
let calls = 0;
const cancelledApi = new DiscordApi({
token: TOKEN,
fetchImpl: async () => {
calls += 1;
return jsonResponse({ id: '987654321012345680' });
},
});
await assert.rejects(() => cancelledApi.createFileMessage({
channelId: '123456789012345678',
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
signal: caller.signal,
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
assert.equal(calls, 0);
});
test('Discord controller persists a credential reference and exposes only masked identity', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-'));
t.after(() => rm(directory, { recursive: true, force: true }));

View file

@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/discord/production.mjs';
test('Discord production has no per-bot result-file Gate', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-discord-production-artifacts-'));
t.after(() => rm(root, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const supervisor = {
ready: Promise.resolve(),
start() { return this; },
async close() {},
};
const internals = {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor: () => supervisor,
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};
const createRuntime = (botId) => controllerOptions.createRuntime({
botId,
config: { botId },
token: 'host-only',
});
const production = await createProductionController(ctx, {
dataDir: join(root, 'default'),
}, internals);
await createRuntime('discord_enabled');
await createRuntime('discord_not_listed');
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
});

View file

@ -2,11 +2,17 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { mkdirSync, realpathSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -90,6 +96,41 @@ function textClient(sendText) {
};
}
async function committedArtifact(t, fileName, content, suffix = '') {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-feishu-artifact-${suffix}`));
t.after(() => rm(workspace, { recursive: true, force: true }));
let nextId = 0;
const registry = new OutboundArtifactRegistry({
uuid: () => `${suffix || 'file'}-${++nextId}`,
});
t.after(() => registry.clear());
const agent = {
session: {
header: { id: `session-${suffix || 'file'}`, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{
type: 'user/message',
data: { turn: 1, source: { rpcId: `rpc-${suffix || 'file'}` } },
},
],
},
};
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix || 'file'}`,
rootCallId: `call-${suffix || 'file'}`,
token: Symbol(`call-${suffix || 'file'}`),
agent,
};
await writeFile(join(workspace, fileName), content);
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
const [artifact] = registry.take(agent.session.header.id, 1);
return artifact;
}
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
const groupFixture = stateFixture();
const groupBridge = new FeishuHarnessBridge({
@ -1879,6 +1920,380 @@ test('reaction failures do not block streaming replies', async () => {
assert.equal(status.streamResponses, 1);
});
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');
const fixture = stateFixture([['p2p:ou_user', 'session-artifacts']]);
const order = [];
const delivered = [];
const notices = [];
const status = bridgeStatus();
const abort = new AbortController();
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => notices.push(text)),
channel: {
addReaction: async () => 'reaction',
removeReaction: async () => undefined,
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
order.push('card-finalized');
return { messageId: 'om-card' };
},
sendFile: async (chatId, file, options) => {
order.push(`file:${file.fileName}`);
delivered.push({ chatId, file, options });
if (file.fileName === 'notes.txt') {
const error = new Error('provider detail must stay private');
error.code = 'artifact-rate-limited';
throw error;
}
return {
schemaVersion: 1,
deliveryId: file.deliveryKey,
presentation: 'feishu-file',
providerMessageIds: ['om-file'],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
};
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(html);
await options.onArtifact(generic);
return '两个结果文件已经生成。';
},
},
state: fixture.state,
status,
allowedSenderOpenIds: new Set(['ou_user']),
signal: abort.signal,
});
bridge.accept(event('om_artifacts', '生成 HTML 和说明文件并发给我'));
await bridge.waitForIdle();
assert.deepEqual(order, ['card-finalized', 'file:result.html', 'file:notes.txt']);
assert.equal(delivered[0].chatId, 'oc_chat');
assert.deepEqual(delivered[0].options, {
replyTo: 'om_artifacts',
signal: abort.signal,
});
assert.equal(delivered[0].file.bytes.toString(), '<h1>result</h1>');
assert.equal(delivered[1].file.bytes.toString(), 'notes');
assert.equal(status.artifactsSent, 1);
assert.equal(status.artifactSendErrors, 1);
assert.equal(notices.length, 1);
assert.match(notices[0], /notes\.txt.*限流/);
assert.doesNotMatch(notices[0], /provider detail/);
});
test('Feishu tells users to check the chat before retrying an uncertain file delivery', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
const notices = [];
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => notices.push(text)),
channel: {
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
return { messageId: 'om-uncertain-card' };
},
sendFile: async () => {
const error = new Error('private transport detail');
error.code = 'artifact-delivery-uncertain';
throw error;
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '结果已生成';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
await bridge.waitForIdle();
assert.deepEqual(notices, [
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
]);
});
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
const artifact = await committedArtifact(t, 'file-only.txt', 'file only', 'file-only');
const fixture = stateFixture([['p2p:ou_user', 'session-file-only']]);
const cardContents = [];
const files = [];
const bridge = new FeishuHarnessBridge({
client: textClient(async () => undefined),
channel: {
stream: async (_chatId, input) => {
await input.markdown({ setContent: async (content) => cardContents.push(content) });
return { messageId: 'om-file-only-card' };
},
sendFile: async (_chatId, file) => {
files.push(file.fileName);
return {
schemaVersion: 1,
deliveryId: file.deliveryKey,
presentation: 'feishu-file',
providerMessageIds: ['om-file-only'],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
};
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_file_only', '只发送结果文件'));
await bridge.waitForIdle();
assert.deepEqual(cardContents, ['结果文件已生成。']);
assert.deepEqual(files, ['file-only.txt']);
});
test('a CardKit finalization failure falls back to text and delivers each artifact once without a second prompt', async (t) => {
const artifact = await committedArtifact(t, 'fallback.txt', 'fallback result', 'fallback');
const fixture = stateFixture([['p2p:ou_user', 'session-fallback-artifact']]);
const sent = [];
const files = [];
let asks = 0;
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => sent.push(text)),
channel: {
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
throw new Error('card finalization failed');
},
sendFile: async (_chatId, file) => {
files.push(file.fileName);
return {
schemaVersion: 1,
deliveryId: file.deliveryKey,
presentation: 'feishu-file',
providerMessageIds: ['om-fallback-file'],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
};
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
asks += 1;
await options.onArtifact(artifact);
return '回答已生成';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_artifact_card_fallback', '生成并发送文件'));
await bridge.waitForIdle();
assert.equal(asks, 1);
assert.deepEqual(sent, ['回答已生成']);
assert.deepEqual(files, ['fallback.txt']);
});
test('Feishu still delivers a file-only result when CardKit and fallback text both fail', async (t) => {
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
const files = [];
let fallbackTextAttempts = 0;
const bridge = new FeishuHarnessBridge({
client: textClient(async () => {
fallbackTextAttempts += 1;
throw new Error('text transport unavailable');
}),
channel: {
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
throw new Error('card finalization unavailable');
},
sendFile: async (_chatId, file) => {
files.push(file.fileName);
return {
schemaVersion: 1,
deliveryId: file.deliveryKey,
presentation: 'feishu-file',
providerMessageIds: ['om-file-after-text-failure'],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
};
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
bridge.accept(event('om_file_after_text_failure', '只生成文件'));
await bridge.waitForIdle();
assert.deepEqual(files, ['survives-text-failure.txt']);
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
});
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
for (const errorCode of ['artifact-invalid', 'artifact-unavailable']) {
await t.test(errorCode, async (subtest) => {
const artifact = await committedArtifact(subtest, `${errorCode}.txt`, 'file bytes', errorCode);
const attemptedTexts = [];
const visibleTexts = [];
const reactions = [];
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: { create: async (request) => {
const text = JSON.parse(request.data.content).text;
attemptedTexts.push(text);
if (text === '文字结果') throw new Error('text transport unavailable');
visibleTexts.push(text);
return { code: 0, data: {} };
} } } },
},
channel: {
addReaction: async (_messageId, emoji) => {
reactions.push(emoji);
return `reaction-${emoji}`;
},
removeReaction: async () => undefined,
sendFile: async () => {
const error = new Error('unsafe result file');
error.code = errorCode;
throw error;
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: stateFixture([['p2p:ou_user', `session-${errorCode}`]]).state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
const receipt = await bridge.accept(event(`om-${errorCode}`, '生成并发送文件'));
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
assert.equal(visibleTexts.length, 1);
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
assert.deepEqual(reactions, ['OnIt', 'DONE']);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: artifact.deliveryKey,
presentation: 'text-fallback',
providerMessageIds: [],
artifacts: [{
artifactId: artifact.artifactId,
outcome: 'rejected',
reason: errorCode,
}],
});
});
}
});
test('Feishu keeps the generic error when no answer or file failure notice is visible', async (t) => {
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
const attemptedTexts = [];
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => {
attemptedTexts.push(text);
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
}),
channel: {
sendFile: async () => {
const error = new Error('file transport unavailable');
error.code = 'artifact-provider-failed';
throw error;
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: stateFixture([['p2p:ou_user', 'session-no-visible-failure']]).state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
assert.equal(attemptedTexts.length, 3);
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
});
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
const artifact = await committedArtifact(t, 'cancel-after-card.txt', 'file bytes', 'cancel-after-card');
const fixture = stateFixture([['p2p:ou_user', 'session-cancel-after-card']]);
const controller = new AbortController();
const fallbackTexts = [];
let files = 0;
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => fallbackTexts.push(text)),
channel: {
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
controller.abort(new DOMException('runtime stopped', 'AbortError'));
return { messageId: 'om-final-card' };
},
sendFile: async () => {
files += 1;
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '卡片已经完成';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
signal: controller.signal,
logger: { info() {}, warn() {}, error() {} },
});
bridge.accept(event('om_cancel_after_card', '生成文件'));
await bridge.waitForIdle();
assert.equal(files, 0);
assert.deepEqual(fallbackTexts, []);
});
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
const seen = new Set();
const sent = [];

View file

@ -10,6 +10,7 @@ function fakeClient(overrides = {}) {
recalls: [],
reactionsAdded: [],
reactionsRemoved: [],
fileUploads: [],
};
const client = {
cardkit: { v1: {
@ -28,6 +29,12 @@ function fakeClient(overrides = {}) {
},
} },
im: { v1: {
file: {
create: async (request) => {
calls.fileUploads.push(request);
return { file_key: 'file-key-test' };
},
},
message: {
reply: async (request) => {
calls.replies.push(request);
@ -54,6 +61,9 @@ 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.replyMessage) client.im.v1.message.reply = overrides.replyMessage;
if (overrides.createMessage) client.im.v1.message.create = overrides.createMessage;
return { client, calls };
}
@ -129,3 +139,393 @@ test('VerifiedFeishuChannel checks reaction API results', async () => {
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
});
test('VerifiedFeishuChannel uploads a materialized result and replies with a native file message', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
const file = {
artifactId: 'artifact-html',
deliveryKey: 'delivery-html',
fileName: 'result.html',
mediaType: 'text/html',
size: 19,
bytes: Buffer.from('<h1>result</h1>'),
};
const receipt = await channel.sendFile('oc_chat', file, { replyTo: 'om_user' });
assert.equal(calls.fileUploads.length, 1);
assert.equal(calls.fileUploads[0].data.file_type, 'stream');
assert.equal(calls.fileUploads[0].data.file_name, 'result.html');
assert.equal(calls.fileUploads[0].data.file, 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, 'file');
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { file_key: 'file-key-test' });
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'delivery-html',
presentation: 'feishu-file',
providerMessageIds: ['om-stream'],
artifacts: [{ artifactId: 'artifact-html', outcome: 'sent' }],
});
});
test('VerifiedFeishuChannel sends to the current chat when no reply target exists', async () => {
const { client, calls } = fakeClient({
createMessage: async (request) => {
calls.replies.push(request);
return { code: 0, data: { message_id: 'om-created-file' } };
},
});
const channel = new VerifiedFeishuChannel({ client });
await channel.sendFile('oc_chat', {
artifactId: 'artifact-generic',
deliveryKey: 'delivery-generic',
fileName: 'result.bin',
bytes: Buffer.from('generic'),
});
assert.deepEqual(calls.replies[0].params, { receive_id_type: 'chat_id' });
assert.equal(calls.replies[0].data.receive_id, 'oc_chat');
assert.equal(calls.replies[0].data.msg_type, 'file');
});
test('VerifiedFeishuChannel retries uncertain message delivery without uploading twice', async () => {
const requests = [];
let attempts = 0;
const { client, calls } = fakeClient({
replyMessage: async (request) => {
requests.push(structuredClone(request));
attempts += 1;
return attempts === 1
? { code: 230049, msg: 'still sending' }
: { code: 0, data: { message_id: 'om-retried-file' } };
},
});
const channel = new VerifiedFeishuChannel({ client });
const receipt = await channel.sendFile('oc_chat', {
artifactId: 'artifact-retry',
deliveryKey: 'delivery-retry',
fileName: 'retry.txt',
bytes: Buffer.from('retry'),
}, { replyTo: 'om_user' });
assert.equal(calls.fileUploads.length, 1);
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
assert.deepEqual(receipt.providerMessageIds, ['om-retried-file']);
});
test('VerifiedFeishuChannel stops after one 230049 retry and never uploads the file twice', async () => {
const requests = [];
const { client, calls } = fakeClient({
replyMessage: async (request) => {
requests.push(structuredClone(request));
return { code: 230049, msg: 'still sending' };
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.sendFile('oc_chat', {
artifactId: 'artifact-still-uncertain',
deliveryKey: 'delivery-still-uncertain',
fileName: 'uncertain.txt',
bytes: Buffer.from('uncertain'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
&& error.providerCode === 230049);
assert.equal(calls.fileUploads.length, 1);
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
});
test('VerifiedFeishuChannel retries an SDK-thrown 230049 with the same file key and UUID', async () => {
const requests = [];
const { client, calls } = fakeClient({
replyMessage: async (request) => {
requests.push(structuredClone(request));
if (requests.length === 1) {
const error = new Error('still sending');
error.code = 230049;
throw error;
}
return { code: 0, data: { message_id: 'om-after-thrown-230049' } };
},
});
const channel = new VerifiedFeishuChannel({ client });
const receipt = await channel.sendFile('oc_chat', {
artifactId: 'artifact-thrown-retry',
deliveryKey: 'delivery-thrown-retry',
fileName: 'thrown-retry.txt',
bytes: Buffer.from('retry'),
}, { replyTo: 'om_user' });
assert.equal(calls.fileUploads.length, 1);
assert.equal(requests.length, 2);
assert.deepEqual(requests[1], requests[0]);
assert.deepEqual(receipt.providerMessageIds, ['om-after-thrown-230049']);
});
test('VerifiedFeishuChannel bounds upload and message waits independently', async () => {
const uploadFixture = fakeClient({ uploadFile: async () => new Promise(() => {}) });
const uploadChannel = new VerifiedFeishuChannel({
client: uploadFixture.client,
fileUploadTimeoutMs: 10,
fileMessageTimeoutMs: 100,
});
await assert.rejects(uploadChannel.sendFile('oc_chat', {
artifactId: 'artifact-upload-timeout',
deliveryKey: 'delivery-upload-timeout',
fileName: 'upload-timeout.txt',
bytes: Buffer.from('timeout'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed'
&& error.cause?.code === 'provider-timeout');
assert.equal(uploadFixture.calls.replies.length, 0);
let messageCalls = 0;
const messageFixture = fakeClient({
replyMessage: async () => {
messageCalls += 1;
return new Promise(() => {});
},
});
const messageChannel = new VerifiedFeishuChannel({
client: messageFixture.client,
fileUploadTimeoutMs: 100,
fileMessageTimeoutMs: 10,
});
await assert.rejects(messageChannel.sendFile('oc_chat', {
artifactId: 'artifact-message-timeout',
deliveryKey: 'delivery-message-timeout',
fileName: 'message-timeout.txt',
bytes: Buffer.from('timeout'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.code === 'provider-timeout');
assert.equal(messageFixture.calls.fileUploads.length, 1);
assert.equal(messageCalls, 1);
});
test('VerifiedFeishuChannel rejects waits longer than the 120 second operation timeout', () => {
const { client } = fakeClient();
assert.throws(
() => new VerifiedFeishuChannel({ client, fileUploadTimeoutMs: 120_001 }),
/fileUploadTimeoutMs.*120000/,
);
assert.throws(
() => new VerifiedFeishuChannel({ client, fileMessageTimeoutMs: 120_001 }),
/fileMessageTimeoutMs.*120000/,
);
});
test('VerifiedFeishuChannel stops after an in-flight upload when file delivery is cancelled', async () => {
let uploadStarted;
const started = new Promise((resolve) => { uploadStarted = resolve; });
const { client, calls } = fakeClient({
uploadFile: async () => {
uploadStarted();
return new Promise(() => {});
},
});
const channel = new VerifiedFeishuChannel({ client });
const abort = new AbortController();
const reason = new DOMException('runtime stopped', 'AbortError');
const sending = channel.sendFile('oc_chat', {
artifactId: 'artifact-cancelled-upload',
deliveryKey: 'delivery-cancelled-upload',
fileName: 'cancelled.txt',
bytes: Buffer.from('cancelled'),
}, { replyTo: 'om_user', signal: abort.signal });
await started;
abort.abort(reason);
await assert.rejects(sending, (error) => error === reason);
assert.equal(calls.replies.length, 0);
});
test('VerifiedFeishuChannel immediately preserves caller abort during an in-flight message send', async () => {
let messageStarted;
const started = new Promise((resolve) => { messageStarted = resolve; });
const { client, calls } = fakeClient({
replyMessage: async () => {
messageStarted();
return new Promise(() => {});
},
});
const channel = new VerifiedFeishuChannel({ client });
const abort = new AbortController();
const reason = new DOMException('runtime stopped', 'AbortError');
const sending = channel.sendFile('oc_chat', {
artifactId: 'artifact-cancelled-message',
deliveryKey: 'delivery-cancelled-message',
fileName: 'cancelled-message.txt',
bytes: Buffer.from('cancelled'),
}, { replyTo: 'om_user', signal: abort.signal });
await started;
abort.abort(reason);
await assert.rejects(sending, (error) => error === reason
&& error.code !== 'artifact-delivery-uncertain');
assert.equal(calls.fileUploads.length, 1);
});
test('VerifiedFeishuChannel does not retry an uncertain file message after cancellation', async () => {
const abort = new AbortController();
const reason = new DOMException('runtime stopped', 'AbortError');
const requests = [];
const { client } = fakeClient({
replyMessage: async (request) => {
requests.push(request);
abort.abort(reason);
return { code: 230049, msg: 'still sending' };
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.sendFile('oc_chat', {
artifactId: 'artifact-cancelled-retry',
deliveryKey: 'delivery-cancelled-retry',
fileName: 'cancelled-retry.txt',
bytes: Buffer.from('cancelled retry'),
}, { replyTo: 'om_user', signal: abort.signal }), (error) => error === reason);
assert.equal(requests.length, 1);
});
test('VerifiedFeishuChannel lets Feishu decide empty and oversize file outcomes', async () => {
const uploads = [];
const providerCodes = [234010, 234006, 0];
const { client } = fakeClient({
uploadFile: async (request) => {
uploads.push(request);
const code = providerCodes.shift();
return code === 0 ? {} : { code };
},
});
const channel = new VerifiedFeishuChannel({ client });
const base = {
artifactId: 'artifact-invalid',
deliveryKey: 'delivery-invalid',
fileName: 'invalid.bin',
};
await assert.rejects(
channel.sendFile('oc_chat', { ...base, bytes: Buffer.alloc(0) }),
(error) => error.code === 'artifact-empty' && error.providerCode === 234010,
);
await assert.rejects(
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('provider decides') }),
(error) => error.code === 'artifact-too-large' && error.providerCode === 234006,
);
await assert.rejects(
channel.sendFile('oc_chat', { ...base, bytes: Buffer.from('valid bytes') }),
(error) => error.code === 'artifact-provider-failed' && !error.message.includes('undefined'),
);
assert.deepEqual(uploads.map((request) => request.data.file.byteLength), [0, 16, 11]);
});
test('VerifiedFeishuChannel distinguishes upload failure from uncertain final delivery', async () => {
const uploadFixture = fakeClient({
uploadFile: async () => { throw new Error('upload transport closed'); },
});
const uploadChannel = new VerifiedFeishuChannel({ client: uploadFixture.client });
await assert.rejects(uploadChannel.sendFile('oc_chat', {
artifactId: 'artifact-upload-transport',
deliveryKey: 'delivery-upload-transport',
fileName: 'upload.txt',
bytes: Buffer.from('upload'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-provider-failed');
assert.equal(uploadFixture.calls.replies.length, 0);
for (const replyMessage of [
async () => { throw new Error('message transport closed'); },
async () => ({ code: 0, data: {} }),
]) {
const messageFixture = fakeClient({ replyMessage });
const messageChannel = new VerifiedFeishuChannel({ client: messageFixture.client });
await assert.rejects(messageChannel.sendFile('oc_chat', {
artifactId: 'artifact-uncertain-message',
deliveryKey: 'delivery-uncertain-message',
fileName: 'message.txt',
bytes: Buffer.from('message'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-delivery-uncertain');
assert.equal(messageFixture.calls.fileUploads.length, 1);
}
});
test('VerifiedFeishuChannel keeps explicit final provider codes out of the uncertain bucket', async () => {
const { client, calls } = fakeClient({
replyMessage: async () => ({ code: 99991672, msg: 'missing scope' }),
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.sendFile('oc_chat', {
artifactId: 'artifact-message-permission',
deliveryKey: 'delivery-message-permission',
fileName: 'permission.txt',
bytes: Buffer.from('permission'),
}, { replyTo: 'om_user' }), (error) => error.code === 'artifact-permission-required'
&& error.providerCode === 99991672);
assert.equal(calls.fileUploads.length, 1);
});
test('VerifiedFeishuChannel classifies missing file permission without exposing provider details', async () => {
const { client } = fakeClient({
uploadFile: async () => {
const error = new Error('raw tenant detail and private diagnostic');
error.code = 99991672;
throw error;
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(
channel.sendFile('oc_chat', {
artifactId: 'artifact-permission',
deliveryKey: 'delivery-permission',
fileName: 'result.html',
bytes: Buffer.from('result'),
}),
(error) => error.code === 'artifact-permission-required'
&& error.message.includes('im:resource permission')
&& !error.message.includes('im:resource:upload')
&& !error.message.includes('private diagnostic'),
);
});
test('VerifiedFeishuChannel recognizes the SDK array-shaped permission error', async () => {
const { client } = fakeClient({
uploadFile: async () => {
const transport = new Error('raw transport diagnostic');
transport.code = 'ERR_BAD_REQUEST';
throw [transport, {
code: 99991672,
msg: 'raw provider permission URL',
}];
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(
channel.sendFile('oc_chat', {
artifactId: 'artifact-array-permission',
deliveryKey: 'delivery-array-permission',
fileName: 'result.txt',
bytes: Buffer.from('result'),
}),
(error) => error.code === 'artifact-permission-required'
&& error.providerCode === 99991672
&& !error.message.includes('provider permission URL'),
);
});

View file

@ -1,9 +1,19 @@
import test from 'node:test';
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 {
HarnessClient,
HarnessReplyTracker,
} from '../../../src/channels/feishu/harness-client.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
createOutboundArtifactTool,
materializeOutboundArtifact,
outboundArtifactRegistry,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
function deferred() {
let resolve;
@ -582,6 +592,124 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
});
test('HarnessClient asks do not control file-return tool availability', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/dsh-feishu-workspace',
});
client.ensureRunning = async () => undefined;
let promptRpcId;
let prompted = false;
const agent = {
session: {
header: { id: 'session-artifact-availability', cwd: '/tmp/dsh-feishu-workspace' },
events: [],
},
};
client.rpc = async (method, _payload, _timeoutMs, options) => {
if (method === 'session.history' && !prompted) return { events: [] };
if (method === 'session.prompt') {
prompted = true;
promptRpcId = options.rpcId;
agent.session.events = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId: promptRpcId } } },
];
return {};
}
return {
events: [
{ event: { type: 'turn/start', seq: 1, data: { turn: 1 } } },
{
event: {
type: 'user/message',
seq: 2,
data: { turn: 1, source: { rpcId: promptRpcId } },
},
},
{
event: {
type: 'assistant/message',
seq: 3,
data: { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'done' }] } },
},
},
{ event: { type: 'turn/end', seq: 4, data: { turn: 1, reason: { kind: 'completed' } } } },
],
};
};
assert.equal(await client.ask('session-artifact-availability', 'create a file', {
onArtifact: async () => undefined,
}), 'done');
});
test('HarnessClient delivers an existing file-only Turn directly', async (t) => {
outboundArtifactRegistry.clear();
t.after(() => outboundArtifactRegistry.clear());
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-file-only-'));
t.after(() => rm(workspace, { recursive: true, force: true }));
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace,
});
client.ensureRunning = async () => undefined;
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
const delivered = [];
await writeFile(join(workspace, 'file-only.txt'), 'file only');
let promptRpcId;
let prompted = false;
const agent = {
session: {
header: { id: 'session-file-only', cwd: workspace },
events: [],
},
};
client.rpc = async (method, _payload, _timeoutMs, options) => {
if (method === 'session.history' && !prompted) return { events: [] };
if (method === 'session.prompt') {
prompted = true;
promptRpcId = options.rpcId;
agent.session.events = [
{ type: 'turn/start', data: { turn: 2 } },
{ type: 'user/message', data: { turn: 2, source: { rpcId: promptRpcId } } },
];
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: 'file-only-call',
token: Symbol('file-only-call'),
agent,
signal: new AbortController().signal,
};
await tool.definition.execute({ path: 'file-only.txt' }, exec);
tool.onResult(exec, { isError: false });
return {};
}
return {
events: [
{ event: { type: 'turn/start', seq: 1, data: { turn: 2 } } },
{
event: {
type: 'user/message',
seq: 2,
data: { turn: 2, source: { rpcId: promptRpcId } },
},
},
{ event: { type: 'turn/end', seq: 3, data: { turn: 2, reason: { kind: 'completed' } } } },
],
};
};
const answer = await client.ask('session-file-only', 'create and return a file', {
onArtifact: async (artifact) => delivered.push(artifact),
});
assert.equal(answer, '');
assert.equal(delivered.length, 1);
const file = await materializeOutboundArtifact(delivered[0]);
assert.equal(file.bytes.toString(), 'file only');
releaseOutboundArtifact(delivered[0]);
});
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });

View file

@ -180,6 +180,8 @@ test('QR registration separates events from card callbacks', async () => {
const run = fx.registrationRuns.shift();
assert.deepEqual(run.options.addons.events.items.tenant, ['im.message.receive_v1']);
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
run.resolve({
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',

View file

@ -84,6 +84,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
fixture.resolveRegistration({

View file

@ -1223,6 +1223,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
const alphaState = constructed.runtime.state;
assert.equal(constructed.runtime.botId, 'bot_alpha');
assert.equal(constructed.runtime.repair, repair);
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
await constructed.controller.createRuntime({
botId: 'bot_beta',
config: {
@ -1235,6 +1236,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
appSecret: 'beta-secret',
});
const betaState = constructed.runtime.state;
assert.equal(Object.hasOwn(constructed.runtime, 'outboundArtifactsEnabled'), false);
assert.notEqual(alphaState, betaState);
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));

View file

@ -1,8 +1,16 @@
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 { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
function deferred() {
let resolve;
@ -52,6 +60,37 @@ function message(overrides = {}) {
};
}
async function committedArtifact(t, fileName, content, suffix) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-qq-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
let nextId = 0;
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
t.after(() => registry.clear());
const rpcId = `rpc-${suffix}`;
const agent = {
session: {
header: { id: `session-${suffix}`, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
],
},
};
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await writeFile(join(workspace, fileName), content);
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
const [artifact] = registry.take(agent.session.header.id, 1);
return artifact;
}
const PNG_BYTES = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x00,
@ -1419,3 +1458,311 @@ test('QQ propagates the stop signal and cancels its pending question on abort',
assert.notEqual(cancellationSignal, controller.signal);
assert.equal(cancellationSignal.aborted, false);
});
test('QQ sends registered files after text with the native SDK and continues after one file fails', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
const order = [];
const sentTexts = [];
const files = [];
const status = {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
artifactsSent: 0,
artifactSendErrors: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-artifact-partial' };
const bridge = new QqHarnessBridge({
bot: {
sendText: async (replyTarget, text) => {
sentTexts.push({ replyTarget, text });
order.push(`text:${text}`);
return { id: `text-${sentTexts.length}` };
},
sendFile: async (replyTarget, source, options) => {
files.push({ replyTarget, source, options });
order.push(`file:${options.fileName}`);
if (options.fileName === 'first.txt') {
const error = new Error('private quota detail');
error.name = 'UploadDailyLimitExceededError';
throw error;
}
return { message: { id: 'qq-file-2' } };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first);
await options.onArtifact(second);
return '文件处理完成。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-artifacts']]).state,
status,
logger: { warn() {}, error() {} },
});
await bridge.accept(message({
messageId: 'qq-artifact-partial',
replyTarget: target,
}));
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
'text', 'file', 'text', 'file',
]);
assert.equal(files[0].replyTarget, target);
assert.equal(files[0].source.buffer.toString(), 'first bytes');
assert.equal(files[0].options.fileName, 'first.txt');
assert.equal(typeof files[0].options.onProgress, 'function');
assert.equal(files[1].source.buffer.toString(), '<h1>second</h1>');
assert.equal(files[1].options.fileName, 'second.html');
assert.match(sentTexts[1].text, /first\.txt.*上传额度/);
assert.doesNotMatch(sentTexts[1].text, /private quota detail/);
assert.equal(status.artifactsSent, 1);
assert.equal(status.artifactSendErrors, 1);
});
test('QQ still delivers registered files when every final text delivery attempt fails', async (t) => {
const artifact = await committedArtifact(t, 'survives-text-failure.txt', 'file bytes', 'text-failure');
const files = [];
let textAttempts = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => {
textAttempts += 1;
throw new Error('text transport unavailable');
},
sendFile: async (_target, source, options) => {
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
return { message: { id: 'qq-file-after-text-failure' } };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: stateFixture([['c2c:owner-openid', 'session-text-failure']]).state,
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'qq-text-failure' }));
assert.deepEqual(files, [{ bytes: Buffer.from('file bytes'), fileName: 'survives-text-failure.txt' }]);
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
});
test('QQ returns the authoritative receipt and sends one safe notice when text and file delivery fail', async (t) => {
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
const attemptedTexts = [];
const visibleTexts = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
attemptedTexts.push(text);
if (text === '文字结果') throw new Error('text transport unavailable');
visibleTexts.push(text);
return undefined;
},
sendFile: async () => {
const error = new Error('mismatched file signature');
error.code = 'artifact-invalid';
throw error;
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: stateFixture([['c2c:owner-openid', 'session-all-fail']]).state,
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(message({ messageId: 'qq-all-fail' }));
assert.equal(attemptedTexts.length, 2, 'must not append a generic error after the safe notice');
assert.equal(visibleTexts.length, 1);
assert.match(visibleTexts[0], /暂时无法读取或准备发送.*仍可访问/);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'qq-all-fail',
presentation: 'qq-files',
providerMessageIds: [],
artifacts: [{
artifactId: artifact.artifactId,
outcome: 'rejected',
reason: 'artifact-invalid',
}],
});
});
test('QQ keeps the generic error when neither the answer nor the file failure notice is visible', async (t) => {
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
const attemptedTexts = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
attemptedTexts.push(text);
if (attemptedTexts.length < 3) throw new Error('text transport unavailable');
return { id: 'qq-generic-error' };
},
sendFile: async () => {
const error = new Error('file transport unavailable');
error.code = 'artifact-provider-failed';
throw error;
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: stateFixture([['c2c:owner-openid', 'session-no-visible-failure']]).state,
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'qq-no-visible-failure' }));
assert.equal(attemptedTexts.length, 3);
assert.equal(attemptedTexts.at(-1), '消息处理失败,请稍后重试。');
});
test('QQ reports an unacknowledged native file send as uncertain instead of inviting a blind retry', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
const texts = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => texts.push(text),
sendFile: async () => new Promise(() => {}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文件已生成。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-uncertain']]).state,
fileUploadTimeoutMs: 20,
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'qq-uncertain-file' }));
assert.match(texts[1], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
});
test('QQ runtime cancellation interrupts an in-flight file send and skips later files and notices', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
const started = deferred();
const controller = new AbortController();
const texts = [];
const files = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => texts.push(text),
sendFile: async (_target, _source, options) => {
files.push(options.fileName);
started.resolve();
return new Promise(() => {});
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first);
await options.onArtifact(second);
return '文件如下。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-abort-files']]).state,
signal: controller.signal,
logger: { warn() {}, error() {} },
});
const processing = bridge.accept(message({ messageId: 'qq-abort-file' }));
await started.promise;
controller.abort(new DOMException('runtime stopped', 'AbortError'));
await Promise.race([
processing,
new Promise((_, reject) => setTimeout(() => reject(new Error('QQ abort timed out')), 500)),
]);
assert.deepEqual(files, ['first.txt']);
assert.equal(texts.some((text) => text.includes('发送结果未能确认')), false);
assert.equal(texts.some((text) => text === '消息处理失败,请稍后重试。'), false);
});
test('QQ uses a neutral final text for a file-only Turn', async (t) => {
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
const texts = [];
const files = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => texts.push(text),
sendFile: async (_target, source, options) => {
files.push({ bytes: Buffer.from(source.buffer), fileName: options.fileName });
return { message: { id: 'qq-file-only' } };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: stateFixture([['c2c:owner-openid', 'session-file-only']]).state,
});
await bridge.accept(message({ messageId: 'qq-file-only' }));
assert.deepEqual(texts, ['结果文件已生成。']);
assert.deepEqual(files, [{ bytes: Buffer.from('only bytes'), fileName: 'only.txt' }]);
});
test('QQ cancellation prevents SDK sendFile', async (t) => {
let fileCalls = 0;
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
const controller = new AbortController();
const cancelledBridge = new QqHarnessBridge({
bot: {
sendText: async () => {},
sendFile: async () => { fileCalls += 1; },
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
controller.abort(new DOMException('stopped', 'AbortError'));
return '停止前的回答';
},
},
state: stateFixture([['c2c:owner-openid', 'session-cancelled']]).state,
signal: controller.signal,
});
await cancelledBridge.accept(message({ messageId: 'qq-artifact-cancelled' }));
assert.equal(fileCalls, 0);
});

View file

@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/qq/production.mjs';
test('QQ production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-qq-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) {
this.options = options;
runtimes.push(options);
}
}
class Controller {
constructor(options) {
controllerOptions = options;
}
async close() {}
}
class QrAuth {}
const supervisor = {
ready: Promise.resolve(),
start() { return this; },
async close() {},
};
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
QrAuth,
createConnectionSupervisor: () => supervisor,
});
await controllerOptions.createRuntime({
botId: 'qq_enabled',
config: { botId: 'qq_enabled', appId: 'app-enabled' },
appSecret: 'host-only',
});
await controllerOptions.createRuntime({
botId: 'qq_disabled',
config: { botId: 'qq_disabled', appId: 'app-disabled' },
appSecret: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
});

View file

@ -184,3 +184,45 @@ test('QQ runtime aborts an in-flight Harness interaction when stopped', async ()
await runtime.stop();
assert.equal(askSignal.aborted, true);
});
test('QQ runtime enables result-file delivery without per-bot configuration', async () => {
const bot = new FakeBot();
const finished = deferred();
let onArtifact;
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
appSecret: 'secret',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
onArtifact = options.onArtifact;
return '完成';
},
},
state: {
hasSeen: () => false,
markSeen: async () => finished.resolve(),
sessionFor: () => 'session-existing',
setSession: async () => {},
clearSession: async () => {},
},
createBot: () => bot,
typingMiddleware: () => 'typing',
connectTimeoutMs: 100,
});
await runtime.start();
bot.emit('message', {}, {
kind: 'c2c',
rawEventType: 'C2C_MESSAGE_CREATE',
senderId: 'owner',
senderIsBot: false,
content: '生成文件',
messageId: 'qq-artifact-gate',
replyTarget: { scope: 'c2c', targetId: 'owner', msgId: 'qq-artifact-gate' },
});
await finished.promise;
assert.equal(typeof onArtifact, 'function');
await runtime.stop();
});

View file

@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DiscordBotClient } from '../../../src/channels/discord/discord-runtime.mjs';
import { createEditableMessageStream } from '../../../src/channels/shared/editable-message-stream.mjs';
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
import { SlackBotClient } from '../../../src/channels/slack/slack-runtime.mjs';
import { TelegramBotClient } from '../../../src/channels/telegram/telegram-runtime.mjs';
import { WhatsappBotClient } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
function memoryState() {
const sessions = new Map();
const seen = new Set();
return {
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => { sessions.set(key, sessionId); },
clearSession: async (key) => { sessions.delete(key); },
hasSeen: (messageId) => seen.has(messageId),
markSeen: async (messageId) => { seen.add(messageId); },
};
}
function inboundMessage(messageId, replyTarget) {
return {
messageId,
senderId: 'message-id-test-user',
senderIsBot: false,
kind: 'direct',
conversationId: `conversation-${messageId}`,
content: '请返回长文本',
addressed: true,
replyTarget,
};
}
async function textReceipt({ key, bot, answer, replyTarget }) {
const bridge = new TextHarnessBridge({
descriptor: { key, label: key },
bot,
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => `session-${key}`,
ask: async () => answer,
},
});
return bridge.accept(inboundMessage(`inbound-${key}`, replyTarget));
}
test('editable message streams retain the initial and every remainder message id', async () => {
let nextMessageId = 101;
const edited = [];
const remainders = [];
const stream = createEditableMessageStream({
initialText: 'working',
limit: 5,
create: async () => 100,
edit: async (messageId, text) => { edited.push({ messageId, text }); },
sendRemainder: async (text) => {
remainders.push(text);
return { message_id: nextMessageId++ };
},
messageIdForResult: (result) => result.message_id,
});
await stream.start();
await stream.finish('ABCDEFGHIJKLM');
assert.deepEqual(edited, [{ messageId: 100, text: 'ABCDE' }]);
assert.deepEqual(remainders, ['FGHIJ', 'KLM']);
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
const copy = stream.providerMessageIds;
copy.push('not-stored');
assert.deepEqual(stream.providerMessageIds, ['100', '101', '102']);
});
test('plain Slack, Telegram, Discord, and WhatsApp receipts retain every split message id', async () => {
let slackId = 0;
const slack = new SlackBotClient({
api: {
postMessage: async () => ({ ts: `slack-${++slackId}` }),
},
});
const slackReceipt = await textReceipt({
key: 'slack',
bot: { sendText: slack.sendText.bind(slack) },
answer: 'S'.repeat(38_001),
replyTarget: { channelId: 'C-message-ids', threadTs: '1.0' },
});
assert.deepEqual(slackReceipt.providerMessageIds, ['slack-1', 'slack-2']);
let telegramId = 200;
const telegram = new TelegramBotClient({
api: {
sendMessage: async () => ({ message_id: ++telegramId }),
},
});
const telegramReceipt = await textReceipt({
key: 'telegram',
bot: { sendText: telegram.sendText.bind(telegram) },
answer: 'T'.repeat(4_001),
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
});
assert.deepEqual(telegramReceipt.providerMessageIds, ['201', '202']);
let discordId = 0;
const discord = new DiscordBotClient({
api: {
createMessage: async () => ({ id: `discord-${++discordId}` }),
},
});
const discordReceipt = await textReceipt({
key: 'discord',
bot: { sendText: discord.sendText.bind(discord) },
answer: 'D'.repeat(1_901),
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
});
assert.deepEqual(discordReceipt.providerMessageIds, ['discord-1', 'discord-2']);
let whatsappId = 0;
const remembered = [];
const whatsapp = new WhatsappBotClient({
sendPresenceUpdate: async () => {},
sendMessage: async () => ({ key: { id: `whatsapp-${++whatsappId}` } }),
}, {
remember: (messageId) => remembered.push(messageId),
});
const whatsappReceipt = await textReceipt({
key: 'whatsapp',
bot: { sendText: whatsapp.sendText.bind(whatsapp) },
answer: 'W'.repeat(4_001),
replyTarget: { jid: '16505550123@s.whatsapp.net' },
});
assert.deepEqual(whatsappReceipt.providerMessageIds, ['whatsapp-1', 'whatsapp-2']);
assert.deepEqual(remembered, ['whatsapp-1', 'whatsapp-2']);
});
test('Slack, Telegram, and Discord stream receipts retain the initial and remainder ids', async () => {
let slackRemainder = 0;
const slack = new SlackBotClient({
api: {
startStream: async () => ({ ts: 'slack-stream-first' }),
appendStream: async () => ({ ok: true }),
stopStream: async () => ({ ok: true }),
updateMessage: async () => ({ ok: true }),
postMessage: async () => ({ ts: `slack-stream-rest-${++slackRemainder}` }),
},
logger: { warn() {} },
});
const slackReceipt = await textReceipt({
key: 'slack-stream',
bot: slack,
answer: 'S'.repeat(38_001),
replyTarget: { channelId: 'C-stream', threadTs: '2.0' },
});
assert.deepEqual(slackReceipt.providerMessageIds, [
'slack-stream-first',
'slack-stream-rest-1',
]);
let telegramId = 300;
const telegram = new TelegramBotClient({
api: {
sendChatAction: async () => true,
sendMessage: async () => ({ message_id: ++telegramId }),
editMessageText: async () => true,
},
});
const telegramReceipt = await textReceipt({
key: 'telegram-stream',
bot: telegram,
answer: 'T'.repeat(4_001),
replyTarget: { chatId: 42, replyToMessageId: 7, messageThreadId: 8 },
});
assert.deepEqual(telegramReceipt.providerMessageIds, ['301', '302']);
let discordId = 0;
const discord = new DiscordBotClient({
api: {
sendTyping: async () => true,
createMessage: async () => ({ id: `discord-stream-${++discordId}` }),
editMessage: async () => ({ id: 'ignored-edit-id' }),
},
});
const discordReceipt = await textReceipt({
key: 'discord-stream',
bot: discord,
answer: 'D'.repeat(1_901),
replyTarget: { channelId: 'discord-channel', replyToMessageId: 'discord-inbound' },
});
assert.deepEqual(discordReceipt.providerMessageIds, [
'discord-stream-1',
'discord-stream-2',
]);
});
test('the text bridge preserves stream ids before any ids returned only by finish', async () => {
const stream = {
messageId: 'stream-first',
providerMessageIds: ['stream-first', 'stream-remainder'],
update() {},
async finish() {
return { providerMessageIds: ['stream-remainder', 'finish-only'] };
},
cancel() {},
};
const receipt = await textReceipt({
key: 'custom-stream',
bot: {
sendText: async () => assert.fail('stream finalization must not fall back to plain text'),
openStream: async () => stream,
},
answer: 'done',
replyTarget: { channelId: 'custom-stream' },
});
assert.deepEqual(receipt.providerMessageIds, [
'stream-first',
'stream-remainder',
'finish-only',
]);
});

View file

@ -1,8 +1,16 @@
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 { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
createOutboundArtifactTool,
outboundArtifactRegistry,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import { WORKSPACE_SESSION_STALE } from '../../../src/channels/shared/workspace-session.mjs';
const CATALOG = {
@ -201,6 +209,7 @@ function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {})
calls,
client,
admitted: admitted.promise,
promptRpcId: () => promptRpcId,
setText(text) { answer = text; },
failHistory(error = new Error('history unavailable')) { historyFailure = error; },
finish({ text = '', reason = 'cancelled' } = {}) {
@ -259,6 +268,96 @@ test('a stopped turn returns partial text instead of a generic failure', async (
assert.equal(await asking, 'partial result');
});
test('an accepted stop preserves partial text but never hands off a registered artifact', async (t) => {
outboundArtifactRegistry.clear();
t.after(() => outboundArtifactRegistry.clear());
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-artifact-'));
t.after(() => rm(workspace, { recursive: true, force: true }));
const turn = controlledTurn({ sessionId: 'session-stopped-artifact' });
const control = { owner: {}, key: 'direct:stopped-artifact' };
const handedOff = [];
const asking = turn.client.ask(turn.id, 'work', {
control,
timeoutMs: 2_000,
onArtifact: async (artifact) => handedOff.push(artifact),
});
await turn.admitted;
const agent = {
session: {
header: { id: turn.id, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 7 } },
{
type: 'user/message',
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
},
],
},
};
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled result');
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: 'stopped-artifact-call',
rootCallId: 'stopped-artifact-call',
token: Symbol('stopped-artifact-call'),
agent,
};
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
tool.onResult(exec, { isError: false });
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
turn.finish({ text: 'partial result', reason: 'stopped' });
assert.equal(await asking, 'partial result');
assert.deepEqual(handedOff, []);
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
});
test('an accepted stop also discards a file-only result instead of handing it off', async (t) => {
outboundArtifactRegistry.clear();
t.after(() => outboundArtifactRegistry.clear());
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-file-only-'));
t.after(() => rm(workspace, { recursive: true, force: true }));
const turn = controlledTurn({ sessionId: 'session-stopped-file-only' });
const control = { owner: {}, key: 'direct:stopped-file-only' };
const handedOff = [];
const asking = turn.client.ask(turn.id, 'work', {
control,
timeoutMs: 2_000,
onArtifact: async (artifact) => handedOff.push(artifact),
});
await turn.admitted;
const agent = {
session: {
header: { id: turn.id, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 7 } },
{
type: 'user/message',
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
},
],
},
};
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled file-only result');
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: 'stopped-file-only-call',
rootCallId: 'stopped-file-only-call',
token: Symbol('stopped-file-only-call'),
agent,
};
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
tool.onResult(exec, { isError: false });
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
turn.finish({ reason: 'stopped' });
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
assert.deepEqual(handedOff, []);
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
});
test('accepted stop converts later polling failures to turn-stopped and preserves streamed text', async () => {
const withoutText = controlledTurn();
const firstControl = { owner: {}, key: 'direct:poll-error' };

View file

@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createTokenProductionController } from '../../../plugin-src/host/channels/shared/production.mjs';
test('token-channel production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-token-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const supervisor = {
ready: Promise.resolve(),
start() { return this; },
async close() {},
};
const production = await createTokenProductionController({
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor: () => supervisor,
}, {
channel: 'test-token',
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
});
await controllerOptions.createRuntime({
botId: 'bot_enabled',
config: { botId: 'bot_enabled' },
token: 'host-only',
});
await controllerOptions.createRuntime({
botId: 'bot_disabled',
config: { botId: 'bot_disabled' },
token: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
});

View file

@ -1,8 +1,17 @@
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 { DiscordHarnessBridge } from '../../../src/channels/discord/discord-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';
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
@ -121,7 +130,13 @@ function approvalInteraction({
};
}
function createBridge({ harness, state, bot, signal, logger } = {}) {
function createBridge({
harness,
state,
bot,
signal,
logger,
} = {}) {
return new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
bot,
@ -132,6 +147,41 @@ function createBridge({ harness, state, bot, signal, logger } = {}) {
});
}
async function committedArtifact(t, fileName, content, suffix) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-text-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
const sessionId = `session-artifact-${suffix}`;
const rpcId = `rpc-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 } } },
],
},
};
await writeFile(join(workspace, fileName), content);
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
const artifact = registry.take(sessionId, 1)[0];
t.after(() => releaseOutboundArtifact(artifact));
return artifact;
}
test('all four shared text channels execute /compact outside the model prompt path', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
@ -329,6 +379,301 @@ test('a stopped shared-channel turn closes an opened stream instead of leaving a
assert.deepEqual(sent, []);
});
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
const fixture = stateFixture();
const order = [];
const files = [];
const target = { id: 'artifact-success-target' };
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => {
order.push(`text:${text}`);
return { id: 'text-success' };
},
sendFile: async (receivedTarget, file) => {
order.push(`file:${file.fileName}`);
files.push({ target: receivedTarget, file });
return { message_id: `file-${files.length}` };
},
},
harness: {
createSession: async () => 'session-artifact-success',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first);
await options.onArtifact(second);
return '结果文件如下。';
},
},
});
const receipt = await bridge.accept(message(
'artifact-success',
'生成结果文件',
{ replyTarget: target },
));
assert.deepEqual(order, [
'text:结果文件如下。',
'file:result.html',
'file:notes.txt',
]);
assert.equal(files[0].target, target);
assert.equal(files[0].file.bytes.toString(), '<h1>result</h1>');
assert.equal(files[1].file.bytes.toString(), 'notes');
assert.equal(bridge.status.artifactsSent, 2);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'artifact-success',
presentation: 'test-text-and-files',
providerMessageIds: ['text-success', 'file-1', 'file-2'],
artifacts: [
{ artifactId: 'success-html-1', outcome: 'sent' },
{ artifactId: 'success-text-1', outcome: 'sent' },
],
});
});
test('a file-only shared text reply shows one neutral completion message before the file', async (t) => {
const artifact = await committedArtifact(t, 'only.txt', 'only file', 'file-only');
const fixture = stateFixture();
const order = [];
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => order.push(`text:${text}`),
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
},
harness: {
createSession: async () => 'session-file-only',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return ' ';
},
},
});
await bridge.accept(message('artifact-file-only', '只生成并发送文件'));
assert.deepEqual(order, ['text:任务已完成。', 'file:only.txt']);
});
test('shared text delivery still attempts registered files when its final text cannot be sent', async (t) => {
const artifact = await committedArtifact(t, 'text-failed.txt', 'still delivered', 'text-failed');
const fixture = stateFixture();
const files = [];
const texts = [];
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => {
texts.push(text);
if (texts.length === 1) throw new Error('private text transport failure');
},
sendFile: async (_target, file) => {
files.push(file.fileName);
return { key: { id: 'file-after-text-failure' } };
},
},
harness: {
createSession: async () => 'session-text-failed',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字回答';
},
},
});
const receipt = await bridge.accept(message('artifact-text-failed', '生成文件'));
assert.deepEqual(files, ['text-failed.txt']);
assert.deepEqual(texts, ['文字回答']);
assert.equal(bridge.status.artifactsSent, 1);
assert.equal(bridge.status.messagesReplied, 1);
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
});
test('shared text artifact provider failures are isolated from text and later files', async (t) => {
const providerFailure = await committedArtifact(t, 'provider.txt', 'bad', 'provider-failure');
const oversized = await committedArtifact(t, 'oversized.txt', '123456789', 'oversized');
const success = await committedArtifact(t, 'success.txt', 'ok', 'partial-success');
const fixture = stateFixture();
const sent = [];
const attempted = [];
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => {
sent.push(text);
return { id: `text-partial-${sent.length}` };
},
sendFile: async (_target, file) => {
attempted.push(file.fileName);
if (file.fileName === 'provider.txt') {
throw new Error('private provider diagnostic');
}
if (file.fileName === 'oversized.txt') {
const error = new Error('provider upload limit');
error.code = 'artifact-too-large';
throw error;
}
return { id: 'file-partial-success' };
},
},
harness: {
createSession: async () => 'session-artifact-partial',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(providerFailure);
await options.onArtifact(oversized);
await options.onArtifact(success);
return '三个文件已生成。';
},
},
});
const receipt = await bridge.accept(message('artifact-partial', '生成三个文件'));
assert.deepEqual(attempted, ['provider.txt', 'oversized.txt', 'success.txt']);
assert.equal(sent[0], '三个文件已生成。');
assert.match(sent[1], /provider\.txt.*暂时未能发送/);
assert.doesNotMatch(sent[1], /private provider diagnostic/);
assert.match(sent[2], /oversized\.txt.*超过当前渠道大小上限/);
assert.equal(bridge.status.artifactsSent, 1);
assert.equal(bridge.status.artifactSendErrors, 2);
assert.deepEqual(receipt.providerMessageIds, [
'text-partial-1',
'text-partial-2',
'text-partial-3',
'file-partial-success',
]);
assert.deepEqual(receipt.artifacts, [
{
artifactId: 'provider-failure-1',
outcome: 'failed',
reason: 'artifact-provider-failed',
},
{ artifactId: 'oversized-1', outcome: 'rejected', reason: 'artifact-too-large' },
{ artifactId: 'partial-success-1', outcome: 'sent' },
]);
});
test('shared text bridge tells users to check the chat before retrying an uncertain file send', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.txt', 'bytes', 'uncertain');
const sent = [];
let textAttempts = 0;
const bridge = createBridge({
state: stateFixture().state,
bot: {
sendText: async (_target, text) => {
textAttempts += 1;
if (textAttempts === 1) throw new Error('initial text delivery failed');
sent.push(text);
return { ts: 'unknown-notice' };
},
sendFile: async () => {
const error = new Error('private timeout detail');
error.code = 'artifact-delivery-uncertain';
throw error;
},
},
harness: {
createSession: async () => 'session-uncertain',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文件已生成。';
},
},
});
const receipt = await bridge.accept(message('artifact-uncertain', '生成文件'));
assert.equal(textAttempts, 2, 'must not append a contradictory generic retry notice');
assert.match(sent[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
assert.doesNotMatch(sent[0], /private timeout detail/);
assert.deepEqual(receipt.providerMessageIds, ['unknown-notice']);
assert.deepEqual(receipt.artifacts, [{
artifactId: 'uncertain-1',
outcome: 'unknown',
reason: 'artifact-delivery-uncertain',
}]);
});
test('shared text streaming finalizes once before delivering result files', async (t) => {
const artifact = await committedArtifact(t, 'stream.txt', 'stream file', 'stream');
const fixture = stateFixture();
const order = [];
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => order.push(`text:${text}`),
sendTyping: async () => order.push('typing'),
openStream: async () => {
order.push('open');
return {
messageId: 'stream-message',
update: async (text) => order.push(`update:${text}`),
finish: async (text) => order.push(`finish:${text}`),
cancel: () => order.push('cancel'),
};
},
sendFile: async (_target, file) => order.push(`file:${file.fileName}`),
},
harness: {
createSession: async () => 'session-artifact-stream',
ask: async (_sessionId, _text, options) => {
await options.onUpdate({ type: 'text', text: '处理中' });
await options.onArtifact(artifact);
return '流式回答完成';
},
},
});
const receipt = await bridge.accept(message('artifact-stream', '流式生成文件'));
assert.deepEqual(order, [
'typing',
'open',
'update:处理中',
'finish:流式回答完成',
'file:stream.txt',
]);
assert.deepEqual(receipt.providerMessageIds, ['stream-message']);
});
test('aborting shared text delivery stops before any registered file is sent', async (t) => {
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled', 'cancelled');
const fixture = stateFixture();
const controller = new AbortController();
const sent = [];
const files = [];
const bridge = createBridge({
state: fixture.state,
signal: controller.signal,
bot: {
sendText: async (_target, text) => {
sent.push(text);
controller.abort(new DOMException('runtime stopped', 'AbortError'));
},
sendFile: async (_target, file) => files.push(file.fileName),
},
harness: {
createSession: async () => 'session-artifact-cancelled',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '回答完成';
},
},
});
await bridge.accept(message('artifact-cancelled', '生成后取消'));
assert.deepEqual(sent, ['回答完成']);
assert.deepEqual(files, []);
});
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],

View file

@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/slack/production.mjs';
test('Slack production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-slack-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const createConnectionSupervisor = () => ({
ready: Promise.resolve(),
start() { return this; },
async close() {},
});
const internals = {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor,
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};
const production = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'slack_enabled',
config: { botId: 'slack_enabled' },
botToken: 'host-only-bot-token',
appToken: 'host-only-app-token',
});
await controllerOptions.createRuntime({
botId: 'slack_disabled',
config: { botId: 'slack_disabled' },
botToken: 'host-only-bot-token',
appToken: 'host-only-app-token',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'slack_default',
config: { botId: 'slack_default' },
botToken: 'host-only-bot-token',
appToken: 'host-only-app-token',
});
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
await productionWithDefault.close();
});

View file

@ -134,6 +134,269 @@ test('Slack API uses native streaming methods and suppresses generated mass ment
assert.equal(calls[3].body.text, '请通知 @channel 和 @U99999999');
});
test('Slack API completes the native external-upload flow in the original thread', 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/TICKET',
file_id: 'F12345678',
});
}
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
return jsonResponse({ ok: true, files: [{ id: 'F12345678', title: 'result.txt' }] });
},
});
const result = await api.uploadFile({
channelId: 'C12345678',
threadTs: '1700000000.001',
file: {
fileName: 'result.txt',
mediaType: 'text/plain',
bytes: Buffer.from('slack-result'),
},
});
assert.equal(result.files[0].id, 'F12345678');
assert.deepEqual(calls.map(({ url }) => url.pathname), [
'/api/files.getUploadURLExternal',
'/upload/v1/TICKET',
'/api/files.completeUploadExternal',
]);
assert.equal(
calls[0].options.headers['content-type'],
'application/x-www-form-urlencoded;charset=utf-8',
);
assert.equal(calls[0].options.body, 'filename=result.txt&length=12');
assert.equal(calls[1].options.headers.authorization, undefined);
assert.equal(Buffer.from(calls[1].options.body).toString(), 'slack-result');
assert.equal(calls[2].options.headers['content-type'], 'application/json;charset=utf-8');
assert.deepEqual(JSON.parse(calls[2].options.body), {
files: [{ id: 'F12345678', title: 'result.txt' }],
channel_id: 'C12345678',
thread_ts: '1700000000.001',
});
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:write\n/);
});
test('Slack file preparation maps missing scope and pre-delivery failures without claiming uncertainty', async () => {
const missingScopeApi = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async () => jsonResponse({ ok: false, error: 'missing_scope' }),
});
await assert.rejects(() => missingScopeApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-permission-required'
&& error.providerCode === 'missing_scope');
const invalidArgumentsApi = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async () => jsonResponse({ ok: false, error: 'invalid_arguments' }),
});
await assert.rejects(() => invalidArgumentsApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-provider-rejected'
&& error.providerCode === 'invalid_arguments'
&& error.status === 200);
const sizeRestrictedApi = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async () => jsonResponse({
ok: false,
error: 'file_upload_size_restricted',
}),
});
await assert.rejects(() => sizeRestrictedApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-too-large'
&& error.providerCode === 'file_upload_size_restricted'
&& error.status === 200);
for (const ticketResponse of [
new Response('not-json', { status: 200 }),
jsonResponse({
ok: true,
upload_url: 'https://example.com/untrusted',
file_id: 'F12345678',
}),
]) {
const api = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async () => ticketResponse,
});
await assert.rejects(() => api.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-provider-failed');
}
const rawUploadTimeoutApi = new SlackApi({
botToken: BOT_TOKEN,
fileUploadTimeoutMs: 10,
fetchImpl: async (url, options) => {
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
return jsonResponse({
ok: true,
upload_url: 'https://files.slack.com/upload/v1/TICKET',
file_id: 'F12345678',
});
}
return new Promise((resolve, reject) => {
if (options.signal.aborted) reject(options.signal.reason);
else options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
});
},
});
await assert.rejects(() => rawUploadTimeoutApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-provider-failed'
&& error.cause?.name === 'TimeoutError');
for (const status of [401, 403]) {
const expiredTicketApi = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async (url) => {
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
return jsonResponse({
ok: true,
upload_url: 'https://files.slack.com/upload/v1/TICKET',
file_id: 'F12345678',
});
}
return new Response('', { status });
},
});
await assert.rejects(() => expiredTicketApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-provider-rejected'
&& error.status === status);
}
});
test('Slack completion is never retried and all non-permission failures are uncertain', async () => {
const completionCases = [{
complete: async () => jsonResponse({ ok: false, error: 'ratelimited' }, 429, {
'retry-after': '0.001',
}),
providerCode: 'ratelimited',
}, {
complete: async () => new Response('not-json', { status: 200 }),
}, {
complete: async () => { throw new TypeError('socket reset'); },
}, {
complete: async () => jsonResponse({ ok: true, files: [] }),
}, {
complete: async () => jsonResponse({
ok: true,
files: [{ id: 'F87654321', title: 'result.txt' }],
}),
}];
for (const entry of completionCases) {
let completionCalls = 0;
const api = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async (url) => {
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
return jsonResponse({
ok: true,
upload_url: 'https://files.slack.com/upload/v1/TICKET',
file_id: 'F12345678',
});
}
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
completionCalls += 1;
return entry.complete();
},
});
await assert.rejects(() => api.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.providerCode === entry.providerCode);
assert.equal(completionCalls, 1);
}
});
test('Slack completion timeout is uncertain while a caller abort remains an abort', async () => {
const createApi = ({ complete, fileUploadTimeoutMs = 120_000 }) => new SlackApi({
botToken: BOT_TOKEN,
fileUploadTimeoutMs,
fetchImpl: async (url, options) => {
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
return jsonResponse({
ok: true,
upload_url: 'https://files.slack.com/upload/v1/TICKET',
file_id: 'F12345678',
});
}
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
return complete(options.signal);
},
});
const timeoutApi = createApi({
fileUploadTimeoutMs: 10,
complete: async (signal) => new Promise((resolve, reject) => {
if (signal.aborted) reject(signal.reason);
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
});
await assert.rejects(() => timeoutApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.name === 'TimeoutError');
const caller = new AbortController();
const reason = new DOMException('caller stopped', 'AbortError');
const cancelledApi = createApi({
complete: async () => {
caller.abort(reason);
throw reason;
},
});
await assert.rejects(() => cancelledApi.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
signal: caller.signal,
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
});
test('Slack completion missing_scope remains a permission error', async () => {
let completionCalls = 0;
const api = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async (url) => {
if (url.pathname.endsWith('/files.getUploadURLExternal')) {
return jsonResponse({
ok: true,
upload_url: 'https://files.slack.com/upload/v1/TICKET',
file_id: 'F12345678',
});
}
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
completionCalls += 1;
return jsonResponse({ ok: false, error: 'missing_scope' });
},
});
await assert.rejects(() => api.uploadFile({
channelId: 'C12345678',
file: { fileName: 'result.txt', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-permission-required'
&& error.providerCode === 'missing_scope');
assert.equal(completionCalls, 1);
});
test('Slack downloads private files with the bot token from Slack hosts only', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const calls = [];

View file

@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
import {
createProductionController,
normalizeTelegramAllowedUsers,
} from '../../../plugin-src/host/channels/telegram/production.mjs';
test('Telegram per-bot policy normalizes and validates private-message allowlists', () => {
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
@ -18,3 +24,54 @@ test('Telegram per-bot policy normalizes and validates private-message allowlist
/invalid Telegram User ID/,
);
});
test('Telegram production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-telegram-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const supervisor = {
ready: Promise.resolve(),
start() { return this; },
async close() {},
};
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor: () => supervisor,
});
await controllerOptions.createRuntime({
botId: 'telegram_default',
config: { botId: 'telegram_default' },
token: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
await production.close();
});

View file

@ -200,6 +200,136 @@ test('Telegram API registers the command menu and commands-type menu button', as
await assert.rejects(() => api.setChatMenuButton({ menuButton: 'commands' }), /menu button is invalid/);
});
test('Telegram API uploads a result file as a native document 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: 901 } });
},
});
const result = await api.sendDocument({
chatId: -100123,
replyToMessageId: 44,
messageThreadId: 55,
file: {
fileName: 'result.txt',
mediaType: 'text/plain',
bytes: Buffer.from('telegram-result'),
},
});
assert.equal(result.message_id, 901);
assert.match(request.url.pathname, /sendDocument$/);
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 document = request.options.body.get('document');
assert.equal(document.name, 'result.txt');
assert.equal(document.type, 'text/plain');
assert.equal(Buffer.from(await document.arrayBuffer()).toString(), 'telegram-result');
});
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' },
status: 403,
code: 'artifact-permission-required',
}, {
body: { ok: false, error_code: 400, description: 'Bad Request: file is too big' },
status: 400,
code: 'artifact-too-large',
}, {
body: {
ok: false,
error_code: 429,
description: 'Too Many Requests',
parameters: { retry_after: 7 },
},
status: 429,
code: 'artifact-rate-limited',
retryAfter: 7,
}, {
body: { ok: false, error_code: 400, description: 'Bad Request: unsupported document' },
status: 400,
code: 'artifact-provider-rejected',
}, {
body: { ok: false, error_code: 500, description: 'Internal Server Error' },
status: 500,
code: 'artifact-delivery-uncertain',
}];
for (const entry of cases) {
const api = new TelegramApi({
token: TOKEN,
fetchImpl: async () => jsonResponse(entry.body, entry.status),
});
await assert.rejects(() => api.sendDocument({
chatId: 123,
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => {
assert.equal(error.code, entry.code);
assert.equal(error.providerCode, entry.body.error_code);
assert.equal(error.status, entry.status);
assert.equal(error.retry_after, entry.retryAfter);
assert.equal(error.retryAfter, entry.retryAfter);
return true;
});
}
});
test('Telegram document delivery marks post-dispatch failures uncertain but preserves caller aborts', async () => {
for (const fetchImpl of [
async () => { throw new TypeError('socket reset'); },
async () => new Response('not-json', { status: 200 }),
]) {
const api = new TelegramApi({ token: TOKEN, fetchImpl });
await assert.rejects(() => api.sendDocument({
chatId: 123,
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain');
}
const timeoutApi = new TelegramApi({
token: TOKEN,
fileUploadTimeoutMs: 10,
fetchImpl: async (_url, { signal }) => new Promise((resolve, reject) => {
if (signal.aborted) reject(signal.reason);
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
});
await assert.rejects(() => timeoutApi.sendDocument({
chatId: 123,
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.name === 'TimeoutError');
const caller = new AbortController();
const reason = new DOMException('caller stopped', 'AbortError');
caller.abort(reason);
let calls = 0;
const cancelledApi = new TelegramApi({
token: TOKEN,
fetchImpl: async () => {
calls += 1;
return jsonResponse({ ok: true, result: {} });
},
});
await assert.rejects(() => cancelledApi.sendDocument({
chatId: 123,
file: { fileName: 'result.bin', bytes: Buffer.from('result') },
signal: caller.signal,
}), (error) => error === reason && error.code !== 'artifact-delivery-uncertain');
assert.equal(calls, 0);
});
test('Telegram config and controller store only a credential reference in bot data', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-'));
t.after(() => rm(directory, { recursive: true, force: true }));

View file

@ -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 {
@ -7,6 +10,11 @@ import {
} from '../../../src/channels/wecom/wecom-bridge.mjs';
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -77,6 +85,37 @@ function testClient() {
};
}
async function committedArtifact(t, fileName, content, suffix) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-wecom-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
let nextId = 0;
const registry = new OutboundArtifactRegistry({ uuid: () => `${suffix}-${++nextId}` });
t.after(() => registry.clear());
const rpcId = `rpc-${suffix}`;
const agent = {
session: {
header: { id: `session-${suffix}`, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId } } },
],
},
};
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await writeFile(join(workspace, fileName), content);
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
const [artifact] = registry.take(agent.session.header.id, 1);
return artifact;
}
function questionInteraction({
interactionId = 'question-1',
sessionId = 'session-existing',
@ -1219,3 +1258,368 @@ test('aborting Enterprise WeChat work cancels its pending question without a fai
assert.equal(cancellations[0].responseOptions.signal.aborted, false);
assert.equal(transport.streamed.some(({ content }) => content === '消息处理失败,请稍后重试。'), false);
});
test('Enterprise WeChat sends registered files after the final text and continues after one file fails', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first bytes', 'partial-first');
const second = await committedArtifact(t, 'second.html', '<h1>second</h1>', 'partial-second');
const order = [];
const uploads = [];
const active = [];
const status = {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
artifactsSent: 0,
artifactSendErrors: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
const client = {
replyStream: async (_source, _streamId, content, finish) => {
if (finish) order.push(`text:${content}`);
},
replyStreamNonBlocking: async () => {},
sendMessage: async (chatId, body) => {
const content = body.markdown.content;
active.push({ chatId, content });
order.push(`notice:${content}`);
return { body: { msgid: `notice-${active.length}` } };
},
uploadMedia: async (bytes, options) => {
uploads.push({ bytes: Buffer.from(bytes), options });
order.push(`upload:${options.filename}`);
return { media_id: `media-${options.filename}` };
},
sendMediaMessage: async (chatId, type, mediaId) => {
order.push(`file:${mediaId}`);
if (mediaId === 'media-first.txt') {
const error = new Error('private provider detail');
error.code = 'artifact-provider-failed';
throw error;
}
return { body: { msgid: 'wecom-file-2' }, chatId, type };
},
};
const bridge = new WecomHarnessBridge({
client,
generateStreamId: () => 'artifact-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first);
await options.onArtifact(second);
return '文件处理完成。';
},
},
state: state(),
status,
logger: { warn() {}, error() {} },
});
await bridge.accept(frame({ msgid: 'wecom-artifact-partial' }));
assert.deepEqual(order.map((entry) => entry.split(':', 1)[0]), [
'text', 'upload', 'file', 'notice', 'upload', 'file',
]);
assert.deepEqual(uploads.map(({ options }) => options), [
{ type: 'file', filename: 'first.txt' },
{ type: 'file', filename: 'second.html' },
]);
assert.equal(uploads[0].bytes.toString(), 'first bytes');
assert.equal(uploads[1].bytes.toString(), '<h1>second</h1>');
assert.equal(active.length, 1);
assert.match(active[0].content, /first\.txt.*暂时未能/);
assert.doesNotMatch(active[0].content, /private provider detail/);
assert.equal(status.artifactsSent, 1);
assert.equal(status.artifactSendErrors, 1);
});
test('Enterprise WeChat still delivers registered files when final text delivery fails', async (t) => {
const artifact = await committedArtifact(
t,
'survives-text-failure.txt',
'file bytes',
'text-failure',
);
const files = [];
let finalTextAttempts = 0;
let activeTextAttempts = 0;
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_source, _streamId, _content, finish) => {
if (finish) {
finalTextAttempts += 1;
throw new Error('stream finalization unavailable');
}
},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {
activeTextAttempts += 1;
throw new Error('active text unavailable');
},
uploadMedia: async () => ({ media_id: 'media-after-text-failure' }),
sendMediaMessage: async (chatId, type, mediaId) => {
files.push({ chatId, type, mediaId });
return { body: { msgid: 'wecom-file-after-text-failure' } };
},
},
generateStreamId: () => 'text-failure-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: state(),
logger: { warn() {}, error() {} },
});
await bridge.accept(frame({ msgid: 'wecom-text-failure' }));
assert.deepEqual(files, [{
chatId: 'member-1',
type: 'file',
mediaId: 'media-after-text-failure',
}]);
assert.equal(finalTextAttempts, 1, 'must not append a generic retry stream after file success');
assert.equal(activeTextAttempts, 1);
});
test('Enterprise WeChat returns the authoritative receipt and one safe notice when text and file delivery fail', async (t) => {
const artifact = await committedArtifact(t, 'mismatch.txt', 'file bytes', 'all-fail');
const attemptedActiveTexts = [];
const visibleActiveTexts = [];
const finalStreamTexts = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_source, _streamId, content, finish) => {
if (finish) {
finalStreamTexts.push(content);
throw new Error('stream finalization unavailable');
}
},
replyStreamNonBlocking: async () => {},
sendMessage: async (_chatId, body) => {
const text = body.markdown.content;
attemptedActiveTexts.push(text);
if (text === '文字结果') throw new Error('active text unavailable');
visibleActiveTexts.push(text);
return {};
},
uploadMedia: async () => {
const error = new Error('mismatched file signature');
error.code = 'artifact-invalid';
throw error;
},
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
},
generateStreamId: () => 'all-fail-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: state(),
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(frame({ msgid: 'wecom-all-fail' }));
assert.deepEqual(finalStreamTexts, ['文字结果']);
assert.equal(attemptedActiveTexts.length, 2, 'must not append a generic error after the safe notice');
assert.equal(visibleActiveTexts.length, 1);
assert.match(visibleActiveTexts[0], /暂时无法读取或准备发送.*仍可访问/);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'wecom-all-fail',
presentation: 'wecom-files',
providerMessageIds: [],
artifacts: [{
artifactId: artifact.artifactId,
outcome: 'rejected',
reason: 'artifact-invalid',
}],
});
});
test('Enterprise WeChat keeps the generic error when no answer or file failure notice is visible', async (t) => {
const artifact = await committedArtifact(t, 'unavailable.txt', 'file bytes', 'no-visible-failure');
const attemptedActiveTexts = [];
const finalStreamTexts = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_source, _streamId, content, finish) => {
if (!finish) return;
finalStreamTexts.push(content);
if (finalStreamTexts.length === 1) throw new Error('stream finalization unavailable');
},
replyStreamNonBlocking: async () => {},
sendMessage: async (_chatId, body) => {
attemptedActiveTexts.push(body.markdown.content);
throw new Error('active text unavailable');
},
uploadMedia: async () => {
const error = new Error('file transport unavailable');
error.code = 'artifact-provider-failed';
throw error;
},
sendMediaMessage: async () => assert.fail('a rejected upload must not be sent'),
},
generateStreamId: () => 'no-visible-failure-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '文字结果';
},
},
state: state(),
logger: { warn() {}, error() {} },
});
await bridge.accept(frame({ msgid: 'wecom-no-visible-failure' }));
assert.equal(attemptedActiveTexts.length, 2);
assert.deepEqual(finalStreamTexts, ['文字结果', '消息处理失败,请稍后重试。']);
});
test('Enterprise WeChat reports an unacknowledged file message as uncertain', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.txt', 'file bytes', 'uncertain');
const active = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
uploadMedia: async () => ({ media_id: 'media-uncertain' }),
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() {} },
});
await bridge.accept(frame({ msgid: 'wecom-uncertain-file' }));
assert.match(active[0], /发送结果未能确认.*先检查聊天内是否已收到.*不要立即重试/);
});
test('Enterprise WeChat cancellation interrupts an in-flight file send and skips later files', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first', 'abort-first');
const second = await committedArtifact(t, 'second.txt', 'second', 'abort-second');
const started = deferred();
const controller = new AbortController();
const uploads = [];
const active = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async (_chatId, body) => active.push(body.markdown.content),
uploadMedia: async (_bytes, options) => {
uploads.push(options.filename);
return { media_id: `media-${options.filename}` };
},
sendMediaMessage: async () => {
started.resolve();
return new Promise(() => {});
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first);
await options.onArtifact(second);
return '文件如下。';
},
},
state: state(),
signal: controller.signal,
logger: { warn() {}, error() {} },
});
const processing = bridge.accept(frame({ msgid: 'wecom-abort-file' }));
await started.promise;
controller.abort(new DOMException('runtime stopped', 'AbortError'));
await Promise.race([
processing,
new Promise((_, reject) => setTimeout(() => reject(new Error('WeCom abort timed out')), 500)),
]);
assert.deepEqual(uploads, ['first.txt']);
assert.equal(active.some((text) => text.includes('发送结果未能确认')), false);
assert.equal(active.some((text) => text === '消息处理失败,请稍后重试。'), false);
});
test('Enterprise WeChat uses a neutral final text for a file-only Turn', async (t) => {
const artifact = await committedArtifact(t, 'only.txt', 'only bytes', 'file-only');
const finalTexts = [];
const files = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_source, _streamId, content, finish) => {
if (finish) finalTexts.push(content);
},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
uploadMedia: async () => ({ media_id: 'media-only' }),
sendMediaMessage: async (chatId, type, mediaId) => {
files.push({ chatId, type, mediaId });
return {};
},
},
generateStreamId: () => 'file-only-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: state(),
});
await bridge.accept(frame({ msgid: 'wecom-file-only' }));
assert.deepEqual(finalTexts, ['结果文件已生成。']);
assert.deepEqual(files, [{ chatId: 'member-1', type: 'file', mediaId: 'media-only' }]);
});
test('Enterprise WeChat cancellation prevents SDK upload', async (t) => {
let uploads = 0;
const artifact = await committedArtifact(t, 'cancelled.txt', 'cancelled bytes', 'cancelled');
const controller = new AbortController();
const cancelledBridge = new WecomHarnessBridge({
client: {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
uploadMedia: async () => { uploads += 1; return { media_id: 'must-not-upload' }; },
sendMediaMessage: async () => {},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
controller.abort(new DOMException('stopped', 'AbortError'));
return '停止前的回答';
},
},
state: state(),
signal: controller.signal,
});
await cancelledBridge.accept(frame({ msgid: 'wecom-artifact-cancelled' }));
assert.equal(uploads, 0);
});

View file

@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/wecom/production.mjs';
test('Enterprise WeChat production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-wecom-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) {
this.options = options;
runtimes.push(options);
}
}
class Controller {
constructor(options) {
controllerOptions = options;
}
async close() {}
}
class QrAuth {}
const supervisor = {
ready: Promise.resolve(),
start() { return this; },
async close() {},
};
const production = await createProductionController({
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir }, {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
QrAuth,
createConnectionSupervisor: () => supervisor,
});
await controllerOptions.createRuntime({
botId: 'wecom_enabled',
config: { botId: 'wecom_enabled', remoteBotId: 'remote-enabled' },
secret: 'host-only',
});
await controllerOptions.createRuntime({
botId: 'wecom_disabled',
config: { botId: 'wecom_disabled', remoteBotId: 'remote-disabled' },
secret: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
});

View file

@ -157,3 +157,45 @@ test('Enterprise WeChat runtime aborts an in-flight Harness interaction when sto
await runtime.stop();
assert.equal(askSignal.aborted, true);
});
test('Enterprise WeChat runtime enables result-file delivery without per-bot configuration', async () => {
const client = new FakeClient();
const finished = deferred();
let onArtifact;
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
onArtifact = options.onArtifact;
return '完成';
},
},
state: {
hasSeen: () => false,
markSeen: async () => finished.resolve(),
sessionFor: () => 'session-existing',
setSession: async () => {},
clearSession: async () => {},
},
createClient: () => client,
connectTimeoutMs: 100,
});
await runtime.start();
client.emit('message', {
headers: { req_id: 'req-artifact-gate' },
body: {
msgid: 'msg-artifact-gate',
chattype: 'single',
from: { userid: 'member-1' },
msgtype: 'text',
text: { content: '生成文件' },
},
});
await finished.promise;
assert.equal(typeof onArtifact, 'function');
await runtime.stop();
});

View file

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/weixin/production.mjs';
test('Weixin production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-weixin-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const createConnectionSupervisor = () => ({
ready: Promise.resolve(),
start() { return this; },
async close() {},
});
const internals = {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
api: {},
createConnectionSupervisor,
};
const ctx = {
credentials: {},
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};
const production = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'wx_enabled',
config: { botId: 'wx_enabled' },
token: 'host-only',
});
await controllerOptions.createRuntime({
botId: 'wx_disabled',
config: { botId: 'wx_disabled' },
token: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'wx_default',
config: { botId: 'wx_default' },
token: 'host-only',
});
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
await productionWithDefault.close();
});

View file

@ -171,6 +171,243 @@ test('sendText emits the iLink message envelope without reflecting the token in
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
});
test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file 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=one',
});
}
if (url.pathname === '/c2c/upload') {
return new Response(null, {
status: 200,
headers: { 'x-encrypted-param': 'download-ticket' },
});
}
return jsonResponse({ ret: 0 });
},
});
const plaintext = Buffer.from('weixin-native-file');
const result = await api.sendFile({
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'host-only-token',
toUserId: 'wx-user',
contextToken: 'message-context',
runId: 'run-1',
file: {
artifactId: 'artifact-one',
deliveryKey: 'session:turn:artifact-one',
fileName: 'result.txt',
mediaType: 'text/plain',
bytes: plaintext,
},
});
assert.equal(calls.length, 3);
const ticket = JSON.parse(calls[0].init.body);
assert.equal(ticket.media_type, 3);
assert.equal(ticket.to_user_id, 'wx-user');
assert.equal(ticket.rawsize, plaintext.length);
assert.equal(ticket.rawfilemd5, 'ae8e1f4207c0468828419884f7329cb8');
assert.equal(ticket.filesize, 32);
assert.equal(ticket.no_need_thumb, true);
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
assert.equal(calls[1].init.headers.Authorization, undefined);
assert.equal(calls[1].init.headers['content-type'], 'application/octet-stream');
assert.equal(calls[1].init.body.byteLength, 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-1');
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
assert.equal(result.messageId, sent.client_id);
assert.deepEqual(sent.item_list, [{
type: 4,
file_item: {
media: {
encrypt_query_param: 'download-ticket',
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
encrypt_type: 1,
},
file_name: 'result.txt',
len: String(plaintext.length),
},
}]);
});
function weixinFileRequest(overrides = {}) {
return {
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'host-only-token',
toUserId: 'wx-user',
file: {
artifactId: 'artifact-error-case',
deliveryKey: 'session:turn:artifact-error-case',
fileName: 'result.txt',
mediaType: 'text/plain',
bytes: Buffer.from('weixin-error-case'),
},
...overrides,
};
}
function weixinFileFetch(finalResponse) {
return async (url, init) => {
if (url.pathname.endsWith('/getuploadurl')) {
return jsonResponse({
ret: 0,
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=error-case',
});
}
if (url.pathname === '/c2c/upload') {
return new Response(null, {
status: 200,
headers: { 'x-encrypted-param': 'download-error-case' },
});
}
return finalResponse(url, init);
};
}
test('sendFile marks every ambiguous sendmessage result as uncertain', async (t) => {
const cases = [
{
name: 'network failure',
finalResponse: async () => { throw new TypeError('private socket detail'); },
},
{
name: 'timeout',
finalResponse: async () => {
throw new WeixinApiError('timeout', 'private timeout detail');
},
},
{
name: 'invalid JSON',
finalResponse: async () => new Response('{not-json', { status: 200 }),
},
{
name: 'HTTP 5xx',
finalResponse: async () => jsonResponse({ ret: -1 }, { status: 503 }),
},
];
for (const scenario of cases) {
await t.test(scenario.name, async () => {
const api = createWeixinApi({
fetchImpl: weixinFileFetch(scenario.finalResponse),
});
await assert.rejects(
api.sendFile(weixinFileRequest()),
(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' },
{ name: 'too large', status: 413, code: 'artifact-too-large' },
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
];
for (const scenario of cases) {
await t.test(scenario.name, async () => {
const api = createWeixinApi({
fetchImpl: weixinFileFetch(async () => jsonResponse(
{ ret: scenario.status },
{ status: scenario.status },
)),
});
await assert.rejects(
api.sendFile(weixinFileRequest()),
(error) => error.code === scenario.code,
);
});
}
const permissionApi = createWeixinApi({
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: 403 })),
});
await assert.rejects(
permissionApi.sendFile(weixinFileRequest()),
(error) => error.code === 'artifact-permission-required'
&& error.providerCode === '403',
);
const api = createWeixinApi({
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: -2001 })),
});
await assert.rejects(
api.sendFile(weixinFileRequest()),
(error) => error.code === 'artifact-provider-rejected'
&& error.providerCode === '-2001',
);
});
test('sendFile preserves caller abort and never marks a pre-send preparation failure uncertain', async () => {
let preparationCalls = 0;
const preparationApi = createWeixinApi({
fetchImpl: async () => {
preparationCalls += 1;
throw new TypeError('private upload preparation failure');
},
});
await assert.rejects(
preparationApi.sendFile(weixinFileRequest()),
(error) => error.code === 'artifact-provider-failed'
&& error.code !== 'artifact-delivery-uncertain',
);
assert.equal(preparationCalls, 1);
const controller = new AbortController();
const reason = new Error('caller stopped the turn');
const abortApi = createWeixinApi({
fetchImpl: weixinFileFetch(async () => {
controller.abort(reason);
throw new DOMException('Aborted', 'AbortError');
}),
});
await assert.rejects(
abortApi.sendFile(weixinFileRequest({ signal: controller.signal })),
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
);
});
test('sendFile rejects a provider upload URL outside the canonical Weixin CDN', async () => {
let calls = 0;
const api = createWeixinApi({
fetchImpl: async () => {
calls += 1;
return jsonResponse({
ret: 0,
upload_full_url: 'https://attacker.example/c2c/upload?ticket=one',
});
},
});
await assert.rejects(
api.sendFile({
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'host-only-token',
toUserId: 'wx-user',
file: { fileName: 'result.txt', bytes: Buffer.from('safe') },
}),
(error) => error instanceof WeixinApiError && error.code === 'untrusted-upload-url',
);
assert.equal(calls, 1);
});
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
const api = createWeixinApi({
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {

View file

@ -1,8 +1,17 @@
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 { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-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;
@ -69,6 +78,36 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
async function committedArtifact(t, fileName, content) {
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
t.after(() => rm(workspace, { recursive: true, force: true }));
const registry = new OutboundArtifactRegistry({ uuid: () => 'weixin-artifact-one' });
t.after(() => registry.clear());
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), content);
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: 'weixin-artifact-call',
rootCallId: 'weixin-artifact-call',
token: Symbol('weixin-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('Weixin remembers any authorized private inbound as a connection-test target', async () => {
const fixture = stateFixture();
const sent = [];
@ -98,6 +137,144 @@ test('Weixin remembers any authorized private inbound as a connection-test targe
assert.equal(connectionTestTarget(rejectedFixture.state), null);
});
test('Weixin returns a registered result file with native context after its existing text path', async (t) => {
const artifact = await committedArtifact(t, 'result.txt', 'weixin-result');
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-artifact');
const order = [];
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
sendText: async ({ text }) => {
order.push(`text:${text}`);
return { messageId: 'weixin-text-one' };
},
sendFile: async (request) => {
order.push(`file:${request.file.fileName}`);
assert.equal(request.toUserId, 'owner-user');
assert.equal(request.contextToken, 'context-weixin-artifact');
assert.equal(request.runId, 'run-artifact');
assert.equal(request.file.bytes.toString(), 'weixin-result');
return { messageId: 'weixin-file-one' };
},
},
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,
});
const receipt = await bridge.accept(message(
'weixin-artifact',
'生成文件',
{ run_id: 'run-artifact' },
));
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.txt']);
assert.equal(bridge.status.artifactsSent, 1);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'weixin-artifact',
presentation: 'weixin-text-and-files',
providerMessageIds: ['weixin-text-one', 'weixin-file-one'],
artifacts: [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }],
});
});
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();
fixture.sessions.set('p2p:owner-user', 'session-artifact-text-failed');
const files = [];
let textAttempts = 0;
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
sendText: async () => {
textAttempts += 1;
throw new Error('private text failure');
},
sendFile: async ({ file }) => {
files.push(file.fileName);
return { messageId: 'weixin-file-after-text-failure' };
},
},
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,
});
const receipt = await bridge.accept(message('weixin-artifact-text-failed', '生成文件'));
assert.deepEqual(files, ['weixin-text-failed.txt']);
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, ['weixin-file-after-text-failure']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
});
test('Weixin tells users to inspect the chat instead of retrying an uncertain file delivery', async (t) => {
const artifact = await committedArtifact(t, 'weixin-uncertain.txt', 'weixin-file');
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-artifact-uncertain');
const sent = [];
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
sendText: async ({ text }) => {
sent.push(text);
return { messageId: `weixin-text-${sent.length}` };
},
sendFile: async () => {
const error = new Error('private provider transport detail');
error.code = 'artifact-delivery-uncertain';
throw error;
},
},
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-artifact-uncertain', '生成文件'));
assert.equal(
sent.at(-1),
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
);
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
assert.equal(bridge.status.artifactSendErrors, 1);
assert.deepEqual(receipt.artifacts, [{
artifactId: 'weixin-artifact-one',
outcome: 'unknown',
reason: 'artifact-delivery-uncertain',
}]);
});
test('Weixin sends image-only messages to Harness as structured content', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');

View file

@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/whatsapp/production.mjs';
test('WhatsApp production has no per-bot result-file Gate', async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-whatsapp-production-artifacts-'));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const runtimes = [];
let controllerOptions;
class ConfigStore {
async load() { return this; }
list() { return []; }
}
class StateStore {
async load() { return this; }
}
class Harness {
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
async close() {}
}
const createConnectionSupervisor = () => ({
ready: Promise.resolve(),
start() { return this; },
async close() {},
});
const internals = {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createSession: async () => ({}),
createConnectionSupervisor,
};
const ctx = {
webServer: { port: 3080 },
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
};
const production = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'whatsapp_enabled',
config: { botId: 'whatsapp_enabled' },
authDir: '00000000-0000-4000-8000-000000000001',
});
await controllerOptions.createRuntime({
botId: 'whatsapp_disabled',
config: { botId: 'whatsapp_disabled' },
authDir: '00000000-0000-4000-8000-000000000002',
});
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'whatsapp_default',
config: { botId: 'whatsapp_default' },
authDir: '00000000-0000-4000-8000-000000000003',
});
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
await productionWithDefault.close();
});

View file

@ -1,12 +1,23 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { mkdtemp, stat } from 'node:fs/promises';
import {
mkdtemp,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { DisconnectReason } from '@whiskeysockets/baileys';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
WhatsappConfigStore,
deriveWhatsappBotId,
@ -25,6 +36,87 @@ import {
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function within(promise, timeoutMs, message) {
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function committedArtifact(t, {
suffix,
fileName = 'result.txt',
content = 'WhatsApp result file',
} = {}) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-whatsapp-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
const sessionId = `session-whatsapp-artifact-${suffix}`;
const rpcId = `rpc-whatsapp-artifact-${suffix}`;
let nextId = 0;
const ids = [];
const registry = new OutboundArtifactRegistry({
uuid: () => {
const id = `${suffix}-${++nextId}`;
ids.push(id);
return id;
},
});
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 } } },
],
},
};
await writeFile(join(workspace, fileName), content);
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await tool.definition.execute({ path: fileName }, exec);
tool.onResult(exec, { isError: false });
return {
artifact: registry.take(sessionId, 1)[0],
deliveryKey: ids[1],
};
}
function artifactState(sessionId = 'session-whatsapp-artifact') {
const seen = new Set();
return {
hasSeen: (messageId) => seen.has(messageId),
markSeen: async (messageId) => { seen.add(messageId); },
sessionFor: () => sessionId,
sessionExists: async () => true,
setSession: async () => {},
clearSession: async () => {},
};
}
function linkedConfig(overrides = {}) {
return {
botId: deriveWhatsappBotId(ACCOUNT_JID),
@ -376,6 +468,265 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
await runtime.stop();
});
test('WhatsApp runtime sends result files with native metadata, quote, stable id, and upload timeout', async (t) => {
const { artifact, deliveryKey } = await committedArtifact(t, {
suffix: 'native-file',
fileName: 'report.txt',
content: 'native WhatsApp artifact',
});
let callbacks;
const calls = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content, options) => {
calls.push({ jid, content, options });
return { key: { id: content.document ? 'file-message-1' : 'text-message-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-native-file',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
assert.equal(typeof options.onArtifact, 'function');
await options.onArtifact(artifact);
return '结果文件如下。';
},
},
state: artifactState(),
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
const inbound = {
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'native-file-1', fromMe: false },
message: { conversation: '生成结果文件' },
};
await callbacks.onMessage(inbound);
const textCall = calls.find((call) => call.content.text === '结果文件如下。');
const fileCall = calls.find((call) => call.content.document);
assert.ok(textCall);
assert.ok(fileCall);
assert.equal(fileCall.jid, '16505550999@s.whatsapp.net');
assert.equal(fileCall.content.document.toString(), 'native WhatsApp artifact');
assert.equal(fileCall.content.mimetype, 'text/plain');
assert.equal(fileCall.content.fileName, 'report.txt');
assert.equal(fileCall.options.quoted, inbound);
assert.equal(fileCall.options.mediaUploadTimeoutMs, 120_000);
assert.equal(
fileCall.options.messageId,
createHash('sha256').update(deliveryKey).digest('hex').slice(0, 20).toUpperCase(),
);
assert.equal(fileCall.options.messageId.length, 20);
assert.equal(calls.indexOf(textCall) < calls.indexOf(fileCall), true);
});
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',
fileName: 'self-report.txt',
content: 'self chat artifact',
});
let callbacks;
let echoTask;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content, options = {}) => {
sent.push({ jid, content, options });
if (content.document) {
echoTask = callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: options.messageId, fromMe: true },
message: {
documentMessage: {
fileName: content.fileName,
mimetype: content.mimetype,
},
},
});
return { key: { id: options.messageId } };
}
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-early-self-file-echo',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
askCount += 1;
await options.onArtifact(artifact);
return '结果文件如下。';
},
},
state: artifactState('session-whatsapp-early-self-file-echo'),
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'self-file-request-1', fromMe: true },
message: { conversation: '生成结果文件' },
});
await echoTask;
assert.equal(askCount, 1);
assert.equal(sent.filter(({ content }) => content.document).length, 1);
assert.equal(sent.some(({ content }) => content.text === '目前支持文字和图片消息。'), false);
});
test('WhatsApp runtime treats a lost file-send response as uncertain delivery', async (t) => {
const { artifact } = await committedArtifact(t, {
suffix: 'uncertain-file',
fileName: 'uncertain.txt',
});
let callbacks;
const warnings = [];
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (_jid, content) => {
sent.push(content);
if (content.document) return new Promise(() => {});
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-uncertain-file',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '已生成文件。';
},
},
state: artifactState('session-whatsapp-uncertain'),
logger: {
warn: (...args) => warnings.push(args.join(' ')),
error() {},
},
mediaUploadTimeoutMs: 20,
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
t.after(() => runtime.stop());
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'uncertain-file-1', fromMe: false },
message: { conversation: '生成文件' },
});
assert.equal(sent.filter((content) => content.document).length, 1);
assert.equal(warnings.some((warning) => warning.includes('artifact-delivery-uncertain')), true);
assert.equal(sent.some((content) => content.text?.includes('发送结果未能确认')), true);
});
test('stopping WhatsApp aborts a pending upload without another file or failure notice', async (t) => {
const first = await committedArtifact(t, {
suffix: 'cancel-first',
fileName: 'first.txt',
});
const second = await committedArtifact(t, {
suffix: 'cancel-second',
fileName: 'second.txt',
});
let callbacks;
const uploadStarted = deferred();
const uploadResponse = deferred();
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (_jid, content) => {
sent.push(content);
if (content.document) {
uploadStarted.resolve();
return uploadResponse.promise;
}
return { key: { id: `text-${sent.length}` } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-cancel-upload',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(first.artifact);
await options.onArtifact(second.artifact);
return '结果文件如下。';
},
},
state: artifactState('session-whatsapp-cancel'),
logger: { warn() {}, error() {} },
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
const processing = callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancel-upload-1', fromMe: false },
message: { conversation: '生成两个文件' },
});
await uploadStarted.promise;
await within(runtime.stop(), 500, 'WhatsApp runtime did not stop a pending upload promptly');
await within(processing, 500, 'WhatsApp message processing remained blocked after stop');
uploadResponse.resolve({ key: { id: 'late-provider-response' } });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
sent.filter((content) => content.document).map((content) => content.fileName),
['first.txt'],
);
assert.equal(sent.some((content) => content.text?.includes('暂时未能发送')), false);
assert.equal(sent.some((content) => content.text === '消息处理失败,请稍后重试。'), false);
});
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
let callbacks;
let askCount = 0;

View file

@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
artifactOutcomeForError,
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../src/channels/shared/semantic/delivery.mjs';
test('DeliveryReceipt validates and freezes the shared versioned contract', () => {
const receipt = createDeliveryReceipt({
deliveryId: 'delivery-1',
presentation: 'feishu-file',
providerMessageIds: ['om-file', 'om-file'],
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
});
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'delivery-1',
presentation: 'feishu-file',
providerMessageIds: ['om-file'],
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
});
assert.equal(Object.isFrozen(receipt), true);
assert.equal(Object.isFrozen(receipt.providerMessageIds), true);
assert.equal(Object.isFrozen(receipt.artifacts[0]), true);
assert.throws(
() => createDeliveryReceipt({
deliveryId: 'delivery-invalid',
presentation: 'feishu-file',
artifacts: [{ artifactId: 'artifact-1', outcome: 'maybe' }],
}),
/artifact outcome/,
);
});
test('artifact failures distinguish policy rejection, retryable failure, and uncertain delivery', () => {
assert.equal(artifactOutcomeForError({ code: 'artifact-permission-required' }), 'rejected');
assert.equal(artifactOutcomeForError({ code: 'artifact-too-large' }), 'rejected');
assert.equal(artifactOutcomeForError({ code: 'artifact-unavailable' }), 'rejected');
assert.equal(artifactOutcomeForError({ code: 'artifact-rate-limited' }), 'failed');
assert.equal(artifactOutcomeForError(new Error('network unavailable')), 'failed');
assert.equal(artifactOutcomeForError({ code: 'artifact-delivery-uncertain' }), 'unknown');
assert.deepEqual(createArtifactFailureReceipt({
artifactId: 'artifact-uncertain',
deliveryId: 'delivery-uncertain',
error: { code: 'artifact-delivery-uncertain' },
}).artifacts, [{
artifactId: 'artifact-uncertain',
outcome: 'unknown',
reason: 'artifact-delivery-uncertain',
}]);
});
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({
providerMessageIds: ['first', 'second', 'first', '', null],
}), ['first', 'second']);
assert.deepEqual(providerMessageIdsFor({ processQueryKey: 'not-a-message-id' }), []);
assert.deepEqual(providerMessageIdsFor({ files: [{ id: 'not-a-message-id' }] }), []);
});
test('text and multiple artifact attempts merge into one authoritative receipt', () => {
const text = createDeliveryReceipt({
deliveryId: 'turn-message',
presentation: 'feishu-cardkit',
providerMessageIds: ['om-card'],
});
const sent = createDeliveryReceipt({
deliveryId: 'file-1',
presentation: 'feishu-file',
providerMessageIds: ['om-file'],
artifacts: [{ artifactId: 'artifact-1', outcome: 'sent' }],
});
const failed = createArtifactFailureReceipt({
artifactId: 'artifact-2',
deliveryId: 'file-2',
error: { code: 'artifact-rate-limited' },
providerMessageIds: ['om-notice'],
});
assert.deepEqual(mergeDeliveryReceipts({
deliveryId: 'turn-message',
presentation: 'feishu-text-and-files',
receipts: [text, sent, failed],
}), {
schemaVersion: 1,
deliveryId: 'turn-message',
presentation: 'feishu-text-and-files',
providerMessageIds: ['om-card', 'om-file', 'om-notice'],
artifacts: [
{ artifactId: 'artifact-1', outcome: 'sent' },
{ artifactId: 'artifact-2', outcome: 'failed', reason: 'artifact-rate-limited' },
],
});
});

View file

@ -35,7 +35,12 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
await plugin.apply(ctx, config);
assert.equal(name, 'dsh-im-host');
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
assert.deepEqual(inject, [
'connection',
'credentials',
'webServer',
'typertGateway',
]);
assert.deepEqual(calls, [
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],

View file

@ -0,0 +1,391 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, symlink, 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,
installOutboundArtifactTool,
materializeOutboundArtifact,
readExactArtifactFile,
releaseOutboundArtifact,
} from '../src/channels/shared/semantic/artifact.mjs';
async function fixture(t) {
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-workspace-'));
const outside = await mkdtemp(join(tmpdir(), 'dsh-im-artifact-outside-'));
t.after(async () => {
await rm(workspace, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
let nextId = 0;
const registry = new OutboundArtifactRegistry({
uuid: () => `artifact-id-${++nextId}`,
});
t.after(() => registry.clear());
const agent = {
session: {
header: { id: 'session-artifact', cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 7 } },
{ type: 'user/message', data: { turn: 7, source: { rpcId: 'rpc-artifact' } } },
],
},
};
return { workspace, outside, registry, agent };
}
function execution(agent, callId, overrides = {}) {
return {
name: OUTBOUND_ARTIFACT_TOOL,
agent,
callId,
rootCallId: callId,
token: Symbol(callId),
...overrides,
};
}
async function execute(tool, args, exec, result = { isError: false }) {
const value = await tool.definition.execute(args, exec);
tool.onResult(exec, result);
return value;
}
async function takeFile(registry, sessionId = 'session-artifact', turn = 7) {
const [artifact] = registry.take(sessionId, turn);
assert.ok(artifact);
const file = await materializeOutboundArtifact(artifact);
return { artifact, file };
}
test('an existing file can be sent directly without recreation', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'existing.txt'), 'already here');
const tool = createOutboundArtifactTool({ registry: fx.registry });
const result = await execute(tool, { path: 'existing.txt' }, execution(fx.agent, 'existing'));
assert.deepEqual(result, {
artifactId: 'artifact-id-1',
fileName: 'existing.txt',
size: 12,
});
const { artifact, file } = await takeFile(fx.registry);
assert.equal(file.bytes.toString(), 'already here');
releaseOutboundArtifact(artifact);
});
test('absolute outside-workspace paths and symbolic links are delivered normally', async (t) => {
const fx = await fixture(t);
const outsidePath = join(fx.outside, 'outside.txt');
await writeFile(outsidePath, 'outside content');
await symlink(outsidePath, join(fx.workspace, 'linked.txt'));
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: outsidePath }, execution(fx.agent, 'outside'));
await execute(tool, { path: 'linked.txt' }, execution(fx.agent, 'linked'));
const artifacts = fx.registry.take('session-artifact', 7);
assert.equal(artifacts.length, 2);
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
assert.deepEqual(files.map((file) => file.fileName), ['outside.txt', 'linked.txt']);
assert.deepEqual(files.map((file) => file.bytes.toString()), ['outside content', 'outside content']);
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
});
test('empty, sensitive-looking, and extension-mismatched files are not filtered', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'empty.txt'), '');
await writeFile(
join(fx.workspace, '.env'),
'PASSWORD=example\n-----BEGIN PRIVATE KEY-----\nexample\n',
);
await writeFile(join(fx.workspace, 'plain.png'), 'not a PNG signature');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: 'empty.txt' }, execution(fx.agent, 'empty'));
await execute(tool, { path: '.env' }, execution(fx.agent, 'sensitive-looking'));
await execute(tool, { path: 'plain.png' }, execution(fx.agent, 'extension-mismatch'));
const artifacts = fx.registry.take('session-artifact', 7);
assert.equal(artifacts.length, 3);
const files = await Promise.all(artifacts.map((artifact) => materializeOutboundArtifact(artifact)));
assert.deepEqual(files.map((file) => file.size), [0, 53, 19]);
assert.equal(files[1].bytes.toString().startsWith('PASSWORD='), true);
assert.equal(files[2].bytes.toString(), 'not a PNG signature');
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
});
test('the registry does not deduplicate or impose project-level file-count quotas', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'same.txt'), 'same');
const tool = createOutboundArtifactTool({ registry: fx.registry });
for (let index = 0; index < 12; index += 1) {
await execute(tool, { path: 'same.txt' }, execution(fx.agent, `same-${index}`));
}
const artifacts = fx.registry.take('session-artifact', 7);
assert.equal(artifacts.length, 12);
assert.equal(new Set(artifacts.map((artifact) => artifact.artifactId)).size, 12);
for (const artifact of artifacts) releaseOutboundArtifact(artifact);
});
test('registration keeps a private snapshot without changing or deleting the source file', async (t) => {
const fx = await fixture(t);
const path = join(fx.workspace, 'mutable.txt');
await writeFile(path, 'first value');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path }, execution(fx.agent, 'snapshot'));
await writeFile(path, 'second value');
const { artifact, file } = await takeFile(fx.registry);
assert.equal(file.bytes.toString(), 'first value');
releaseOutboundArtifact(artifact);
assert.equal(await readFile(path, 'utf8'), 'second value');
});
test('missing paths and directories fail because they cannot be uploaded as files', async (t) => {
const fx = await fixture(t);
const tool = createOutboundArtifactTool({ registry: fx.registry });
await assert.rejects(
tool.definition.execute({ path: 'missing.txt' }, execution(fx.agent, 'missing')),
(error) => error.code === 'artifact-unavailable',
);
await assert.rejects(
tool.definition.execute({ path: '.' }, execution(fx.agent, 'directory')),
(error) => error.code === 'artifact-not-file',
);
});
test('a failed authoritative tool result is not delivered', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'failed.txt'), 'failed');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(
tool,
{ path: 'failed.txt' },
execution(fx.agent, 'failed'),
{ isError: true },
);
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
});
test('Code Mode waits for the outer execution result before delivery', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'nested.txt'), 'nested');
const tool = createOutboundArtifactTool({ registry: fx.registry });
const rootToken = Symbol('root');
const nested = execution(fx.agent, 'nested', {
parent: rootToken,
rootCallId: 'root-call',
});
await execute(tool, { path: 'nested.txt' }, nested);
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
tool.onResult({
name: 'run_code',
callId: 'root-call',
rootCallId: 'root-call',
token: rootToken,
agent: fx.agent,
}, { isError: false });
const { artifact, file } = await takeFile(fx.registry);
assert.equal(file.bytes.toString(), 'nested');
releaseOutboundArtifact(artifact);
});
test('Session and Turn ownership routes files only to the originating conversation', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'owned.txt'), 'owned');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: 'owned.txt' }, execution(fx.agent, 'owned'));
assert.deepEqual(fx.registry.take('other-session', 7), []);
assert.deepEqual(fx.registry.take('session-artifact', 8), []);
const [artifact] = fx.registry.take('session-artifact', 7);
assert.ok(artifact);
releaseOutboundArtifact(artifact);
});
test('a completed Turn without a channel consumer releases its unclaimed snapshot', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'unclaimed.txt'), 'unclaimed');
const artifact = await fx.registry.stage(
{ path: 'unclaimed.txt' },
execution(fx.agent, 'unclaimed'),
);
fx.registry.commit(artifact);
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'turn/end', data: { turn: 7 } },
);
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error.code === 'artifact-invalid',
);
});
test('a channel consumer keeps its completed Turn available until polling claims it', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'consumer.txt'), 'consumer');
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'turn/start', data: { turn: 7 } },
);
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
);
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: 'consumer.txt' }, execution(fx.agent, 'consumer'));
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'turn/end', data: { turn: 7 } },
);
const [artifact] = fx.registry.take('session-artifact', 7);
assert.ok(artifact);
closeConsumer();
const file = await materializeOutboundArtifact(artifact);
assert.equal(file.bytes.toString(), 'consumer');
releaseOutboundArtifact(artifact);
});
test('closing an unclaimed channel consumer releases its completed Turn', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'consumer-closed.txt'), 'consumer closed');
const closeConsumer = fx.registry.openConsumer('session-artifact', 'rpc-artifact');
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'turn/start', data: { turn: 7 } },
);
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'user/message', data: { source: { rpcId: 'rpc-artifact' } } },
);
const artifact = await fx.registry.stage(
{ path: 'consumer-closed.txt' },
execution(fx.agent, 'consumer-closed'),
);
fx.registry.commit(artifact);
fx.registry.observeSessionEvent(
{ id: 'session-artifact' },
{ type: 'turn/end', data: { turn: 7 } },
);
closeConsumer();
assert.deepEqual(fx.registry.take('session-artifact', 7), []);
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error.code === 'artifact-invalid',
);
});
test('aborting the owning delivery releases its unhanded snapshot', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'abort.txt'), 'abort');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: 'abort.txt' }, execution(fx.agent, 'abort'));
const controller = new AbortController();
const [artifact] = fx.registry.take('session-artifact', 7, { signal: controller.signal });
controller.abort();
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error.code === 'artifact-invalid',
);
});
test('an explicit release also cleans a snapshot that was already claimed', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'handoff-failed.txt'), 'handoff failed');
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(tool, { path: 'handoff-failed.txt' }, execution(fx.agent, 'handoff-failed'));
const [artifact] = fx.registry.take('session-artifact', 7);
fx.registry.release(artifact);
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error.code === 'artifact-invalid',
);
});
test('cleanup requested during materialization runs as soon as the read completes', async (t) => {
const fx = await fixture(t);
await writeFile(join(fx.workspace, 'concurrent-cleanup.txt'), Buffer.alloc(1024 * 1024, 7));
const tool = createOutboundArtifactTool({ registry: fx.registry });
await execute(
tool,
{ path: 'concurrent-cleanup.txt' },
execution(fx.agent, 'concurrent-cleanup'),
);
const [artifact] = fx.registry.take('session-artifact', 7);
const materializing = materializeOutboundArtifact(artifact);
fx.registry.clear();
const file = await materializing;
assert.equal(file.size, 1024 * 1024);
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error.code === 'artifact-invalid',
);
});
test('exact reads have no independent timeout and preserve caller cancellation', async () => {
let read = 0;
const handle = {
async read(_buffer, _offset, _length, position) {
await new Promise((resolve) => setTimeout(resolve, 10));
if (position === 0 && read++ === 0) return { bytesRead: 1 };
return { bytesRead: 0 };
},
};
const bytes = await readExactArtifactFile(handle, 1);
assert.equal(bytes.byteLength, 1);
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await assert.rejects(
readExactArtifactFile(handle, 1, { signal: controller.signal }),
/cancelled/,
);
});
test('Host installer always exposes the tool and explicitly permits existing files', () => {
let definition;
const listeners = new Map();
let section;
const installed = installOutboundArtifactTool({
tools: { register(value) { definition = value; } },
on(name, value) { listeners.set(name, value); },
systemPrompt: { section(value) { section = value; } },
}, { registry: new OutboundArtifactRegistry() });
assert.equal(installed, true);
assert.equal(definition.name, OUTBOUND_ARTIFACT_TOOL);
assert.match(definition.description, /Existing and newly created files are both valid/);
assert.match(section.text, /Existing files can be sent directly/);
assert.equal(typeof listeners.get('tools/result'), 'function');
assert.equal(typeof listeners.get('session/event'), 'function');
assert.equal(typeof listeners.get('session/disposed'), 'function');
assert.equal(listeners.has('agent/inbox/claimed'), false);
assert.equal(listeners.has('system-prompt/assemble'), false);
assert.equal(installOutboundArtifactTool({}), false);
});

View file

@ -52,6 +52,34 @@ async function fixture(t) {
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
}
test('workspace asks collect result files without an explicit Gate', async () => {
const observed = [];
const harness = {
async sessionExists() { return true; },
async ask(_sessionId, _text, options) {
observed.push(options);
await options?.onArtifact?.({ artifactId: 'artifact-one' });
return 'answer';
},
};
const state = { sessionFor: () => 'session-existing' };
const askOptions = { timeoutMs: 1234 };
assert.deepEqual(await askInWorkspaceSession({
harness,
state,
key: 'default',
text: 'file request',
askOptions,
}), {
sessionId: 'session-existing',
answer: 'answer',
artifacts: [{ artifactId: 'artifact-one' }],
});
assert.notEqual(observed[0], askOptions);
assert.equal(typeof observed[0].onArtifact, 'function');
});
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();