merge: integrate upstream v2.0.0 (files, images, i18n) into feishu-card-system

- Resolve conflicts in feishu-cards.mjs / bridge.mjs / control-command.mjs / lib
- Keep card-system enhancements: menuCard/settingsCard/presetCard/modelCard, /m newCard, card form submit, model select
- Adopt upstream i18n t() wrapping + file/image delivery + inbound files
- Add missing English dictionary entries for new feishu card strings
- Rebuild lib (package now 2.0.0)
This commit is contained in:
C3H3-AI 2026-08-24 07:22:50 +08:00
commit 044ab74c28
167 changed files with 22497 additions and 1793 deletions

View file

@ -0,0 +1,346 @@
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 {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
materializeOutboundArtifact,
} from '../src/channels/shared/semantic/artifact.mjs';
import { deliverOutboundArtifacts } from '../src/channels/shared/semantic/artifact-delivery.mjs';
import { createDeliveryReceipt } from '../src/channels/shared/semantic/delivery.mjs';
let fixtureId = 0;
async function committedArtifact(t, fileName, content) {
fixtureId += 1;
const suffix = String(fixtureId);
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-delivery-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
const registry = new OutboundArtifactRegistry({ uuid: () => `artifact-${suffix}` });
t.after(() => registry.clear());
const sessionId = `session-${suffix}`;
const agent = {
session: {
header: { id: sessionId, cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId: `rpc-${suffix}` } } },
],
},
};
await writeFile(join(workspace, fileName), content);
const tool = createOutboundArtifactTool({ registry });
const execution = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await tool.definition.execute({ path: fileName }, execution);
tool.onResult(execution, { isError: false });
return registry.take(sessionId, 1)[0];
}
function rejected(code) {
const error = new Error(code);
error.code = code;
return error;
}
async function assertReleased(artifact) {
await assert.rejects(
materializeOutboundArtifact(artifact),
(error) => error?.code === 'artifact-invalid',
);
}
test('non-image artifacts use the existing file sender once', async (t) => {
const artifact = await committedArtifact(t, 'report.txt', 'report');
const calls = [];
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
deliveryId: 'reply-1',
channelKey: 'test',
sendImage: async () => calls.push('image'),
sendFile: async (file) => {
calls.push(`file:${file.fileName}`);
return { messageId: 'file-1' };
},
});
assert.deepEqual(calls, ['file:report.txt']);
assert.equal(delivery.receipt.presentation, 'test-file');
assert.deepEqual(delivery.receipt.providerMessageIds, ['file-1']);
assert.equal(delivery.artifactsSent, 1);
assert.equal(delivery.artifactSendErrors, 0);
await assertReleased(artifact);
});
test('image artifacts prefer the native image sender', async (t) => {
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1, 2, 3]));
const calls = [];
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
sendImage: async (file) => {
calls.push(`image:${file.mediaType}`);
return { id: 'image-1' };
},
sendFile: async () => calls.push('file'),
});
assert.deepEqual(calls, ['image:image/png']);
assert.equal(delivery.receipt.presentation, 'test-image');
assert.deepEqual(delivery.receipt.providerMessageIds, ['image-1']);
await assertReleased(artifact);
});
test('an image uses file delivery when the channel has no image sender', async (t) => {
const artifact = await committedArtifact(t, 'result.jpg', Buffer.from([1]));
let fileCalls = 0;
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
sendFile: async () => {
fileCalls += 1;
return { id: 'file-1' };
},
});
assert.equal(fileCalls, 1);
assert.equal(delivery.receipt.presentation, 'test-file');
await assertReleased(artifact);
});
test('a definitive native-image rejection falls back to file delivery', async (t) => {
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
const calls = [];
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
sendImage: async () => {
calls.push('image');
throw rejected('artifact-provider-rejected');
},
sendFile: async () => {
calls.push('file');
return { id: 'file-after-image' };
},
});
assert.deepEqual(calls, ['image', 'file']);
assert.equal(delivery.receipt.presentation, 'test-file');
assert.equal(delivery.artifactsSent, 1);
assert.equal(delivery.artifactSendErrors, 0);
await assertReleased(artifact);
});
test('a failed file fallback produces one failure receipt and one safe notice', async (t) => {
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
const calls = [];
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
sendImage: async () => {
calls.push('image');
throw rejected('artifact-provider-rejected');
},
sendFile: async () => {
calls.push('file');
throw rejected('artifact-provider-failed');
},
sendFailureNotice: async () => {
calls.push('notice');
return { messageId: 'notice-after-fallback' };
},
});
assert.deepEqual(calls, ['image', 'file', 'notice']);
assert.deepEqual(delivery.receipt.providerMessageIds, ['notice-after-fallback']);
assert.deepEqual(delivery.receipt.artifacts, [{
artifactId: artifact.artifactId,
outcome: 'failed',
reason: 'artifact-provider-failed',
}]);
assert.equal(delivery.artifactsSent, 0);
assert.equal(delivery.artifactSendErrors, 1);
await assertReleased(artifact);
});
test('an uncertain native-image result never sends a duplicate file', async (t) => {
const artifact = await committedArtifact(t, 'result.gif', Buffer.from([1]));
let fileCalls = 0;
let notices = 0;
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
sendImage: async () => {
throw rejected('artifact-delivery-uncertain');
},
sendFile: async () => {
fileCalls += 1;
},
sendFailureNotice: async () => {
notices += 1;
return { messageId: 'notice-1' };
},
});
assert.equal(fileCalls, 0);
assert.equal(notices, 1);
assert.deepEqual(delivery.receipt.artifacts, [{
artifactId: artifact.artifactId,
outcome: 'unknown',
reason: 'artifact-delivery-uncertain',
}]);
assert.equal(delivery.userVisible, true);
assert.equal(delivery.artifactSendErrors, 1);
await assertReleased(artifact);
});
test('abort during native image delivery stops without fallback or notice', async (t) => {
const artifact = await committedArtifact(t, 'result.png', Buffer.from([1]));
const controller = new AbortController();
let fileCalls = 0;
let notices = 0;
await assert.rejects(deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
signal: controller.signal,
sendImage: async () => {
controller.abort();
controller.signal.throwIfAborted();
},
sendFile: async () => {
fileCalls += 1;
},
sendFailureNotice: async () => {
notices += 1;
},
}), (error) => error?.name === 'AbortError');
assert.equal(fileCalls, 0);
assert.equal(notices, 0);
await assertReleased(artifact);
});
test('a pre-aborted delivery releases every unprocessed artifact', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first');
const second = await committedArtifact(t, 'second.txt', 'second');
const controller = new AbortController();
controller.abort();
let sends = 0;
await assert.rejects(deliverOutboundArtifacts({
artifacts: [first, second],
channelKey: 'test',
signal: controller.signal,
sendFile: async () => { sends += 1; },
}), (error) => error?.name === 'AbortError');
assert.equal(sends, 0);
await assertReleased(first);
await assertReleased(second);
});
test('an abort while processing multiple artifacts releases the remaining artifacts', async (t) => {
const first = await committedArtifact(t, 'first.txt', 'first');
const second = await committedArtifact(t, 'second.txt', 'second');
const controller = new AbortController();
const calls = [];
await assert.rejects(deliverOutboundArtifacts({
artifacts: [first, second],
channelKey: 'test',
signal: controller.signal,
sendFile: async (file) => {
calls.push(file.fileName);
controller.abort();
return { id: 'provider-may-have-sent' };
},
}), (error) => error?.name === 'AbortError');
assert.deepEqual(calls, ['first.txt']);
await assertReleased(first);
await assertReleased(second);
});
test('abort during a failure notice remains terminal and releases the artifact', async (t) => {
const artifact = await committedArtifact(t, 'result.txt', 'result');
const controller = new AbortController();
await assert.rejects(deliverOutboundArtifacts({
artifacts: [artifact],
channelKey: 'test',
signal: controller.signal,
sendFile: async () => {
throw rejected('artifact-provider-failed');
},
sendFailureNotice: async () => {
controller.abort();
return { messageId: 'must-not-be-receipted' };
},
}), (error) => error?.name === 'AbortError');
await assertReleased(artifact);
});
test('mixed artifacts preserve order and merge existing receipt semantics', async (t) => {
const image = await committedArtifact(t, 'first.png', Buffer.from([1]));
const file = await committedArtifact(t, 'second.txt', 'two');
const calls = [];
const baseReceipt = createDeliveryReceipt({
deliveryId: 'reply-mixed',
presentation: 'test-text',
providerMessageIds: ['text-1'],
});
const delivery = await deliverOutboundArtifacts({
artifacts: [image, file],
baseReceipt,
deliveryId: 'reply-mixed',
channelKey: 'test',
sendImage: async (materialized) => {
calls.push(materialized.fileName);
return { id: 'image-1' };
},
sendFile: async (materialized) => {
calls.push(materialized.fileName);
return { id: 'file-1' };
},
});
assert.deepEqual(calls, ['first.png', 'second.txt']);
assert.equal(delivery.receipt.presentation, 'test-text-and-files');
assert.deepEqual(delivery.receipt.providerMessageIds, ['text-1', 'image-1', 'file-1']);
assert.deepEqual(delivery.receipt.artifacts.map(({ outcome }) => outcome), ['sent', 'sent']);
assert.equal(delivery.artifactsSent, 2);
await assertReleased(image);
await assertReleased(file);
});
test('a definitively failed base delivery is not treated as user-visible', async () => {
const delivery = await deliverOutboundArtifacts({
baseReceipt: createDeliveryReceipt({
deliveryId: 'failed-text',
presentation: 'telegram-rich-final',
deliveryOutcome: 'failed',
reason: 'telegram-provider-rejected',
}),
channelKey: 'telegram',
});
assert.equal(delivery.userVisible, false);
});

View file

@ -63,16 +63,9 @@ test('the QR card responds to its plugin panel width instead of the browser view
assert.match(styles, /\.ddt-qrCopy \{ min-width: 0; overflow-wrap: anywhere; \}/);
});
test('bot metrics stay in one compact two-column row at narrow widths', async () => {
test('bot cards do not reserve a row for repeated channel metrics', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(
styles,
/\.ddt-metrics \{[^\n]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
);
assert.doesNotMatch(
styles,
/\.ddt-metrics \{ grid-template-columns: minmax\(0, 1fr\); \}/,
);
assert.doesNotMatch(styles, /\.ddt-metrics|\.ddt-metric/);
});
test('the narrow-panel toolbar keeps all three controls on one row', async () => {

View file

@ -5,6 +5,7 @@ import {
createDingtalkApi,
DINGTALK_AI_CARD_TEMPLATE_ID,
DINGTALK_API_BASE_URL,
DingtalkApiError,
normalizeDingtalkCardMarkdown,
normalizeDingtalkSessionWebhook,
splitDingtalkText,
@ -113,6 +114,301 @@ 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',
});
});
test('DingTalk uploads and sends a native image message to the exact robot user', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'image-access-token', expireIn: 7_200 });
}
if (url.pathname.endsWith('/media/upload')) {
return jsonResponse({ errcode: 0, media_id: '@image-one', type: 'image' });
}
return jsonResponse({ processQueryKey: 'image-query-one' });
};
const api = createDingtalkApi({ fetchImpl });
const response = await api.sendImage({
clientId: 'ding-client',
clientSecret: 'host-only-secret',
target: {
type: 'user',
robotCode: 'robot-code',
userId: 'user-one',
},
file: {
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('dingtalk-image'),
},
});
assert.equal(response.processQueryKey, 'image-query-one');
const uploadUrl = new URL(calls[1].url);
assert.equal(uploadUrl.origin, 'https://oapi.dingtalk.com');
assert.equal(uploadUrl.pathname, '/media/upload');
assert.equal(uploadUrl.searchParams.get('access_token'), 'image-access-token');
assert.equal(uploadUrl.searchParams.get('type'), 'image');
const media = calls[1].options.body.get('media');
assert.equal(media.name, 'result.png');
assert.equal(media.type, 'image/png');
assert.equal(Buffer.from(await media.arrayBuffer()).toString(), 'dingtalk-image');
assert.equal(
calls[2].url,
`${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`,
);
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'image-access-token');
assert.deepEqual(JSON.parse(calls[2].options.body), {
robotCode: 'robot-code',
msgKey: 'sampleImageMsg',
msgParam: JSON.stringify({ photoURL: '@image-one' }),
userIds: ['user-one'],
});
});
function dingtalkFileRequest(overrides = {}) {
return {
clientId: 'ding-client',
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 keeps ambiguous native image sends in the uncertain bucket', async () => {
const api = createDingtalkApi({
fetchImpl: dingtalkFileFetch(async () => { throw new TypeError('private socket detail'); }),
});
await assert.rejects(
api.sendImage(dingtalkFileRequest({
file: {
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('dingtalk-image-error'),
},
})),
(error) => error.code === 'artifact-delivery-uncertain'
&& !error.message.includes('private'),
);
});
test('DingTalk maps definitive robot file rejection statuses without treating them as uncertain', async (t) => {
const cases = [
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
{ 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,
@ -156,6 +452,38 @@ test('DingTalk image downloads exchange downloadCode with the callback robotCode
assert.equal(calls[2].options.headers?.['x-acs-dingtalk-access-token'], undefined);
});
test('DingTalk downloads ordinary files through the native downloadCode exchange without image limits', async () => {
const fileBytes = Buffer.from('ordinary-dingtalk-file');
const calls = [];
const fetchImpl = async (url, options) => {
const href = url.toString();
calls.push({ url: href, options });
if (href === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'file-access-token', expireIn: 7_200 });
}
if (href === `${DINGTALK_API_BASE_URL}v1.0/robot/messageFiles/download`) {
return jsonResponse({ downloadUrl: 'https://download.example.test/native-file' });
}
return new Response(fileBytes);
};
const api = createDingtalkApi({ fetchImpl });
assert.deepEqual(await api.downloadFile({
clientId: 'ding-client',
clientSecret: 'host-secret',
robotCode: 'robot-from-callback',
downloadCode: 'ordinary-file-code',
}), fileBytes);
assert.deepEqual(JSON.parse(calls[1].options.body), {
downloadCode: 'ordinary-file-code',
robotCode: 'robot-from-callback',
});
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'file-access-token');
assert.equal(calls[2].options.method, 'GET');
assert.equal(calls[2].options.redirect, 'follow');
assert.equal(calls[2].options.headers, undefined);
});
test('DingTalk upgrades its HTTP temporary image URL to HTTPS without changing the signed request', async () => {
const imageBytes = Buffer.from([0xff, 0xd8, 0xff, 0x01]);
const calls = [];

View file

@ -1,11 +1,21 @@
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 {
createDingtalkBridgeStatus,
dingtalkInboundMessage,
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 +92,172 @@ 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 normalizes a native file callback into one lazy ordinary file', async () => {
const calls = [];
const bytes = Buffer.from('dingtalk-native-file');
const inbound = dingtalkInboundMessage({
msgtype: 'file',
robotCode: 'robot-from-callback',
content: JSON.stringify({
spaceId: 'space-one',
fileId: 'file-one',
fileName: '钉钉报告.zip',
downloadCode: 'opaque-file-code',
}),
}, {
api: {
downloadFile: async (request) => {
calls.push(request);
return bytes;
},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
});
assert.equal(inbound.content, '');
assert.deepEqual(inbound.images, []);
assert.equal(inbound.files.length, 1);
assert.equal(inbound.files[0].name, '钉钉报告.zip');
assert.equal(calls.length, 0, 'file download stays lazy');
assert.deepEqual(await inbound.files[0].load({}), bytes);
assert.deepEqual(calls, [{
clientId: 'ding-client',
clientSecret: 'host-secret',
robotCode: 'robot-from-callback',
downloadCode: 'opaque-file-code',
signal: undefined,
}]);
});
test('DingTalk bridge hands a native file source to the current Harness turn', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-native-file');
const bytes = Buffer.from('dingtalk-bridge-file');
const downloads = [];
const prompts = [];
const sent = [];
const bridge = new DingtalkHarnessBridge({
api: {
downloadFile: async (request) => {
downloads.push(request);
return bytes;
},
sendText: async (request) => sent.push(request.text),
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async (sessionId, prompt, options) => {
prompts.push({
sessionId,
prompt,
name: options.files[0].name,
bytes: await options.files[0].load({ signal: options.signal }),
});
return '文件已收到';
},
},
state: fixture.state,
});
await bridge.accept(message('dingtalk-native-file', '', {
msgtype: 'file',
text: undefined,
robotCode: 'robot-from-callback',
content: {
fileName: '钉钉报告.pdf',
downloadCode: 'native-file-code',
},
}));
assert.equal(downloads.length, 1);
assert.equal(downloads[0].downloadCode, 'native-file-code');
assert.deepEqual(prompts, [{
sessionId: 'session-native-file',
prompt: '',
name: '钉钉报告.pdf',
bytes,
}]);
assert.equal(sent.at(-1), '文件已收到');
});
test('DingTalk starts a native-file download before an earlier queued turn finishes', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-prefetch-file');
const firstTurn = deferred();
const bytes = Buffer.from('dingtalk-prefetched-file');
let asks = 0;
let downloads = 0;
const bridge = new DingtalkHarnessBridge({
api: {
downloadFile: async () => {
downloads += 1;
return bytes;
},
sendText: async () => {},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _prompt, options) => {
asks += 1;
if (asks === 1) return firstTurn.promise;
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
return '第二条完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message('dingtalk-prefetch-first', '先等待'));
await eventually(() => asks === 1);
const second = bridge.accept(message('dingtalk-prefetch-second', '', {
msgtype: 'file',
text: undefined,
robotCode: 'robot-from-callback',
content: { fileName: 'queued.bin', downloadCode: 'queued-file-code' },
}));
await eventually(() => downloads === 1, 'queued DingTalk file did not start downloading');
assert.equal(asks, 1, 'the second Harness turn must remain queued');
firstTurn.resolve('第一条完成');
await Promise.all([first, second]);
});
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
const privateFixture = stateFixture();
const privateSent = [];
@ -114,6 +290,265 @@ 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 routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
const scenarios = [
{
name: 'native image',
fileName: 'native.png',
content: PNG_BYTES,
expectedCalls: ['image'],
expectedPresentation: 'dingtalk-image',
expectedProviderIds: ['dingtalk-native-image'],
},
{
name: 'ordinary file',
fileName: 'ordinary.txt',
content: 'ordinary file',
expectedCalls: ['file'],
expectedPresentation: 'dingtalk-file',
expectedProviderIds: ['dingtalk-native-file'],
},
{
name: 'definite image rejection falls back',
fileName: 'fallback.png',
content: PNG_BYTES,
imageError: 'artifact-provider-rejected',
expectedCalls: ['image', 'file'],
expectedPresentation: 'dingtalk-file',
expectedProviderIds: ['dingtalk-native-file'],
},
{
name: 'uncertain image never falls back',
fileName: 'uncertain.png',
content: PNG_BYTES,
imageError: 'artifact-delivery-uncertain',
expectedCalls: ['image'],
expectedPresentation: 'text-fallback',
expectedProviderIds: [],
expectedOutcome: 'unknown',
},
];
for (const [index, scenario] of scenarios.entries()) {
await t.test(scenario.name, async (subtest) => {
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', `session-image-route-${index}`);
const calls = [];
const bridge = new DingtalkHarnessBridge({
api: {
sendText: async () => { throw new Error('text intentionally unavailable'); },
sendImage: async ({ file, target }) => {
calls.push('image');
assert.equal(file.fileName, scenario.fileName);
assert.equal(file.mediaType, 'image/png');
assert.deepEqual(target, {
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
});
if (scenario.imageError) {
const error = new Error('private image result');
error.code = scenario.imageError;
throw error;
}
return { processQueryKey: 'dingtalk-native-image' };
},
sendFile: async ({ file, target }) => {
calls.push('file');
assert.equal(file.fileName, scenario.fileName);
assert.deepEqual(target, {
type: 'user', userId: 'staff-approved', robotCode: 'robot-code',
});
return { processQueryKey: 'dingtalk-native-file' };
},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(message(`dingtalk-image-route-${index}`, '生成产物', {
robotCode: 'robot-code',
}));
assert.deepEqual(calls, scenario.expectedCalls);
assert.equal(receipt.presentation, scenario.expectedPresentation);
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
assert.deepEqual(receipt.artifacts, [{
artifactId: artifact.artifactId,
outcome: scenario.expectedOutcome ?? 'sent',
...(scenario.imageError === 'artifact-delivery-uncertain'
? { reason: 'artifact-delivery-uncertain' }
: {}),
}]);
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
});
}
});
test('DingTalk still attempts a registered file when the final text transport fails', async (t) => {
const artifact = await committedArtifact(t, 'dingtalk-text-failed.pdf', 'dingtalk-file');
const fixture = stateFixture();
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

@ -35,7 +35,8 @@ test('Discord account card matches the unified compact card layout', () => {
}));
assert.match(markup, /data-im-channel-logo="discord"/);
assert.match(markup, /@HarnessBot/);
assert.match(markup, />Gateway 长连接</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /Gateway 长连接|消息通道|dim-botMetric/);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
assert.doesNotMatch(markup, /dim-cardSummary/);

File diff suppressed because it is too large Load diff

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({
@ -464,6 +505,63 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
assert.deepEqual(sent, ['看到了一张图片']);
});
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
const bytes = Buffer.from('feishu-native-file');
const downloads = [];
const asked = [];
const sent = [];
const client = {
im: { v1: {
messageResource: { get: async (request) => {
downloads.push(request);
return { getReadableStream: () => Readable.from([bytes]) };
} },
message: { create: async (request) => {
sent.push(JSON.parse(request.data.content).text);
return { code: 0, data: { message_id: 'om_file_reply' } };
} },
} },
};
const bridge = new FeishuHarnessBridge({
client,
channel: {},
harness: {
sessionExists: async () => true,
ask: async (sessionId, prompt, options) => {
asked.push({
sessionId,
prompt,
name: options.files[0].name,
bytes: await options.files[0].load({ signal: options.signal }),
});
return '文件已收到';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
await bridge.accept(event('om_file_input', '', {
message_type: 'file',
content: JSON.stringify({ file_key: 'file_input', file_name: '飞书报告.pdf' }),
}));
await bridge.waitForIdle();
assert.deepEqual(downloads, [{
path: { message_id: 'om_file_input', file_key: 'file_input' },
params: { type: 'file' },
}]);
assert.deepEqual(asked, [{
sessionId: 'session-file',
prompt: '',
name: '飞书报告.pdf',
bytes,
}]);
assert.deepEqual(sent, ['文件已收到']);
});
test('bridge tells users to grant im:message:readonly when Feishu rejects image access', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-image-permission']]);
const sent = [];
@ -1879,6 +1977,495 @@ test('reaction failures do not block streaming replies', async () => {
assert.equal(status.streamResponses, 1);
});
test('Feishu routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
const scenarios = [
{
name: 'native image',
fileName: 'native.png',
content: PNG_1X1,
expectedCalls: ['image'],
expectedPresentation: 'feishu-image',
expectedProviderIds: ['om-native-image'],
},
{
name: 'ordinary file',
fileName: 'ordinary.txt',
content: 'ordinary file',
expectedCalls: ['file'],
expectedPresentation: 'feishu-file',
expectedProviderIds: ['om-native-file'],
},
{
name: 'definite image rejection falls back',
fileName: 'fallback.png',
content: PNG_1X1,
imageError: 'artifact-provider-rejected',
expectedCalls: ['image', 'file'],
expectedPresentation: 'feishu-file',
expectedProviderIds: ['om-native-file'],
},
{
name: 'uncertain image never falls back',
fileName: 'uncertain.png',
content: PNG_1X1,
imageError: 'artifact-delivery-uncertain',
expectedCalls: ['image'],
expectedPresentation: 'text-fallback',
expectedProviderIds: [],
expectedOutcome: 'unknown',
},
];
for (const [index, scenario] of scenarios.entries()) {
await t.test(scenario.name, async (subtest) => {
const artifact = await committedArtifact(
subtest,
scenario.fileName,
scenario.content,
`bridge-image-route-${index}`,
);
const calls = [];
const status = bridgeStatus();
const resultFor = (file, presentation, providerMessageId) => ({
schemaVersion: 1,
deliveryId: file.deliveryKey,
presentation,
providerMessageIds: [providerMessageId],
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
});
const bridge = new FeishuHarnessBridge({
client: textClient(async () => { throw new Error('text intentionally unavailable'); }),
channel: {
addReaction: async () => 'reaction',
removeReaction: async () => undefined,
sendImage: async (chatId, file, options) => {
calls.push('image');
assert.equal(chatId, 'oc_chat');
assert.equal(file.fileName, scenario.fileName);
assert.equal(file.mediaType, 'image/png');
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
if (scenario.imageError) {
const error = new Error('private image result');
error.code = scenario.imageError;
throw error;
}
return resultFor(file, 'feishu-image', 'om-native-image');
},
sendFile: async (chatId, file, options) => {
calls.push('file');
assert.equal(chatId, 'oc_chat');
assert.equal(file.fileName, scenario.fileName);
assert.equal(options.replyTo, `om-feishu-image-route-${index}`);
return resultFor(file, 'feishu-file', 'om-native-file');
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: stateFixture([
['p2p:ou_user', `session-feishu-image-route-${index}`],
]).state,
status,
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
const receipt = await bridge.accept(event(`om-feishu-image-route-${index}`, '生成产物'));
assert.deepEqual(calls, scenario.expectedCalls);
assert.equal(receipt.presentation, scenario.expectedPresentation);
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
assert.deepEqual(receipt.artifacts, [{
artifactId: artifact.artifactId,
outcome: scenario.expectedOutcome ?? 'sent',
...(scenario.imageError === 'artifact-delivery-uncertain'
? { reason: 'artifact-delivery-uncertain' }
: {}),
}]);
assert.equal(status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
assert.equal(status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
});
}
});
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,8 @@ function fakeClient(overrides = {}) {
recalls: [],
reactionsAdded: [],
reactionsRemoved: [],
fileUploads: [],
imageUploads: [],
};
const client = {
cardkit: { v1: {
@ -28,6 +30,18 @@ function fakeClient(overrides = {}) {
},
} },
im: { v1: {
file: {
create: async (request) => {
calls.fileUploads.push(request);
return { file_key: 'file-key-test' };
},
},
image: {
create: async (request) => {
calls.imageUploads.push(request);
return { data: { image_key: 'image-key-test' } };
},
},
message: {
reply: async (request) => {
calls.replies.push(request);
@ -54,6 +68,10 @@ 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.uploadImage) client.im.v1.image.create = overrides.uploadImage;
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 +147,427 @@ 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 uploads an image and replies with a native image message', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
const file = {
artifactId: 'artifact-image',
deliveryKey: 'delivery-image',
fileName: 'result.png',
mediaType: 'image/png',
size: 12,
bytes: Buffer.from('png contents'),
};
const receipt = await channel.sendImage('oc_chat', file, { replyTo: 'om_user' });
assert.equal(calls.fileUploads.length, 0);
assert.equal(calls.imageUploads.length, 1);
assert.deepEqual(calls.imageUploads[0].data, {
image_type: 'message',
image: 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, 'image');
assert.deepEqual(JSON.parse(calls.replies[0].data.content), { image_key: 'image-key-test' });
assert.match(calls.replies[0].data.uuid, /^dshim_[a-f0-9]{40}$/);
assert.deepEqual(receipt, {
schemaVersion: 1,
deliveryId: 'delivery-image',
presentation: 'feishu-image',
providerMessageIds: ['om-stream'],
artifacts: [{ artifactId: 'artifact-image', 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,280 @@ 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 stages inbound files, appends a neutral manifest, and cleans after turn end', async () => {
const inboundSources = [{ name: '用户报告.bin', load: async () => Buffer.from('bytes') }];
const ingressCalls = [];
let cleanupCalls = 0;
let promptPayload;
let promptRpcId;
let prompted = false;
const stagedFiles = [{
name: '用户报告.bin',
path: '.dsh-im/inbound/turn-abc/01-用户报告.bin',
mediaType: 'application/octet-stream',
}];
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/must-not-select-the-session-cwd',
fileIngressExecutor: async (request) => {
ingressCalls.push(request);
return {
files: stagedFiles,
async cleanup() { cleanupCalls += 1; },
};
},
});
client.ensureRunning = async () => undefined;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.history' && !prompted) return { events: [] };
if (method === 'session.list') {
return { items: [{ sessionId: 'session-inbound-files', cwd: '/tmp/exact-session-cwd' }] };
}
if (method === 'session.prompt') {
prompted = true;
promptPayload = payload;
promptRpcId = options.rpcId;
assert.equal(cleanupCalls, 0, 'files remain available while the prompt is running');
return {};
}
return {
events: [
{ event: { type: 'turn/start', seq: 1, data: { turn: 3 } } },
{
event: {
type: 'user/message',
seq: 2,
data: { turn: 3, source: { rpcId: promptRpcId } },
},
},
{
event: {
type: 'assistant/message',
seq: 3,
data: {
turn: 3,
step: 1,
message: { content: [{ type: 'text', text: '已读取附件。' }] },
},
},
},
{ event: { type: 'turn/end', seq: 4, data: { turn: 3, reason: { kind: 'completed' } } } },
],
};
};
assert.equal(await client.ask('session-inbound-files', '请查看附件', {
files: inboundSources,
}), '已读取附件。');
assert.equal(ingressCalls.length, 1);
assert.equal(ingressCalls[0].sessionId, 'session-inbound-files');
assert.equal(ingressCalls[0].workspace, '/tmp/exact-session-cwd');
assert.deepEqual(ingressCalls[0].files, inboundSources);
assert.equal(ingressCalls[0].files[0], inboundSources[0]);
assert.equal(ingressCalls[0].signal, undefined);
assert.equal(promptPayload.sessionId, 'session-inbound-files');
assert.equal(promptPayload.mode, 'queue');
assert.equal(promptPayload.content.length, 1);
assert.equal(promptPayload.content[0].type, 'text');
const promptText = promptPayload.content[0].text;
assert.match(promptText, /^请查看附件\n\n<dsh_im_files>\n/);
assert.match(promptText, /\n<\/dsh_im_files>$/);
const manifest = JSON.parse(promptText.match(/<dsh_im_files>\n(.+)\n<\/dsh_im_files>$/s)[1]);
assert.deepEqual(manifest, {
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
files: stagedFiles,
});
assert.doesNotMatch(promptText, /summari[sz]e|解析|总结|处理这些文件/i);
assert.equal(cleanupCalls, 1);
});
test('HarnessClient cleans staged inbound files when session.prompt rejects them', async () => {
const promptFailure = new Error('Harness rejected prompt');
let cleanupCalls = 0;
let prompted = false;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
fileIngressExecutor: async () => ({
files: [{ name: 'rejected.dat', path: '.dsh-im/inbound/turn-rejected/01-rejected.dat' }],
async cleanup() { cleanupCalls += 1; },
}),
});
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'session.history' && !prompted) return { events: [] };
if (method === 'session.list') {
return { items: [{ sessionId: 'session-rejected-file', cwd: '/tmp/rejected-cwd' }] };
}
assert.equal(method, 'session.prompt');
prompted = true;
throw promptFailure;
};
await assert.rejects(
client.ask('session-rejected-file', '', {
files: [{ name: 'rejected.dat', data: Buffer.from('bytes') }],
}),
(error) => error === promptFailure,
);
assert.equal(cleanupCalls, 1);
});
test('HarnessClient retains staged files when an accepted turn outcome is unknown', async () => {
const uncertainFailure = new Error('history transport failed after prompt acceptance');
let cleanupCalls = 0;
let prompted = false;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
fileIngressExecutor: async () => ({
files: [{ name: 'uncertain.dat', path: '.dsh-im/inbound/turn-unknown/01-uncertain.dat' }],
async cleanup() { cleanupCalls += 1; },
}),
});
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'session.history' && !prompted) return { events: [] };
if (method === 'session.list') {
return { items: [{ sessionId: 'session-uncertain-file', cwd: '/tmp/uncertain-cwd' }] };
}
if (method === 'session.prompt') {
prompted = true;
return { accepted: true };
}
assert.equal(method, 'session.history');
throw uncertainFailure;
};
await assert.rejects(
client.ask('session-uncertain-file', 'use the attached file', {
files: [{ name: 'uncertain.dat', data: Buffer.from('bytes') }],
timeoutMs: 1_000,
}),
(error) => error === uncertainFailure,
);
assert.equal(cleanupCalls, 0, 'uncertain accepted turns may still be reading the staged path');
});
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 });
@ -667,5 +951,24 @@ test('HarnessReplyTracker emits tool progress without exposing tool results', ()
assert.deepEqual(tracker.consume([
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
]), { type: 'status', text: '正在整理结果…' });
]), { type: 'status', text: '正在整理结果…', toolName: 'web_search' });
});
test('HarnessReplyTracker keeps every frame of a batched turn in order', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-batch' });
const updates = tracker.consumeAll([
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-batch' } } },
{ type: 'assistant/chunk', seq: 3, data: { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: '先创建再观察:' } } },
{ type: 'tool/call', seq: 4, data: { turn: 1, step: 1, name: 'add_observations' } },
{ type: 'tool/result', seq: 5, data: { turn: 1, step: 1, error: { message: 'Status code: 404.' } } },
{ type: 'tool/call', seq: 6, data: { turn: 1, step: 2, name: 'create_entities' } },
]);
assert.deepEqual(updates, [
{ type: 'text', text: '先创建再观察:' },
{ type: 'tool', name: 'add_observations' },
{ type: 'status', text: '正在整理结果…', toolName: 'add_observations', error: 'Status code: 404.' },
{ type: 'tool', name: 'create_entities' },
]);
assert.equal(tracker.answer, '先创建再观察:');
});

View file

@ -110,6 +110,36 @@ test('extractInboundMessage preserves visible Feishu post text and every embedde
]);
});
test('extractInboundMessage exposes a native Feishu file as a lazy unbounded resource download', async () => {
const calls = [];
const bytes = Buffer.from('ordinary-file-payload');
const event = {
message: {
message_id: 'om_file',
message_type: 'file',
content: JSON.stringify({ file_key: 'file_test', file_name: 'report.bin' }),
},
};
const client = { im: { v1: { messageResource: { get: async (request) => {
calls.push(request);
return {
getReadableStream: () => Readable.from([bytes.subarray(0, 4), bytes.subarray(4)]),
};
} } } } };
const message = extractInboundMessage(event, client);
assert.equal(message.content, '');
assert.deepEqual(message.images, []);
assert.equal(message.files.length, 1);
assert.equal(message.files[0].name, 'report.bin');
assert.equal(calls.length, 0, 'file download stays lazy');
assert.deepEqual(await message.files[0].load({}), bytes);
assert.deepEqual(calls, [{
path: { message_id: 'om_file', file_key: 'file_test' },
params: { type: 'file' },
}]);
});
test('Feishu image loading rejects declared or streamed data above the caller limit', async () => {
for (const resource of [
{
@ -182,10 +212,10 @@ test('Feishu image loading leaves unrelated provider failures on the generic pat
test('malformed Feishu image content does not create a downloadable image reference', () => {
assert.deepEqual(extractInboundMessage({
message: { message_type: 'image', content: '{not-json' },
}, {}), { content: '', images: [] });
}, {}), { content: '', images: [], files: [] });
assert.deepEqual(extractInboundMessage({
message: { message_type: 'post', content: '{not-json' },
}, {}), { content: '', images: [] });
}, {}), { content: '', images: [], files: [] });
});
test('conversationKey isolates p2p users and groups', () => {

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,20 @@
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 {
qqInboundMessage,
QqHarnessBridge,
sendQqImage,
} 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,11 +64,329 @@ 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,
]);
test('QQ native image adapter delegates to the SDK sendImage method', async () => {
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'image-request' };
const calls = [];
const result = { message: { id: 'qq-image-1' } };
const returned = await sendQqImage({
sendImage: async (replyTarget, source, options) => {
calls.push({ replyTarget, source, options });
return result;
},
}, target, {
artifactId: 'image-artifact',
deliveryKey: 'image-delivery',
fileName: 'diagram.png',
mediaType: 'image/png',
bytes: PNG_BYTES,
});
assert.equal(returned, result);
assert.equal(calls.length, 1);
assert.equal(calls[0].replyTarget, target);
assert.deepEqual(calls[0].source.buffer, PNG_BYTES);
assert.equal(typeof calls[0].options.onProgress, 'function');
});
test('QQ native image adapter preserves definite SDK rejections', async () => {
await assert.rejects(sendQqImage({
sendImage: async () => {
const error = new Error('unsupported image');
error.httpStatus = 415;
throw error;
},
}, { scope: 'c2c', targetId: 'owner-openid' }, {
fileName: 'diagram.png',
bytes: PNG_BYTES,
}), (error) => error.code === 'artifact-provider-rejected');
});
test('QQ native image adapter marks an unacknowledged SDK send as uncertain', async () => {
await assert.rejects(sendQqImage({
sendImage: async () => new Promise(() => {}),
}, { scope: 'c2c', targetId: 'owner-openid' }, {
fileName: 'diagram.png',
bytes: PNG_BYTES,
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
});
test('QQ sends an image artifact with the native SDK image method', async (t) => {
const artifact = await committedArtifact(t, 'native.png', PNG_BYTES, 'native-image');
const images = [];
let files = 0;
const target = { scope: 'c2c', targetId: 'owner-openid', msgId: 'qq-native-image' };
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => ({ id: 'qq-text' }),
sendImage: async (replyTarget, source, options) => {
images.push({ replyTarget, bytes: Buffer.from(source.buffer), options });
return { message: { id: 'qq-image' } };
},
sendFile: async () => { files += 1; },
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-native-image']]).state,
});
const receipt = await bridge.accept(message({
messageId: 'qq-native-image',
replyTarget: target,
}));
assert.equal(images.length, 1);
assert.equal(images[0].replyTarget, target);
assert.deepEqual(images[0].bytes, PNG_BYTES);
assert.equal(typeof images[0].options.onProgress, 'function');
assert.equal(files, 0);
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
assert.equal(receipt.artifacts[0].outcome, 'sent');
});
test('QQ falls back to a native file after a definite native-image rejection', async (t) => {
const artifact = await committedArtifact(t, 'fallback.png', PNG_BYTES, 'image-fallback');
const order = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => ({ id: 'qq-text' }),
sendImage: async () => {
order.push('image');
const error = new Error('unsupported image');
error.httpStatus = 422;
throw error;
},
sendFile: async (_target, source, options) => {
order.push(`file:${options.fileName}:${source.buffer.length}`);
return { message: { id: 'qq-file' } };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-image-fallback']]).state,
});
const receipt = await bridge.accept(message({ messageId: 'qq-image-fallback' }));
assert.deepEqual(order, [`image`, `file:fallback.png:${PNG_BYTES.length}`]);
assert.equal(receipt.artifacts[0].outcome, 'sent');
});
test('QQ does not file-fallback after an uncertain native image send', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.png', PNG_BYTES, 'image-uncertain');
const texts = [];
let files = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
texts.push(text);
return { id: `qq-text-${texts.length}` };
},
sendImage: async () => new Promise(() => {}),
sendFile: async () => { files += 1; },
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
state: stateFixture([['c2c:owner-openid', 'session-image-uncertain']]).state,
fileUploadTimeoutMs: 20,
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(message({ messageId: 'qq-image-uncertain' }));
assert.equal(files, 0);
assert.match(texts.at(-1), /发送结果未能确认.*不要立即重试/);
assert.equal(receipt.artifacts[0].outcome, 'unknown');
});
test('QQ normalizes protocol-relative ordinary-file URLs and lets the CDN redirect', async () => {
const bytes = Buffer.from('qq-native-file');
const calls = [];
const inbound = qqInboundMessage(message({
content: '请读取附件',
attachments: [{
content_type: 'application/pdf',
filename: 'QQ 报告.pdf',
size: bytes.length,
url: '//provider-issued-download.example/native-file',
}],
}), {
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
return new Response(bytes);
},
});
assert.equal(inbound.content, '请读取附件');
assert.deepEqual(inbound.images, []);
assert.equal(inbound.files.length, 1);
assert.equal(inbound.files[0].name, 'QQ 报告.pdf');
assert.equal(inbound.files[0].mediaType, 'application/pdf');
assert.equal(inbound.files[0].size, bytes.length);
assert.equal(calls.length, 0, 'file download stays lazy');
assert.deepEqual(await inbound.files[0].load({}), bytes);
assert.deepEqual(calls, [{
url: 'https://provider-issued-download.example/native-file',
init: { method: 'GET', signal: undefined, redirect: 'follow' },
}]);
});
test('QQ does not duplicate image attachments in ordinary files', () => {
const inbound = qqInboundMessage(message({
attachments: [{
content_type: 'file',
filename: 'diagram.PNG',
url: 'https://multimedia.nt.qq.com.cn/download/opaque',
}],
}));
assert.equal(inbound.images.length, 1);
assert.deepEqual(inbound.files, []);
});
test('QQ bridge hands a native non-image attachment to the current Harness turn', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-native-file']]);
const bytes = Buffer.from('qq-bridge-file');
const downloads = [];
const prompts = [];
const sent = [];
const bridge = new QqHarnessBridge({
bot: { sendText: async (_target, text) => sent.push(text) },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (sessionId, prompt, options) => {
prompts.push({
sessionId,
prompt,
name: options.files[0].name,
bytes: await options.files[0].load({ signal: options.signal }),
});
return '文件已收到';
},
},
state: fixture.state,
fetchImpl: async (url, init) => {
downloads.push({ url: url.toString(), init });
return new Response(bytes);
},
});
await bridge.accept(message({
messageId: 'qq-native-file',
content: '',
attachments: [{
content_type: 'application/octet-stream',
filename: 'QQ报告.bin',
url: 'https://provider-issued-download.example/qq-file',
}],
}));
assert.equal(downloads.length, 1);
assert.deepEqual(prompts, [{
sessionId: 'session-native-file',
prompt: '',
name: 'QQ报告.bin',
bytes,
}]);
assert.deepEqual(sent, ['文件已收到']);
});
test('QQ starts an ordinary-file download before an earlier queued turn finishes', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-prefetch-file']]);
const firstTurn = deferred();
const bytes = Buffer.from('qq-prefetched-file');
let asks = 0;
let downloads = 0;
const bridge = new QqHarnessBridge({
bot: { sendText: async () => {} },
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _prompt, options) => {
asks += 1;
if (asks === 1) return firstTurn.promise;
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
return '第二条完成';
},
},
state: fixture.state,
fetchImpl: async () => {
downloads += 1;
return new Response(bytes);
},
});
const first = bridge.accept(message({ messageId: 'qq-prefetch-first', content: '先等待' }));
await eventually(() => asks === 1);
const second = bridge.accept(message({
messageId: 'qq-prefetch-second',
content: '',
attachments: [{
content_type: 'application/octet-stream',
filename: 'queued.bin',
url: '//provider-issued-download.example/queued-file',
}],
}));
await eventually(() => downloads === 1, 'queued QQ file did not start downloading');
assert.equal(asks, 1, 'the second Harness turn must remain queued');
firstTurn.resolve('第一条完成');
await Promise.all([first, second]);
});
test('QQ sends image-only attachments to Harness and accepts the SDK file MIME fallback', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
const prompts = [];
@ -380,9 +710,9 @@ test('QQ remembers any authorized private inbound as a connection-test target',
assert.equal(sent.length, 2);
});
test('QQ private messages stream Harness snapshots and finalize once', async () => {
const frames = [];
test('QQ private messages produce one final stream bubble', async () => {
const sent = [];
const frames = [];
const seen = new Set();
const bridge = new QqHarnessBridge({
bot: {
@ -398,8 +728,10 @@ test('QQ private messages stream Harness snapshots and finalize once', async ()
sessionExists: async () => true,
createSession: async () => 'session-new',
ensureRunning: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'text', text: '回答中' });
ask: async (_session, _text, { onUpdate, progressMode }) => {
assert.equal(progressMode, 'all');
await onUpdate({ type: 'text', text: '最终回' });
await onUpdate({ type: 'text', text: '最终回答' });
return '最终回答';
},
},
@ -413,28 +745,211 @@ test('QQ private messages stream Harness snapshots and finalize once', async ()
});
await bridge.accept(message());
assert.deepEqual(frames, ['回答中', '最终回答', 'DONE']);
assert.deepEqual(frames, ['最终回答', 'DONE']);
assert.deepEqual(sent, []);
assert.equal(seen.has('msg-1'), true);
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ closes an opened progress stream and announces when the Harness turn is stopped', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
test('QQ does not duplicate a visible private answer when stream completion fails', async () => {
const frames = [];
const sent = [];
let cancellations = 0;
let loggedErrors = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: () => ({
update: async (text) => frames.push(text),
complete: async () => frames.push('DONE'),
cancel: () => { cancellations += 1; },
complete: async () => { throw new Error('already submitted'); },
cancel() {},
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => '最终回答',
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-stream-complete-failure',
setSession: async () => {},
clearSession: async () => {},
},
logger: { warn() {}, error() {} },
});
await bridge.accept(message({ messageId: 'msg-stream-complete-failure' }));
assert.deepEqual(frames, ['最终回答']);
assert.deepEqual(sent, []);
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ group messages suppress every successful progress update', async () => {
const sent = [];
let streamCalls = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: () => { streamCalls += 1; throw new Error('group stream must not open'); },
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'tool', name: 'bash' });
// 工具结束后 Harness 客户端会下发 status 帧“正在整理结果…”。
await onUpdate({ type: 'status', text: '正在整理结果…', toolName: 'bash' });
await onUpdate({ type: 'tool', name: 'read' });
await onUpdate({ type: 'status', text: '正在整理结果…', toolName: 'read' });
return '最终回答';
},
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-status',
setSession: async () => {},
clearSession: async () => {},
},
});
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-progress',
messageId: 'msg-status-frame',
replyTarget: { scope: 'group', targetId: 'group-progress', msgId: 'msg-status-frame' },
}));
assert.deepEqual(sent, ['最终回答']);
assert.equal(streamCalls, 0);
});
test('QQ group messages append tool failures to one final answer', async () => {
const sent = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'text', text: '实体不存在,先创建再添加观察:' });
await onUpdate({ type: 'tool', name: 'add_observations' });
await onUpdate({
type: 'status',
text: '正在整理结果…',
toolName: 'add_observations',
error: 'Error calling add_observations. Status code: 404.',
});
await onUpdate({ type: 'text', text: '改用创建实体的方式:' });
await onUpdate({ type: 'tool', name: 'create_entities' });
await onUpdate({ type: 'status', text: '正在整理结果…', toolName: 'create_entities' });
return '已存入两套记忆。';
},
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-tool-error',
setSession: async () => {},
clearSession: async () => {},
},
});
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-error',
messageId: 'msg-tool-error',
replyTarget: { scope: 'group', targetId: 'group-error', msgId: 'msg-tool-error' },
}));
assert.deepEqual(sent, [
'已存入两套记忆。\n\n---\n\nTool call add_observations\nError: Error calling add_observations. Status code: 404.',
]);
});
test('QQ delivers final group answers as markdown messages', async () => {
const sentText = [];
const markdownCalls = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sentText.push(text),
send: async (options) => {
markdownCalls.push(options);
return { id: `md-${markdownCalls.length}` };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => '## 标题\n\n**加粗**与 `代码`',
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-group',
setSession: async () => {},
clearSession: async () => {},
},
});
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-md',
messageId: 'msg-group-md',
content: '请回答',
replyTarget: { scope: 'group', targetId: 'group-md', msgId: 'msg-group-md' },
}));
assert.equal(markdownCalls.length, 1);
assert.equal(markdownCalls[0].msgType, 2);
assert.equal(markdownCalls[0].markdown.content, '## 标题\n\n**加粗**与 `代码`');
assert.equal(Number.isInteger(markdownCalls[0].extra.msg_seq), true);
assert.deepEqual(sentText, []);
assert.equal(bridge.status.messagesReplied, 1);
});
test('QQ falls back to plain text when the platform rejects markdown', async () => {
const sentText = [];
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sentText.push(text),
send: async () => { throw new Error('markdown rejected'); },
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => '**回答**内容',
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-fallback',
setSession: async () => {},
clearSession: async () => {},
},
logger: { warn() {}, error() {} },
});
await bridge.accept(message({
messageId: 'msg-md-fallback',
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-md-fallback' },
}));
assert.deepEqual(sentText, ['**回答**内容']);
assert.equal(bridge.status.messagesReplied, 1);
assert.equal(bridge.status.lastError, null);
});
test('QQ announces a stopped turn and closes its unused private stream', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
const sent = [];
let loggedErrors = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => sent.push(text),
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, { onUpdate }) => {
@ -450,25 +965,18 @@ test('QQ closes an opened progress stream and announces when the Harness turn is
await bridge.accept(message({ messageId: 'qq-stopped-stream' }));
assert.deepEqual(frames, ['正在使用bash…']);
assert.equal(cancellations, 1);
assert.deepEqual(sent, ['已停止。']);
assert.equal(loggedErrors, 0);
assert.equal(fixture.seen.has('qq-stopped-stream'), true);
});
test('QQ keeps a stopped turn terminal when stream cleanup and its notice both fail', async () => {
test('QQ keeps a stopped turn terminal when its notice cannot be sent', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped-fallback']]);
let warnings = 0;
let loggedErrors = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => { throw new Error('send unavailable'); },
openStream: () => ({
update: async () => {},
complete: async () => {},
cancel: () => { throw new Error('cancel unavailable'); },
}),
},
ownerUserOpenid: 'owner-openid',
harness: {
@ -488,7 +996,7 @@ test('QQ keeps a stopped turn terminal when stream cleanup and its notice both f
await bridge.accept(message({ messageId: 'qq-stopped-stream-fallback' }));
assert.equal(warnings, 2);
assert.equal(warnings, 1);
assert.equal(loggedErrors, 0);
assert.equal(fixture.seen.has('qq-stopped-stream-fallback'), true);
});
@ -1419,3 +1927,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

@ -21,7 +21,7 @@ test('QQ settings uses the shared compact channel toolbar', () => {
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
});
test('QQ bot cards match the shared two-metric card treatment', () => {
test('QQ bot cards keep check time with status and omit repeated channel details', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot',
@ -38,8 +38,8 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
}));
assert.match(markup, /class="ddt-card dim-botCard"/);
assert.match(markup, /data-im-channel-logo="qq"/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /消息通道|dim-botMetric/);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|QQ WebSocket 长连接运行正常/);

View file

@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
chunkMarkdownText,
sendMarkdownReply,
} from '../../../src/channels/qq/markdown-reply.mjs';
const target = { scope: 'c2c', targetId: 'user-openid', msgId: 'msg-1' };
test('chunkMarkdownText keeps short text as a single chunk', () => {
assert.deepEqual(chunkMarkdownText('**你好**,世界'), ['**你好**,世界']);
});
test('chunkMarkdownText returns no chunks for empty text', () => {
assert.deepEqual(chunkMarkdownText(''), []);
assert.deepEqual(chunkMarkdownText(null), []);
});
test('chunkMarkdownText splits long text within the limit', () => {
const text = Array.from({ length: 200 }, (_, index) => `第${index}行内容`).join('\n');
const chunks = chunkMarkdownText(text, 100);
assert.ok(chunks.length > 1);
for (const chunk of chunks) {
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
}
assert.equal(chunks.join('\n'), text);
});
test('chunkMarkdownText does not break inside a code block that fits the limit', () => {
const code = '```js\nconsole.log(1);\n```';
const text = `${'A'.repeat(80)}\n\n${code}\n\n${'B'.repeat(80)}`;
const chunks = chunkMarkdownText(text, 100);
const codeChunk = chunks.find((chunk) => chunk.includes('```js'));
assert.ok(codeChunk);
assert.ok(codeChunk.startsWith(code));
});
test('chunkMarkdownText still chunks an oversized code block within the limit', () => {
const code = ['```js', ...Array.from({ length: 30 }, (_, i) => `console.log(${i});`), '```']
.join('\n');
const chunks = chunkMarkdownText(code, 100);
assert.ok(chunks.length > 1);
for (const chunk of chunks) {
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
}
assert.equal(chunks.join('\n'), code);
});
test('chunkMarkdownText keeps a GFM table together', () => {
const table = [
'| 列一 | 列二 |',
'| --- | --- |',
'| a | b |',
'| c | d |',
].join('\n');
const chunks = chunkMarkdownText(table, 200);
assert.equal(chunks.length, 1);
assert.equal(chunks[0], table);
});
test('chunkMarkdownText hard-splits an oversized single line', () => {
const line = 'x'.repeat(250);
const chunks = chunkMarkdownText(line, 100);
assert.deepEqual(chunks, ['x'.repeat(100), 'x'.repeat(100), 'x'.repeat(50)]);
});
test('chunkMarkdownText does not split an emoji surrogate pair', () => {
const chunks = chunkMarkdownText(`1234😀5678`, 5);
assert.equal(chunks.join(''), '1234😀5678');
assert.equal(chunks.some((chunk) => chunk.includes('\uFFFD')), false);
for (const chunk of chunks) assert.ok(chunk.length <= 5);
});
test('sendMarkdownReply sends markdown with unique msg_seq per chunk', async () => {
const calls = [];
const results = await sendMarkdownReply({
send: async (options) => {
calls.push(options);
return { id: `id-${calls.length}` };
},
sendText: async () => {
throw new Error('sendText must not be called when markdown succeeds');
},
}, target, '# 标题\n\n**加粗**内容');
assert.equal(calls.length, 1);
assert.equal(calls[0].target, target);
assert.equal(calls[0].msgType, 2);
assert.equal(calls[0].markdown.content, '# 标题\n\n**加粗**内容');
assert.equal(Number.isInteger(calls[0].extra.msg_seq), true);
assert.deepEqual(results, [{ id: 'id-1' }]);
});
test('sendMarkdownReply assigns distinct msg_seq values across chunks', async () => {
const seqs = [];
const bot = {
send: async ({ extra }) => {
seqs.push(extra.msg_seq);
return { id: 'x' };
},
sendText: async () => { throw new Error('unexpected'); },
};
const text = Array.from({ length: 1_500 }, (_, index) => `第${index}行`).join('\n');
await sendMarkdownReply(bot, target, text);
assert.ok(seqs.length > 1);
assert.equal(new Set(seqs).size, seqs.length);
});
test('sendMarkdownReply falls back to plain text per chunk on markdown rejection', async () => {
const sentText = [];
const warnings = [];
const results = await sendMarkdownReply({
send: async () => {
throw new Error('markdown rejected: no permission');
},
sendText: async (_target, text) => {
sentText.push(text);
return { id: `text-${sentText.length}` };
},
}, target, '回答内容', { logger: { warn: (...args) => warnings.push(args) } });
assert.deepEqual(sentText, ['回答内容']);
assert.deepEqual(results, [{ id: 'text-1' }]);
assert.equal(warnings.length, 1);
});
test('sendMarkdownReply uses sendText directly when the bot lacks send()', async () => {
const sentText = [];
const results = await sendMarkdownReply({
sendText: async (_target, text) => {
sentText.push(text);
return { id: 'plain-1' };
},
}, target, '没有 send 方法的机器人');
assert.deepEqual(sentText, ['没有 send 方法的机器人']);
assert.deepEqual(results, [{ id: 'plain-1' }]);
});
test('sendMarkdownReply delivers long answers as multiple markdown chunks', async () => {
const markdownChunks = [];
const bot = {
send: async ({ markdown }) => {
markdownChunks.push(markdown.content);
return { id: `id-${markdownChunks.length}` };
},
sendText: async () => { throw new Error('unexpected'); },
};
const text = Array.from({ length: 600 }, (_, index) => `- 列表项 ${index}`).join('\n');
const results = await sendMarkdownReply(bot, target, text);
assert.ok(markdownChunks.length > 1);
for (const chunk of markdownChunks) {
assert.ok(chunk.length <= 4_500);
}
assert.equal(results.length, markdownChunks.length);
assert.equal(markdownChunks.join('\n'), text);
});
test('sendMarkdownReply moves overflow chunks off the passive group reply target', async () => {
const targets = [];
const groupTarget = { scope: 'group', targetId: 'group-1', msgId: 'group-msg' };
await sendMarkdownReply({
send: async ({ target: sentTarget }) => {
targets.push(sentTarget);
return { id: `id-${targets.length}` };
},
sendText: async () => { throw new Error('unexpected'); },
}, groupTarget, 'x'.repeat(4_500 * 6));
assert.equal(targets.length, 6);
assert.equal(targets.slice(0, 4).every((sentTarget) => sentTarget.msgId === 'group-msg'), true);
assert.deepEqual(targets.slice(4), [
{ scope: 'group', targetId: 'group-1' },
{ scope: 'group', targetId: 'group-1' },
]);
});
test('sendMarkdownReply uses the reserved passive reply for a visible partial notice', async () => {
const groupTarget = { scope: 'group', targetId: 'group-1', msgId: 'group-msg' };
const notices = [];
const results = await sendMarkdownReply({
send: async ({ target: sentTarget }) => {
if (!sentTarget.msgId) throw new Error('proactive disabled');
return { id: 'passive' };
},
sendText: async (sentTarget, text) => {
if (!sentTarget.msgId) throw new Error('proactive disabled');
notices.push(text);
return { id: 'partial-notice' };
},
}, groupTarget, 'x'.repeat(4_500 * 6), { logger: { warn() {} } });
assert.equal(results.length, 5);
assert.deepEqual(notices, ['回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。']);
});
test('sendMarkdownReply returns no deliveries for empty text', async () => {
const results = await sendMarkdownReply({
send: async () => { throw new Error('unexpected'); },
sendText: async () => { throw new Error('unexpected'); },
}, target, '');
assert.deepEqual(results, []);
});

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,228 @@
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,
chatType: 'supergroup',
replyToMessageId: 7,
messageThreadId: 8,
},
});
assert.deepEqual(telegramReceipt.providerMessageIds, ['301']);
assert.equal(telegramReceipt.presentation, 'telegram-rich-final');
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,127 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { after, test } from 'node:test';
import {
getImHostLanguage,
setImHostLanguage,
t,
} from '../../../src/channels/shared/i18n.mjs';
import { EN } from '../../../src/channels/shared/i18n-en.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const srcChannelsDir = join(here, '..', '..', '..', 'src', 'channels');
after(() => {
setImHostLanguage('zh');
});
test('host language defaults to Chinese and t() is the identity function', () => {
setImHostLanguage(undefined);
assert.equal(getImHostLanguage(), 'zh');
assert.equal(t('连接成功'), '连接成功');
assert.equal(t('共 {count} 个机器人', { count: 3 }), '共 3 个机器人');
});
test('setImHostLanguage accepts English spellings and falls back to Chinese', () => {
for (const value of ['en', 'EN', 'en-US', ' english ']) {
setImHostLanguage(value);
assert.equal(getImHostLanguage(), 'en', `expected ${value} to select English`);
}
for (const value of [undefined, null, '', 'zh', 'zh-CN', 'fr', 'enabled', 42]) {
setImHostLanguage(value);
assert.equal(getImHostLanguage(), 'zh', `expected ${value} to select Chinese`);
}
});
test('t() translates known keys and fills placeholders in English mode', () => {
setImHostLanguage('en');
const [key] = Object.keys(EN);
if (key) {
assert.equal(t(key), EN[key]);
}
assert.equal(t('未收录的中文'), '未收录的中文');
assert.equal(t('{value} 测试', { value: 'x' }), EN['{value} 测试'] ?? 'x 测试');
assert.equal(t(42), 42);
});
test('every English dictionary entry is a non-empty translation of a Chinese key', () => {
for (const [key, value] of Object.entries(EN)) {
assert.ok(/[一-鿿()]/.test(key), `dictionary key is not a Chinese source string: ${key}`);
assert.ok(typeof value === 'string' && value.trim().length > 0, `empty translation for: ${key}`);
const placeholders = (text) => [...new Set(
[...text.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g)].map((match) => match[1]),
)].sort();
assert.deepEqual(
placeholders(value),
placeholders(key),
`placeholder mismatch for: ${key}`,
);
}
});
test('English dictionary source files do not define duplicate keys', async () => {
const dictionaryDir = join(srcChannelsDir, 'shared', 'i18n-en');
const seen = new Map();
const duplicates = [];
const files = readdirSync(dictionaryDir)
.filter((file) => file.endsWith('.mjs'))
.sort();
for (const file of files) {
const { default: entries } = await import(pathToFileURL(join(dictionaryDir, file)).href);
for (const key of Object.keys(entries)) {
if (seen.has(key)) duplicates.push(`${key}: ${seen.get(key)}, ${file}`);
else seen.set(key, file);
}
}
assert.deepEqual(duplicates, []);
});
test('every literal t() key in src/channels has an English dictionary entry', () => {
const files = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) {
if (entry !== 'i18n-en') walk(path);
} else if (entry.endsWith('.mjs') && entry !== 'i18n.mjs' && entry !== 'i18n-en.mjs') {
files.push(path);
}
}
};
walk(srcChannelsDir);
const keyPattern = /\bt\(\s*(['`])((?:[^\\`'"$]|\$(?!\{)|\\.)*)\1/g;
// Decode the escapes a real string literal would apply, so keys written as
// `...\n...` in source compare against the runtime dictionary key (newline).
const decodeLiteral = (raw) => {
let out = '';
for (let i = 0; i < raw.length; i += 1) {
if (raw[i] !== '\\' || i + 1 >= raw.length) {
out += raw[i];
continue;
}
const next = raw[i + 1];
if (next === 'n') out += '\n';
else if (next === 't') out += '\t';
else if (next === 'r') out += '\r';
else out += next; // \', \", \\, \` and anything else keep the char
i += 1;
}
return out;
};
const missing = [];
for (const file of files) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(keyPattern)) {
const key = decodeLiteral(match[2]);
if (!/[一-鿿]/.test(key)) continue;
if (!Object.hasOwn(EN, key)) {
missing.push(`${file}: ${match[2]}`);
}
}
}
assert.deepEqual(missing, [], 't() keys missing from the English dictionary');
});

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,18 @@
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 { InboundFileError } from '../../../src/channels/shared/inbound-file.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 +131,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 +148,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],
@ -164,6 +215,162 @@ test('all four shared text channels execute /compact outside the model prompt pa
}
});
test('shared text bridge passes a file-only message through askOptions.files', async () => {
const fixture = stateFixture();
const source = Object.freeze({
name: 'report.bin',
load: async () => Buffer.from([0x00, 0xff]),
});
const asks = [];
const sent = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: {
createSession: async () => 'session-file-only-inbound',
ask: async (sessionId, text, options) => {
asks.push({ sessionId, text, files: options.files });
return '文件已交给 Harness。';
},
},
});
await bridge.accept(message('inbound-file-only', '', { files: [source] }));
assert.deepEqual(asks, [{
sessionId: 'session-file-only-inbound',
text: '',
files: [source],
}]);
assert.deepEqual(sent, ['文件已交给 Harness。']);
});
test('shared text bridge keeps caption and native files in one Harness ask', async () => {
const fixture = stateFixture();
const sources = [
{ name: 'one.txt', data: Buffer.from('one') },
{ name: 'two.zip', load: async () => Buffer.from('two') },
];
const asks = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async () => undefined },
harness: {
createSession: async () => 'session-text-and-files',
ask: async (sessionId, text, options) => {
asks.push({ sessionId, text, files: options.files });
return '完成';
},
},
});
await bridge.accept(message('inbound-text-and-files', '请检查这两个文件', { files: sources }));
assert.deepEqual(asks, [{
sessionId: 'session-text-and-files',
text: '请检查这两个文件',
files: sources,
}]);
});
test('a command-looking file caption is an ordinary Harness prompt, not a bridge command', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-existing' });
const source = { name: 'new.txt', data: Buffer.from('not a command') };
const asks = [];
const sent = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: {
sessionExists: async () => true,
ask: async (sessionId, text, options) => {
asks.push({ sessionId, text, files: options.files });
return '附件消息已处理';
},
},
});
await bridge.accept(message('file-caption-command', '/new', { files: [source] }));
assert.equal(fixture.sessions.get('direct:chat-a'), 'session-existing');
assert.deepEqual(asks, [{
sessionId: 'session-existing',
text: '/new',
files: [source],
}]);
assert.deepEqual(sent, ['附件消息已处理']);
});
test('an inbound file cannot claim a pending Harness interaction answer', async () => {
const fixture = stateFixture();
const sent = [];
const questionAnswered = deferred();
let interactionResponses = 0;
const asks = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: {
createSession: async () => 'session-file-during-question',
ask: async (sessionId, text, options) => {
asks.push({ sessionId, text, files: options.files });
await options.onInteraction(questionInteraction({
sessionId,
questions: [{ id: 'confirm', question: '是否继续?' }],
respond: async (result) => {
interactionResponses += 1;
questionAnswered.resolve(result);
return { accepted: true };
},
}));
await questionAnswered.promise;
return '继续执行';
},
},
});
const processing = bridge.accept(message('question-with-file-start', '先询问我'));
await eventually(() => sent.some((text) => text.includes('是否继续?')));
await bridge.accept(message('question-file-attempt', 'yes', {
files: [{ name: 'answer.txt', data: Buffer.from('yes') }],
}));
assert.equal(interactionResponses, 0);
assert.equal(asks.length, 1, 'the attachment must not become a sibling ask while interaction is open');
assert.equal(sent.at(-1), '请用文字回答当前问题。');
await bridge.accept(message('question-text-answer', 'yes'));
await processing;
assert.equal(interactionResponses, 1);
assert.equal(sent.at(-1), '继续执行');
});
test('shared text bridge reports a safe native-file download failure', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: {
createSession: async () => 'session-file-download-failure',
ask: async () => {
throw new InboundFileError(
'inbound-file-download-failed',
'private channel URL https://secret.example/token failed',
'文件下载失败,请重新发送后再试。',
);
},
},
});
await bridge.accept(message('file-download-failure', '', {
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
}));
assert.deepEqual(sent, ['文件下载失败,请重新发送后再试。']);
assert.doesNotMatch(sent[0], /secret|token|https:/i);
});
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
@ -329,6 +536,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],
@ -912,6 +1414,49 @@ test('a group question only accepts an addressed reply from the initiating actor
assert.equal(bridge.status.messagesRejected, 1);
});
test('a managed native thread can keep group isolation without asking for another mention', async () => {
const fixture = stateFixture({ 'group:managed-thread': 'session-managed' });
const sent = [];
const submitted = deferred();
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (target, text) => sent.push({ target, text }) },
harness: {
sessionExists: async () => true,
ask: async (sessionId, _text, options) => {
await options.onInteraction(questionInteraction({
id: 'managed-thread-question',
sessionId,
questions: [{ id: 'choice', question: '请选择下一步' }],
respond: async (result) => {
submitted.resolve(result);
return { accepted: true };
},
}));
await submitted.promise;
return '已继续';
},
},
});
const route = {
kind: 'group',
conversationId: 'managed-thread',
addressed: true,
requiresMention: false,
};
const processing = bridge.accept(message('managed-start', '开始', route));
await eventually(() => sent.some(({ text }) => text.includes('请选择下一步')));
assert.doesNotMatch(sent[0].text, /群聊中请 @机器人/);
await bridge.accept(message('managed-answer', '继续', route));
assert.deepEqual((await submitted.promise).value.answer.answers, [{
id: 'choice',
selected: [],
custom: '继续',
}]);
await processing;
});
test('deduplicates replays and safely closes recovered questions and approvals', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -52,7 +52,8 @@ test('Slack account card matches the unified compact layout', () => {
}));
assert.match(markup, /data-im-channel-logo="slack"/);
assert.match(markup, /@deepseek-harness/);
assert.match(markup, />Socket Mode 长连接</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /Socket Mode 长连接|消息通道|dim-botMetric/);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
});

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

@ -17,8 +17,10 @@ import {
} from '../../../src/channels/slack/slack-api.mjs';
import {
SlackRuntime,
isSlackToolProgress,
normalizeSlackEvent,
} from '../../../src/channels/slack/slack-runtime.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
import { SLACK_APP_MANIFEST_YAML } from '../../../src/channels/slack/manifest.mjs';
import {
SLACK_ENDPOINTS,
@ -57,6 +59,20 @@ function deferred() {
return { promise, resolve, reject };
}
test('Slack recognizes localized tool progress without hiding ordinary text', () => {
assert.equal(isSlackToolProgress('正在使用搜索…'), true);
assert.equal(isSlackToolProgress('正在使用…'), false);
assert.equal(isSlackToolProgress('普通回复'), false);
setImHostLanguage('en');
try {
assert.equal(isSlackToolProgress('Using search…'), true);
assert.equal(isSlackToolProgress('Using …'), false);
assert.equal(isSlackToolProgress('Ordinary reply'), false);
} finally {
setImHostLanguage('zh');
}
});
async function eventually(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@ -134,6 +150,314 @@ 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 sends PNG and JPEG artifacts through one native image-preview upload flow each', 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/IMAGE',
file_id: `FIMAGE${calls.length}`,
});
}
if (url.pathname.startsWith('/upload/')) return new Response('OK', { status: 200 });
const completed = JSON.parse(options.body);
return jsonResponse({ ok: true, files: completed.files });
},
});
for (const image of [{
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
}, {
fileName: 'result.jpg',
mediaType: 'image/jpeg',
bytes: Buffer.from([0xff, 0xd8, 0xff]),
}]) {
const offset = calls.length;
await api.uploadFile({ channelId: 'C12345678', file: image });
assert.equal(calls.length, offset + 3);
assert.equal(
calls[offset].options.body,
`filename=${image.fileName}&length=${image.bytes.length}`,
);
assert.equal(calls[offset + 1].options.headers['content-type'], image.mediaType);
assert.deepEqual(JSON.parse(calls[offset + 2].options.body).files, [{
id: `FIMAGE${offset + 1}`,
title: image.fileName,
}]);
}
});
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 = [];
@ -156,10 +480,17 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
}), png);
assert.equal(calls[1].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
assert.equal(calls[1].url.hostname, 'files.slack.com');
const streamed = await api.downloadFileStream({
url: 'https://files.slack.com/files-pri/T123-F126/download/notes.txt',
});
const streamedChunks = [];
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
assert.deepEqual(Buffer.concat(streamedChunks), png);
assert.equal(calls[2].options.signal, undefined);
await assert.rejects(() => api.downloadFile({
url: 'https://example.com/internal.png', maxBytes: 100,
}), /messaging platform/);
assert.equal(calls.length, 2);
assert.equal(calls.length, 3);
const redirectCalls = [];
const redirectingApi = new SlackApi({
@ -185,6 +516,29 @@ test('Slack downloads private files with the bot token from Slack hosts only', a
assert.match(SLACK_APP_MANIFEST_YAML, /\n\s+- files:read\n/);
});
test('Slack resolves file_access metadata through files.info', async () => {
const calls = [];
const api = new SlackApi({
botToken: BOT_TOKEN,
fetchImpl: async (url, options) => {
calls.push({ url, options });
return jsonResponse({
ok: true,
file: {
id: 'F12345678',
name: 'connect.txt',
mimetype: 'text/plain',
url_private: 'https://files.slack.com/files-pri/T123-F123/connect.txt',
},
});
},
});
assert.equal((await api.fileInfo({ fileId: 'F12345678' })).name, 'connect.txt');
assert.equal(calls[0].url.pathname.endsWith('/files.info'), true);
assert.equal(calls[0].options.body, 'file=F12345678');
assert.equal(calls[0].options.headers.authorization, `Bearer ${BOT_TOKEN}`);
});
test('Slack refuses unsafe file redirects and explains stale files:read authorization', async () => {
const workspaceCalls = [];
const workspaceApi = new SlackApi({
@ -448,7 +802,7 @@ test('Slack normalizes direct messages and addressed channel events', () => {
assert.equal(botMessage, null);
});
test('Slack accepts image file shares and keeps other files out of image prompts', async () => {
test('Slack keeps image shares in image prompts and exposes ordinary files lazily', async () => {
const loads = [];
const direct = normalizeSlackEvent({
event_id: 'Ev010',
@ -471,14 +825,32 @@ test('Slack accepts image file shares and keeps other files out of image prompts
},
}, 'U12345678', {
loadFile: async (url, options) => {
loads.push({ url, options });
loads.push({ kind: 'image', url, options });
return Buffer.from('image');
},
loadFileStream: async (url, options) => {
loads.push({ kind: 'file', url, options });
return { stream: (async function* content() { yield Buffer.from('file'); }()) };
},
});
assert.equal(direct.images.length, 1);
assert.equal(direct.images[0].name, 'screen.png');
await direct.images[0].load({ maxBytes: 5_000 });
assert.match(loads[0].url, /screen\.png$/);
assert.equal(direct.files.length, 1);
assert.deepEqual({
name: direct.files[0].name,
mediaType: direct.files[0].mediaType,
size: direct.files[0].size,
}, { name: 'notes.txt', mediaType: 'text/plain', size: 20 });
const controller = new AbortController();
const loadedFile = await direct.files[0].load({ signal: controller.signal });
const chunks = [];
for await (const chunk of loadedFile.stream) chunks.push(Buffer.from(chunk));
assert.equal(Buffer.concat(chunks).toString(), 'file');
assert.match(loads[1].url, /notes\.txt$/);
assert.equal(loads[1].kind, 'file');
assert.deepEqual(loads[1].options, { signal: controller.signal });
const group = normalizeSlackEvent({
event_id: 'Ev011',
@ -494,6 +866,7 @@ test('Slack accepts image file shares and keeps other files out of image prompts
}, 'U12345678');
assert.equal(group.addressed, true);
assert.equal(group.images.length, 1);
assert.deepEqual(group.files, []);
assert.equal(normalizeSlackEvent({
event_id: 'Ev012',
@ -504,6 +877,54 @@ test('Slack accepts image file shares and keeps other files out of image prompts
}, 'U12345678'), null);
});
test('Slack Connect file placeholders load metadata before streaming the file', async () => {
const calls = [];
const message = normalizeSlackEvent({
event_id: 'Ev013',
event: {
type: 'message',
subtype: 'file_share',
channel_type: 'im',
channel: 'D12345678',
user: 'U87654321',
ts: '1700000000.013',
text: '',
files: [{
id: 'F12345681',
mode: 'file_access',
file_access: 'check_file_info',
}],
},
}, 'U12345678', {
loadFileInfo: async (fileId, options) => {
calls.push({ kind: 'info', fileId, options });
return {
id: fileId,
name: 'connect-report.pdf',
mimetype: 'application/pdf',
url_private_download: 'https://files.slack.com/files-pri/T123-F123/connect-report.pdf',
};
},
loadFileStream: async (url, options) => {
calls.push({ kind: 'stream', url, options });
return { stream: (async function* content() { yield Buffer.from('connect'); }()) };
},
});
assert.equal(message.files.length, 1);
const controller = new AbortController();
const loaded = await message.files[0].load({ signal: controller.signal });
assert.equal(loaded.name, 'connect-report.pdf');
assert.equal(loaded.mediaType, 'application/pdf');
const chunks = [];
for await (const chunk of loaded.stream) chunks.push(Buffer.from(chunk));
assert.equal(Buffer.concat(chunks).toString(), 'connect');
assert.deepEqual(calls[0], {
kind: 'info', fileId: 'F12345681', options: { signal: controller.signal },
});
assert.equal(calls[1].kind, 'stream');
assert.match(calls[1].url, /connect-report\.pdf$/);
});
class FakeSocket {
#listeners = new Map();
sent = [];

View file

@ -40,7 +40,8 @@ test('Telegram account card matches the unified compact card layout', () => {
}));
assert.match(markup, /data-im-channel-logo="telegram"/);
assert.match(markup, /@harness_bot/);
assert.match(markup, />Bot API 长轮询</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /Bot API 长轮询|消息通道|dim-botMetric/);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
assert.match(markup, />访问设置</);

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

@ -0,0 +1,949 @@
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 {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import { TelegramApi } from '../../../src/channels/telegram/telegram-api.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import {
splitTelegramRegularText,
splitTelegramRichMarkdown,
toTelegramRichMarkdown,
} from '../../../src/channels/telegram/telegram-rich-message.mjs';
import { TelegramBotClient } from '../../../src/channels/telegram/telegram-runtime.mjs';
const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456';
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function rejected(code = 400) {
const error = new Error(`Telegram ${code}`);
error.code = `telegram-${code}`;
error.status = code;
error.providerCode = code;
return error;
}
function uncertain(code = 'telegram-timeout') {
const error = new Error('Telegram delivery is uncertain');
error.code = code;
error.deliveryOutcome = 'unknown';
return error;
}
function memoryState() {
const sessions = new Map();
const seen = new Set();
return {
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, value) => sessions.set(key, value),
clearSession: async (key) => sessions.delete(key),
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
};
}
async function committedArtifact(t) {
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-rich-artifact-'));
t.after(() => rm(workspace, { recursive: true, force: true }));
const registry = new OutboundArtifactRegistry({ uuid: () => 'telegram-rich-artifact' });
t.after(() => registry.clear());
await writeFile(join(workspace, 'result.txt'), 'rich result');
const agent = {
session: {
header: { id: 'session-rich-artifact', cwd: workspace },
events: [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'rpc-rich-artifact' } } },
],
},
};
const tool = createOutboundArtifactTool({ registry });
const exec = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: 'call-rich-artifact',
rootCallId: 'call-rich-artifact',
token: Symbol('call-rich-artifact'),
agent,
};
await tool.definition.execute({ path: 'result.txt' }, exec);
tool.onResult(exec, { isError: false });
const artifact = registry.take('session-rich-artifact', 1)[0];
t.after(() => releaseOutboundArtifact(artifact));
return artifact;
}
test('Telegram Rich Markdown keeps structure while escaping raw and encoded HTML', () => {
const source = [
'# 标题 😀',
'',
'- 第一项',
' - 嵌套项',
'',
'```js',
'const tag = "<script>";',
'```',
'',
'| 名称 | 值 |',
'| --- | --- |',
'| 公式 | $E = mc^2$ |',
'',
'[链接](https://example.com/?a=1&b=2)',
'<tg-thinking>hidden</tg-thinking>',
'&lt;tg-thinking&gt;encoded&lt;/tg-thinking&gt;',
].join('\n');
const rich = toTelegramRichMarkdown(source);
assert.match(rich, /^# 标题 😀/);
assert.match(rich, /```js/);
assert.match(rich, /\| 公式 \| \$E = mc\^2\$ \|/);
assert.match(rich, /\[链接\]\(https:\/\/example\.com\/\?a=1&amp;b=2\)/);
assert.doesNotMatch(rich, /<tg-thinking>/);
assert.match(rich, /&lt;tg-thinking&gt;hidden&lt;\/tg-thinking&gt;/);
assert.match(rich, /&amp;lt;tg-thinking&amp;gt;/);
});
test('Telegram splitters preserve every space, newline, Emoji, and long fenced-code character', () => {
const body = ` const emoji = '😀';\n\n${'中'.repeat(33_000)} \n`;
const source = ` ## 代码 😀\n\n\`\`\`js\n${body}\`\`\`\n\n 尾部 \n`;
const chunks = splitTelegramRichMarkdown(source);
assert.ok(chunks.length >= 2);
assert.ok(chunks.every((chunk) => Array.from(chunk.markdown).length <= 30_000));
assert.equal(chunks.map((chunk) => chunk.source).join(''), source);
assert.ok(chunks.filter((chunk) => chunk.markdown.includes('```js\n')).length >= 2);
assert.equal(chunks.some((chunk) => chunk.source.includes('😀')), true);
assert.throws(
() => splitTelegramRichMarkdown('```js\nunfinished'),
/unfinished code fence/,
);
const plain = ` 开头 😀\n\n${'中'.repeat(4_001)} \n尾部 `;
const plainChunks = splitTelegramRegularText(plain);
assert.equal(plainChunks.map((chunk) => chunk).join(''), plain);
assert.ok(plainChunks.every((chunk) => Array.from(chunk).length <= 4_000));
});
test('Telegram API uses the documented Rich Message, Draft, and rich edit fields', async () => {
const calls = [];
const api = new TelegramApi({
token: TOKEN,
fetchImpl: async (url, options) => {
calls.push({ method: url.pathname.split('/').at(-1), body: JSON.parse(options.body) });
return jsonResponse({ ok: true, result: { message_id: 701 } });
},
});
await api.sendRichMessage({
chatId: -100123,
richMessage: { markdown: '# final' },
replyToMessageId: 44,
messageThreadId: 55,
});
await api.sendRichMessageDraft({
chatId: 42,
draftId: 9,
richMessage: { markdown: 'partial' },
messageThreadId: 66,
});
await api.editMessageText({
chatId: -100123,
messageId: 701,
richMessage: { markdown: '# edited' },
});
assert.deepEqual(calls, [{
method: 'sendRichMessage',
body: {
chat_id: -100123,
rich_message: { markdown: '# final' },
reply_parameters: { message_id: 44, allow_sending_without_reply: true },
message_thread_id: 55,
},
}, {
method: 'sendRichMessageDraft',
body: {
chat_id: 42,
draft_id: 9,
rich_message: { markdown: 'partial' },
message_thread_id: 66,
},
}, {
method: 'editMessageText',
body: {
chat_id: -100123,
message_id: 701,
rich_message: { markdown: '# edited' },
},
}]);
await assert.rejects(() => api.sendRichMessage({
chatId: 42,
richMessage: { markdown: 'one', html: 'two' },
}), /exactly one format/);
await assert.rejects(() => api.sendRichMessageDraft({
chatId: 42,
draftId: 0,
richMessage: { markdown: 'draft' },
}), /non-zero integer/);
});
test('Telegram private stream reuses one non-zero Draft id and persists one Rich final', async () => {
const drafts = [];
const finals = [];
const plain = [];
const client = new TelegramBotClient({
api: {
sendRichMessageDraft: async (payload) => { drafts.push(payload); return true; },
sendRichMessage: async (payload) => {
finals.push(payload);
return { message_id: 801 };
},
sendMessage: async (payload) => { plain.push(payload); return { message_id: 802 }; },
},
logger: { warn() {} },
});
const target = {
chatId: 42,
chatType: 'private',
replyToMessageId: 44,
messageThreadId: 66,
};
const stream = await client.openDeliveryStream(target);
const draftResult = await stream.update({
kind: 'text', text: '## partial', format: 'markdown',
});
const result = await stream.finish({ kind: 'text', text: '## final', format: 'markdown' });
assert.equal(drafts.length, 2);
assert.ok(Number.isSafeInteger(drafts[0].draftId) && drafts[0].draftId !== 0);
assert.equal(drafts[1].draftId, drafts[0].draftId);
assert.deepEqual(drafts.map((draft) => draft.messageThreadId), [66, 66]);
assert.equal(finals.length, 1);
assert.equal(finals[0].replyToMessageId, 44);
assert.equal(finals[0].messageThreadId, 66);
assert.deepEqual(finals[0].richMessage, { markdown: '## final' });
assert.equal(plain.length, 0);
assert.deepEqual(draftResult, {
presentation: 'telegram-rich-draft',
providerMessageIds: [],
deliveryOutcome: 'sent',
});
assert.deepEqual(result, {
presentation: 'telegram-rich-final',
providerMessageIds: ['801'],
deliveryOutcome: 'sent',
});
});
test('Telegram group and Topic stream keeps one placeholder and finalizes it in place', async () => {
const created = [];
const edited = [];
const drafts = [];
const richSends = [];
const client = new TelegramBotClient({
api: {
sendMessage: async (payload) => { created.push(payload); return { message_id: 901 }; },
editMessageText: async (payload) => { edited.push(payload); return { message_id: 901 }; },
sendRichMessageDraft: async (payload) => { drafts.push(payload); return true; },
sendRichMessage: async (payload) => { richSends.push(payload); return { message_id: 902 }; },
},
});
const target = {
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
};
const stream = await client.openDeliveryStream(target);
await stream.update({ kind: 'text', text: '正在使用工具…', format: 'plain' });
await stream.update({ kind: 'text', text: '## partial', format: 'markdown' });
const result = await stream.finish({ kind: 'text', text: '## final', format: 'markdown' });
assert.equal(created.length, 1);
assert.equal(created[0].replyToMessageId, 44);
assert.equal(created[0].messageThreadId, 55);
assert.equal(drafts.length, 0);
assert.equal(richSends.length, 0);
assert.equal(edited.length, 3);
assert.equal(edited[0].text, '正在使用工具…');
assert.deepEqual(edited[1].richMessage, { markdown: '## partial' });
assert.deepEqual(edited[2].richMessage, { markdown: '## final' });
assert.ok(edited.every((call) => call.messageId === 901));
assert.deepEqual(result, {
presentation: 'telegram-rich-final',
providerMessageIds: ['901'],
deliveryOutcome: 'sent',
});
});
test('Telegram long Rich final records the placeholder and every remainder message id', async () => {
const edits = [];
const remainders = [];
const client = new TelegramBotClient({
api: {
sendMessage: async () => ({ message_id: 951 }),
editMessageText: async (payload) => {
edits.push(payload);
return { message_id: 951 };
},
sendRichMessage: async (payload) => {
remainders.push(payload);
return { message_id: 952 + remainders.length };
},
},
});
const stream = await client.openDeliveryStream({
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
});
const answer = `## long\n\n${'中'.repeat(31_000)} 😀`;
const result = await stream.finish({ kind: 'text', text: answer, format: 'markdown' });
assert.equal(edits.length, 1);
assert.ok(remainders.length >= 1);
assert.ok(remainders.every((call) => call.messageThreadId === 55));
assert.deepEqual(result.providerMessageIds, [
'951',
...remainders.map((_, index) => String(953 + index)),
]);
assert.equal(result.deliveryOutcome, 'sent');
});
test('Telegram final delivery degrades a definite Rich rejection directly to plain text', async () => {
const calls = [];
const client = new TelegramBotClient({
api: {
sendRichMessage: async (payload) => {
calls.push({ method: 'rich', payload });
throw rejected(400);
},
sendMessage: async (payload) => {
calls.push({ method: 'plain', payload });
return { message_id: 1001 };
},
},
});
const result = await client.sendDelivery({
chatId: 42,
chatType: 'private',
replyToMessageId: 44,
}, { kind: 'text', text: '<b>**answer**</b>', format: 'markdown' });
assert.deepEqual(calls.map((call) => call.method), ['rich', 'plain']);
assert.equal(calls[0].payload.richMessage.markdown, '&lt;b&gt;**answer**&lt;/b&gt;');
assert.equal(calls[1].payload.text, '<b>**answer**</b>');
assert.ok(calls.every((call) => call.payload.replyToMessageId === 44));
assert.deepEqual(result, {
presentation: 'text-fallback',
providerMessageIds: ['1001'],
deliveryOutcome: 'sent',
});
});
test('Telegram conversion failure skips Rich and keeps the exact source in plain fallback', async () => {
let richCalls = 0;
const regular = [];
const client = new TelegramBotClient({
api: {
sendRichMessage: async () => { richCalls += 1; return { message_id: 1050 }; },
sendMessage: async (payload) => { regular.push(payload); return { message_id: 1051 }; },
},
});
const result = await client.sendDelivery({ chatId: 42, replyToMessageId: 44 }, {
kind: 'text', text: '```js\nunfinished <tag>', format: 'markdown',
});
assert.equal(richCalls, 0);
assert.equal(regular.length, 1);
assert.equal(regular[0].text, '```js\nunfinished <tag>');
assert.equal(Object.hasOwn(regular[0], 'parseMode'), false);
assert.equal(result.presentation, 'text-fallback');
});
test('Telegram group fallback edits the same placeholder and never sends a second final', async () => {
const edits = [];
let sends = 0;
const client = new TelegramBotClient({
api: {
sendMessage: async () => { sends += 1; return { message_id: 1101 }; },
editMessageText: async (payload) => {
edits.push(payload);
if (payload.richMessage) throw rejected(400);
return { message_id: 1101 };
},
sendRichMessage: async () => assert.fail('short final must stay on the placeholder'),
},
});
const stream = await client.openDeliveryStream({
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
});
const result = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
assert.equal(sends, 1);
assert.equal(edits.length, 2);
assert.ok(edits.every((edit) => edit.messageId === 1101));
assert.deepEqual(edits.map((edit) => (edit.richMessage ? 'rich' : 'plain')), ['rich', 'plain']);
assert.equal(result.presentation, 'text-fallback');
assert.equal(result.deliveryOutcome, 'sent');
assert.deepEqual(result.providerMessageIds, ['1101']);
});
test('Telegram replaces a rejected placeholder or records every failed terminalization attempt', async () => {
const sends = [];
const edits = [];
const client = new TelegramBotClient({
api: {
sendMessage: async (payload) => {
sends.push(payload);
return { message_id: sends.length === 1 ? 1111 : 1112 };
},
editMessageText: async (payload) => {
edits.push(payload);
if (payload.richMessage || payload.text === '# final') throw rejected(400);
return { message_id: 1111 };
},
},
logger: { warn() {} },
});
const stream = await client.openDeliveryStream({
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
});
const result = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
assert.deepEqual(sends.map((call) => call.text), ['正在处理…', '# final']);
assert.deepEqual(edits.map((call) => (
call.richMessage ? 'rich' : call.text
)), ['rich', '# final', '回复已发送。']);
assert.deepEqual(result.providerMessageIds, ['1111', '1112']);
assert.equal(result.deliveryOutcome, 'sent');
const failedEdits = [];
let failedSends = 0;
const failedClient = new TelegramBotClient({
api: {
sendMessage: async (payload) => {
failedSends += 1;
if (failedSends === 1) return { message_id: 1121 };
throw rejected(403);
},
editMessageText: async (payload) => {
failedEdits.push(payload);
throw rejected(403);
},
},
logger: { warn() {} },
});
const failedStream = await failedClient.openDeliveryStream({
chatId: -100123,
chatType: 'supergroup',
messageThreadId: 55,
});
const failedResult = await failedStream.finish({
kind: 'text', text: '# final', format: 'markdown',
});
assert.equal(failedSends, 2);
assert.deepEqual(failedEdits.map((call) => (
call.richMessage ? 'rich' : call.text
)), ['rich', '# final', '消息发送失败,请稍后重试。']);
assert.equal(failedResult.deliveryOutcome, 'failed');
assert.deepEqual(failedResult.providerMessageIds, ['1121']);
});
test('Telegram uncertain final delivery records unknown and never sends a fallback copy', async () => {
let fallbacks = 0;
const direct = new TelegramBotClient({
api: {
sendRichMessage: async () => { throw uncertain(); },
sendMessage: async () => { fallbacks += 1; return { message_id: 1201 }; },
},
});
const directResult = await direct.sendDelivery({
chatId: 42,
chatType: 'private',
replyToMessageId: 44,
}, { kind: 'text', text: '# final', format: 'markdown' });
assert.equal(fallbacks, 0);
assert.equal(directResult.deliveryOutcome, 'unknown');
assert.equal(directResult.reason, 'telegram-timeout');
const edits = [];
const group = new TelegramBotClient({
api: {
sendMessage: async () => ({ message_id: 1202 }),
editMessageText: async (payload) => {
edits.push(payload);
throw uncertain('telegram-500');
},
sendRichMessage: async () => assert.fail('must not send a fallback final'),
},
});
const stream = await group.openDeliveryStream({
chatId: -100123,
chatType: 'supergroup',
messageThreadId: 55,
});
const groupResult = await stream.finish({ kind: 'text', text: '# final', format: 'markdown' });
assert.equal(edits.length, 1);
assert.equal(groupResult.deliveryOutcome, 'unknown');
assert.deepEqual(groupResult.providerMessageIds, ['1202']);
});
test('shared bridge surfaces a definite final delivery failure without rerunning Prompt or artifacts', async (t) => {
for (const safeReplyFails of [false, true]) {
await t.test(safeReplyFails ? 'safe reply also fails' : 'safe reply succeeds', async () => {
const safeReplies = [];
let prompts = 0;
const stream = {
providerMessageIds: ['failed-placeholder'],
presentation: 'telegram-regular',
async update() {},
async finish() {
return {
presentation: 'telegram-rich-final',
providerMessageIds: ['failed-placeholder'],
deliveryOutcome: 'failed',
reason: 'telegram-403',
};
},
async fail() {
return undefined;
},
cancel() {},
};
const bridge = new TelegramHarnessBridge({
bot: {
sendTyping: async () => {},
openDeliveryStream: async () => stream,
sendText: async (_target, text) => {
safeReplies.push(text);
if (safeReplyFails) throw rejected(403);
return { message_id: 1261 };
},
},
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => 'session-rich-artifact',
ask: async () => {
prompts += 1;
return '## final';
},
},
});
const receipt = await bridge.accept({
messageId: `definite-failure-${safeReplyFails}`,
senderId: 'rich-user',
kind: 'direct',
conversationId: `definite-failure-${safeReplyFails}`,
content: 'return a file',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.equal(prompts, 1);
assert.deepEqual(safeReplies, ['消息处理失败,请稍后重试。']);
assert.equal(receipt.deliveryOutcome, 'failed');
assert.equal(receipt.reason, 'telegram-403');
assert.deepEqual(receipt.artifacts, []);
assert.match(bridge.status.lastError, /telegram-403/);
});
}
});
test('shared bridge settles each artifact once before containing a definite text failure', async (t) => {
const artifact = await committedArtifact(t);
const sentFiles = [];
let prompts = 0;
let safeReplies = 0;
const bridge = new TelegramHarnessBridge({
bot: {
sendTyping: async () => {},
openDeliveryStream: async () => ({
providerMessageIds: ['failed-placeholder'],
presentation: 'telegram-regular',
async update() {},
async finish() {
return {
presentation: 'telegram-rich-final',
providerMessageIds: ['failed-placeholder'],
deliveryOutcome: 'failed',
reason: 'telegram-403',
};
},
cancel() {},
}),
sendText: async () => {
safeReplies += 1;
return { message_id: 1261 };
},
sendFile: async (_target, file) => {
sentFiles.push(file.fileName);
return { message_id: 1262 };
},
},
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => 'session-rich-artifact',
ask: async (_sessionId, _text, options) => {
prompts += 1;
await options.onArtifact(artifact);
return '## final with file';
},
},
});
const receipt = await bridge.accept({
messageId: 'definite-failure-with-file',
senderId: 'rich-user',
kind: 'direct',
conversationId: 'definite-failure-with-file',
content: 'return a file',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.equal(prompts, 1);
assert.equal(safeReplies, 0);
assert.deepEqual(sentFiles, ['result.txt']);
assert.equal(receipt.deliveryOutcome, 'failed');
assert.deepEqual(receipt.artifacts, [{
artifactId: 'telegram-rich-artifact', outcome: 'sent',
}]);
});
test('Telegram distinguishes pre-dispatch abort from an uncertain in-flight abort', async () => {
const before = new AbortController();
const beforeReason = new DOMException('stopped before dispatch', 'AbortError');
before.abort(beforeReason);
let beforeCalls = 0;
const beforeClient = new TelegramBotClient({
api: new TelegramApi({
token: TOKEN,
fetchImpl: async () => {
beforeCalls += 1;
return jsonResponse({ ok: true, result: { message_id: 1251 } });
},
}),
signal: before.signal,
});
const beforeResult = await beforeClient.sendDelivery({ chatId: 42 }, {
kind: 'text', text: '# final', format: 'markdown',
});
assert.equal(beforeCalls, 0);
assert.equal(beforeResult.deliveryOutcome, 'failed');
const after = new AbortController();
let afterCalls = 0;
let dispatchStarted;
const started = new Promise((resolve) => { dispatchStarted = resolve; });
const afterClient = new TelegramBotClient({
api: new TelegramApi({
token: TOKEN,
fetchImpl: async (_url, { signal }) => {
afterCalls += 1;
dispatchStarted();
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
},
}),
signal: after.signal,
});
const inFlight = afterClient.sendDelivery({ chatId: 42 }, {
kind: 'text', text: '# final', format: 'markdown',
});
await started;
after.abort(new DOMException('stopped after dispatch', 'AbortError'));
const afterResult = await inFlight;
assert.equal(afterCalls, 1);
assert.equal(afterResult.deliveryOutcome, 'unknown');
assert.equal(afterResult.reason, 'telegram-aborted-after-dispatch');
});
test('Telegram keeps an uncertain Rich receipt and still delivers the registered result file', async (t) => {
const artifact = await committedArtifact(t);
const sentFiles = [];
const stream = {
providerMessageIds: [],
async update() {},
async finish() {
return {
presentation: 'telegram-rich-final',
providerMessageIds: [],
deliveryOutcome: 'unknown',
reason: 'telegram-timeout',
};
},
cancel() {},
};
const bridge = new TelegramHarnessBridge({
bot: {
sendText: async () => assert.fail('must not replace an uncertain Rich final'),
sendTyping: async () => {},
openDeliveryStream: async () => stream,
sendFile: async (_target, file) => {
sentFiles.push(file.fileName);
return { message_id: 1302 };
},
},
state: memoryState(),
harness: {
createSession: async () => 'session-rich-artifact',
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '## final with file';
},
},
});
const receipt = await bridge.accept({
messageId: 'rich-artifact-inbound',
senderId: 'rich-user',
kind: 'direct',
conversationId: 'rich-user-artifact',
content: 'return a file',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.deepEqual(sentFiles, ['result.txt']);
assert.equal(receipt.presentation, 'telegram-text-and-files');
assert.equal(receipt.deliveryOutcome, 'unknown');
assert.equal(receipt.reason, 'telegram-timeout');
assert.deepEqual(receipt.providerMessageIds, ['1302']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'telegram-rich-artifact', outcome: 'sent' }]);
});
test('Telegram Rich text and a result file keep the placeholder and file message ids', async (t) => {
const artifact = await committedArtifact(t);
const sent = [];
const edited = [];
const files = [];
let prompts = 0;
const client = new TelegramBotClient({
api: {
sendChatAction: async () => true,
sendMessage: async (payload) => {
sent.push(payload);
return { message_id: 1351 };
},
editMessageText: async (payload) => {
edited.push(payload);
return { message_id: 1351 };
},
sendDocument: async (payload) => {
files.push(payload);
return { message_id: 1352 };
},
},
});
const bridge = new TelegramHarnessBridge({
bot: client,
state: memoryState(),
harness: {
createSession: async () => 'session-rich-mixed',
ask: async (_sessionId, _text, options) => {
prompts += 1;
await options.onArtifact(artifact);
return '## final with file';
},
},
});
const receipt = await bridge.accept({
messageId: 'rich-mixed-inbound',
senderId: 'rich-user',
kind: 'group',
conversationId: '-100123:55',
content: 'return text and a file',
addressed: true,
replyTarget: {
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
},
});
assert.equal(prompts, 1);
assert.deepEqual(sent.map((call) => call.text), ['正在处理…']);
assert.deepEqual(edited.map((call) => call.richMessage), [{ markdown: '## final with file' }]);
assert.equal(files.length, 1);
assert.equal(files[0].replyToMessageId, 44);
assert.equal(files[0].messageThreadId, 55);
assert.equal(receipt.presentation, 'telegram-text-and-files');
assert.equal(receipt.deliveryOutcome, 'sent');
assert.deepEqual(receipt.providerMessageIds, ['1351', '1352']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'telegram-rich-artifact', outcome: 'sent' }]);
});
test('shared bridge marks assistant updates and final as markdown but status as plain', async () => {
const blocks = [];
const stream = {
providerMessageIds: ['1301'],
async update(block) { blocks.push(block); },
async finish(block) {
blocks.push(block);
return {
presentation: 'telegram-rich-final',
providerMessageIds: ['1301'],
deliveryOutcome: 'sent',
};
},
cancel() {},
};
const bridge = new TelegramHarnessBridge({
bot: {
sendText: async () => assert.fail('semantic final must not use the legacy path'),
sendTyping: async () => {},
openDeliveryStream: async () => stream,
},
state: memoryState(),
harness: {
createSession: async () => 'session-rich',
ask: async (_sessionId, _text, options) => {
await options.onUpdate({ type: 'tool', name: '搜索' });
await options.onUpdate({ type: 'text', text: '## partial' });
return '## final';
},
},
});
const receipt = await bridge.accept({
messageId: 'rich-inbound',
senderId: 'rich-user',
kind: 'direct',
conversationId: 'rich-user',
content: 'answer richly',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.deepEqual(blocks, [{
kind: 'text', text: '正在使用搜索…', format: 'plain',
}, {
kind: 'text', text: '## partial', format: 'markdown',
}, {
kind: 'text', text: '## final', format: 'markdown',
}]);
assert.equal(receipt.presentation, 'telegram-rich-final');
assert.equal(receipt.deliveryOutcome, 'sent');
});
test('Telegram stop runs one Prompt and replaces the group placeholder with one terminal message', async () => {
const sent = [];
const edits = [];
let prompts = 0;
const client = new TelegramBotClient({
api: {
sendChatAction: async () => true,
sendMessage: async (payload) => { sent.push(payload); return { message_id: 1391 }; },
editMessageText: async (payload) => { edits.push(payload); return { message_id: 1391 }; },
},
});
const bridge = new TelegramHarnessBridge({
bot: client,
state: memoryState(),
harness: {
createSession: async () => 'session-rich-stop',
ask: async () => {
prompts += 1;
const error = new Error('stopped');
error.code = 'turn-stopped';
throw error;
},
},
});
await bridge.accept({
messageId: 'rich-stop-inbound',
senderId: 'rich-user',
kind: 'group',
conversationId: '-100123:55',
content: 'stop safely',
addressed: true,
replyTarget: {
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
},
});
assert.equal(prompts, 1);
assert.deepEqual(sent.map((call) => call.text), ['正在处理…']);
assert.deepEqual(edits.map((call) => call.text), ['已停止。']);
assert.ok(edits.every((call) => !call.richMessage));
});
test('Telegram group processing failure replaces its placeholder instead of leaving it stuck', async () => {
const sent = [];
const edits = [];
let prompts = 0;
const client = new TelegramBotClient({
api: {
sendChatAction: async () => true,
sendMessage: async (payload) => { sent.push(payload); return { message_id: 1401 }; },
editMessageText: async (payload) => { edits.push(payload); return { message_id: 1401 }; },
},
});
const bridge = new TelegramHarnessBridge({
bot: client,
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => 'session-rich-error',
ask: async () => {
prompts += 1;
throw new Error('private Harness diagnostic');
},
},
});
await bridge.accept({
messageId: 'rich-error-inbound',
senderId: 'rich-user',
kind: 'group',
conversationId: '-100123:55',
content: 'fail safely',
addressed: true,
replyTarget: {
chatId: -100123,
chatType: 'supergroup',
replyToMessageId: 44,
messageThreadId: 55,
},
});
assert.equal(prompts, 1);
assert.equal(sent.length, 1);
assert.equal(sent[0].text, '正在处理…');
assert.equal(edits.length, 1);
assert.equal(edits[0].messageId, 1401);
assert.equal(edits[0].text, '消息处理失败,请稍后重试。');
assert.equal(Object.hasOwn(edits[0], 'richMessage'), false);
});

View file

@ -4,6 +4,12 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
import {
TELEGRAM_ACCESS_MODES,
TelegramConfigStore,
@ -19,9 +25,11 @@ import {
} from '../../../src/channels/telegram/telegram-api.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import {
TelegramBotClient,
TelegramRuntime,
TELEGRAM_COMMAND_MENU,
normalizeTelegramUpdate,
telegramCommandMenu,
telegramInboundAllowed,
} from '../../../src/channels/telegram/telegram-runtime.mjs';
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
@ -87,6 +95,42 @@ async function bounded(promise, message, timeoutMs = 1_000) {
}
}
async function committedTelegramArtifact(t, {
suffix,
fileName,
content,
}) {
const workspace = await mkdtemp(join(tmpdir(), `dsh-im-telegram-artifact-${suffix}-`));
t.after(() => rm(workspace, { recursive: true, force: true }));
const sessionId = `session-telegram-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: `rpc-${suffix}` } } },
],
},
};
await writeFile(join(workspace, fileName), content);
const tool = createOutboundArtifactTool({ registry });
const execution = {
name: OUTBOUND_ARTIFACT_TOOL,
callId: `call-${suffix}`,
rootCallId: `call-${suffix}`,
token: Symbol(`call-${suffix}`),
agent,
};
await tool.definition.execute({ path: fileName }, execution);
tool.onResult(execution, { isError: false });
return registry.take(sessionId, 1)[0];
}
test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => {
assert.equal(validTelegramToken(TOKEN), true);
assert.equal(validTelegramToken('short'), false);
@ -139,11 +183,16 @@ test('Telegram API resolves and downloads files without exposing arbitrary paths
},
});
assert.deepEqual(await api.downloadFile({ fileId: 'AgAC_test-file', maxBytes: 100 }), png);
assert.equal(calls.length, 2);
const streamed = await api.downloadFileStream({ fileId: 'AgAC_test-file' });
const streamedChunks = [];
for await (const chunk of streamed.stream) streamedChunks.push(Buffer.from(chunk));
assert.deepEqual(Buffer.concat(streamedChunks), png);
assert.equal(calls.length, 4);
assert.equal(calls[0].options.method, 'POST');
assert.equal(calls[1].options.method, 'GET');
assert.equal(calls[1].url.hostname, 'api.telegram.org');
assert.match(calls[1].url.pathname, /\/file\/bot.+\/photos\/file_1\.png$/);
assert.equal(calls[3].options.signal, undefined);
const unsafeApi = new TelegramApi({
token: TOKEN,
@ -200,6 +249,315 @@ 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 command menu follows the host language at runtime', () => {
assert.equal(TELEGRAM_COMMAND_MENU[0].description, '开启一个全新会话');
setImHostLanguage('en');
try {
assert.equal(telegramCommandMenu()[0].description, 'Start a brand-new Session');
} finally {
setImHostLanguage('zh');
}
assert.deepEqual(telegramCommandMenu(), TELEGRAM_COMMAND_MENU);
});
test('Telegram API preserves the legacy plain send and edit payloads', async () => {
const calls = [];
const api = new TelegramApi({
token: TOKEN,
fetchImpl: async (url, options) => {
calls.push({ method: url.pathname.split('/').at(-1), body: JSON.parse(options.body) });
return jsonResponse({ ok: true, result: { message_id: 501 } });
},
});
await api.sendMessage({
chatId: -100123,
text: 'legacy send',
replyToMessageId: 44,
messageThreadId: 55,
});
await api.editMessageText({
chatId: -100123,
messageId: 501,
text: 'legacy edit',
});
assert.deepEqual(calls, [{
method: 'sendMessage',
body: {
chat_id: -100123,
text: 'legacy send',
link_preview_options: { is_disabled: true },
reply_parameters: { message_id: 44, allow_sending_without_reply: true },
message_thread_id: 55,
},
}, {
method: 'editMessageText',
body: {
chat_id: -100123,
message_id: 501,
text: 'legacy edit',
link_preview_options: { is_disabled: true },
},
}]);
assert.equal(Object.hasOwn(calls[0].body, 'parse_mode'), false);
assert.equal(Object.hasOwn(calls[1].body, 'parse_mode'), false);
});
test('Telegram plain delivery keeps the 4000 boundary, reply, topic, and content', async () => {
const calls = [];
let nextMessageId = 600;
const client = new TelegramBotClient({
api: {
sendMessage: async (payload) => {
calls.push(payload);
return { message_id: nextMessageId++ };
},
},
});
const target = { chatId: -100123, replyToMessageId: 44, messageThreadId: 55 };
const exact = `\`\`\`js\n${'a'.repeat(3990)}\n\`\`\``;
assert.equal(exact.length, 4000);
await client.sendText(target, exact);
assert.equal(calls.length, 1);
assert.equal(calls[0].text, exact);
calls.length = 0;
const long = `${'中'.repeat(3998)}😀Z`;
assert.equal(long.length, 4001);
const receipt = await client.sendText(target, long);
assert.equal(calls.length, 2);
assert.equal(calls.map((call) => call.text).join(''), long);
assert.equal(calls[0].replyToMessageId, 44);
assert.equal(calls[1].replyToMessageId, undefined);
assert.deepEqual(calls.map((call) => call.messageThreadId), [55, 55]);
assert.deepEqual(receipt.providerMessageIds, ['601', '602']);
});
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 API uploads a result image as a native photo 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: 902 } });
},
});
const result = await api.sendPhoto({
chatId: -100123,
replyToMessageId: 44,
messageThreadId: 55,
file: {
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('telegram-image'),
},
});
assert.equal(result.message_id, 902);
assert.match(request.url.pathname, /sendPhoto$/);
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 photo = request.options.body.get('photo');
assert.equal(photo.name, 'result.png');
assert.equal(photo.type, 'image/png');
assert.equal(Buffer.from(await photo.arrayBuffer()).toString(), 'telegram-image');
});
test('Telegram bot client routes images through sendPhoto with the existing reply context', async () => {
let request;
const controller = new AbortController();
const client = new TelegramBotClient({
api: {
sendPhoto: async (value) => {
request = value;
return { message_id: 903 };
},
},
signal: controller.signal,
});
const file = {
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('telegram-image'),
};
assert.deepEqual(await client.sendImage({
chatId: -100456,
replyToMessageId: 66,
messageThreadId: 77,
}, file), { message_id: 903 });
assert.equal(request.chatId, -100456);
assert.equal(request.file, file);
assert.equal(request.replyToMessageId, 66);
assert.equal(request.messageThreadId, 77);
assert.equal(request.signal, controller.signal);
});
test('Telegram photo delivery reuses stable artifact error mapping', async () => {
const rejectedApi = new TelegramApi({
token: TOKEN,
fetchImpl: async () => jsonResponse({
ok: false,
error_code: 400,
description: 'Bad Request: unsupported photo',
}, 400),
});
await assert.rejects(() => rejectedApi.sendPhoto({
chatId: 123,
file: { fileName: 'result.webp', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-provider-rejected'
&& error.providerCode === 400
&& error.status === 400);
const uncertainApi = new TelegramApi({
token: TOKEN,
fetchImpl: async () => new Response('not-json', { status: 200 }),
});
await assert.rejects(() => uncertainApi.sendPhoto({
chatId: 123,
file: { fileName: 'result.png', bytes: Buffer.from('result') },
}), (error) => error.code === 'artifact-delivery-uncertain');
});
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 }));
@ -607,6 +965,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
}, { botId: '123456789', username: 'HarnessBot' });
assert.equal(privateMessage.kind, 'direct');
assert.equal(privateMessage.addressed, true);
assert.equal(privateMessage.replyTarget.chatType, 'private');
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
const groupMessage = normalizeTelegramUpdate({
@ -622,6 +981,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
assert.equal(groupMessage.kind, 'group');
assert.equal(groupMessage.addressed, true);
assert.equal(groupMessage.content, 'run this');
assert.equal(groupMessage.replyTarget.chatType, 'supergroup');
const topicOne = normalizeTelegramUpdate({
update_id: 12,
@ -669,7 +1029,7 @@ test('Telegram compatible mode preserves old routing and private allowlist mode
}), false);
});
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
test('Telegram keeps native photos and image documents as images and exposes ordinary documents as files', async () => {
const loads = [];
const groupPhoto = normalizeTelegramUpdate({
update_id: 20,
@ -709,9 +1069,23 @@ test('Telegram normalizes photo captions and image documents into one downloadab
file_id: 'png-document', file_name: 'diagram.png', mime_type: 'image/png', file_size: 3_000,
},
},
}, { botId: '123456789', username: 'HarnessBot' });
}, {
botId: '123456789',
username: 'HarnessBot',
loadFile: async (fileId, options) => {
loads.push({ fileId, options });
return Buffer.from('document');
},
});
assert.equal(document.images[0].name, 'diagram.png');
assert.equal(document.images[0].mediaType, 'image/png');
assert.equal(document.images[0].size, 3_000);
assert.deepEqual(document.files, []);
await document.images[0].load({ maxBytes: 5_000 });
assert.deepEqual(loads[1], {
fileId: 'png-document',
options: { maxBytes: 5_000 },
});
const documentWithoutMime = normalizeTelegramUpdate({
update_id: 23,
@ -722,8 +1096,11 @@ test('Telegram normalizes photo captions and image documents into one downloadab
document: { file_id: 'webp-document', file_name: 'diagram.webp', file_size: 2_000 },
},
}, { botId: '123456789', username: 'HarnessBot' });
assert.equal(documentWithoutMime.images[0].name, 'diagram.webp');
assert.equal(documentWithoutMime.images[0].mediaType, 'image/webp');
assert.deepEqual(documentWithoutMime.files, []);
const fileLoads = [];
const pdf = normalizeTelegramUpdate({
update_id: 22,
message: {
@ -732,8 +1109,26 @@ test('Telegram normalizes photo captions and image documents into one downloadab
from: { id: 42, is_bot: false },
document: { file_id: 'pdf-document', file_name: 'file.pdf', mime_type: 'application/pdf' },
},
}, { botId: '123456789', username: 'HarnessBot' });
}, {
botId: '123456789',
username: 'HarnessBot',
loadFileStream: async (fileId, options) => {
fileLoads.push({ fileId, options });
return { stream: (async function* content() { yield Buffer.from('pdf'); }()) };
},
});
assert.deepEqual(pdf.images, []);
assert.equal(pdf.files[0].name, 'file.pdf');
assert.equal(pdf.files[0].mediaType, 'application/pdf');
const controller = new AbortController();
const loadedPdf = await pdf.files[0].load({ signal: controller.signal });
const pdfChunks = [];
for await (const chunk of loadedPdf.stream) pdfChunks.push(Buffer.from(chunk));
assert.equal(Buffer.concat(pdfChunks).toString(), 'pdf');
assert.deepEqual(fileLoads, [{
fileId: 'pdf-document',
options: { signal: controller.signal },
}]);
});
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
@ -788,6 +1183,86 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
});
test('Telegram bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
const scenarios = [{
name: 'native image',
suffix: 'native-image',
fileName: 'result.png',
content: Buffer.from([1, 2, 3]),
expectedCalls: ['image:result.png:image/png'],
expectedOutcome: 'sent',
}, {
name: 'ordinary file',
suffix: 'ordinary-file',
fileName: 'result.txt',
content: 'ordinary file',
expectedCalls: ['file:result.txt:text/plain'],
expectedOutcome: 'sent',
}, {
name: 'definitive image rejection',
suffix: 'image-fallback',
fileName: 'result.webp',
content: Buffer.from([4, 5, 6]),
imageErrorCode: 'artifact-provider-rejected',
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
expectedOutcome: 'sent',
}, {
name: 'uncertain image result',
suffix: 'image-uncertain',
fileName: 'result.gif',
content: Buffer.from([7, 8, 9]),
imageErrorCode: 'artifact-delivery-uncertain',
expectedCalls: ['image:result.gif:image/gif'],
expectedOutcome: 'unknown',
}];
for (const scenario of scenarios) {
const artifact = await committedTelegramArtifact(t, scenario);
const calls = [];
const bot = {
sendText: async () => ({ message_id: `text-${scenario.suffix}` }),
sendImage: async (_target, file) => {
calls.push(`image:${file.fileName}:${file.mediaType}`);
if (scenario.imageErrorCode) {
const error = new Error(scenario.imageErrorCode);
error.code = scenario.imageErrorCode;
throw error;
}
return { message_id: `image-${scenario.suffix}` };
},
sendFile: async (_target, file) => {
calls.push(`file:${file.fileName}:${file.mediaType}`);
return { message_id: `file-${scenario.suffix}` };
},
};
const bridge = new TelegramHarnessBridge({
bot,
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => `session-${scenario.suffix}`,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
});
const receipt = await bridge.accept({
messageId: `telegram-artifact-${scenario.suffix}`,
senderId: '42',
kind: 'direct',
conversationId: `telegram-artifact-${scenario.suffix}`,
content: '生成结果',
addressed: true,
replyTarget: { chatId: 88, replyToMessageId: 7, messageThreadId: 9 },
});
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
}
});
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
@ -920,6 +1395,8 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
const fakeApi = {
getMe: async () => ({ id: 123456789, is_bot: true }),
getWebhookInfo: async () => ({ url: '' }),
setMyCommands: async () => true,
setChatMenuButton: async () => true,
getUpdates: async ({ timeout, signal }) => {
if (timeout === 0) return [];
if (!delivered) {
@ -931,6 +1408,8 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
});
},
sendChatAction: async () => true,
sendRichMessageDraft: async () => true,
sendRichMessage: async () => ({ message_id: nextMessageId++ }),
sendMessage: async () => ({ message_id: nextMessageId++ }),
editMessageText: async () => true,
};
@ -1031,6 +1510,13 @@ test('Telegram runtime keeps polling while a Harness question waits for its answ
if (text.includes('请选择测试环境')) questionSent.resolve();
return { message_id: messageId };
},
sendRichMessageDraft: async () => true,
sendRichMessage: async ({ richMessage }) => {
if (richMessage.markdown === '已选择生产环境') finalReplySent.resolve();
const messageId = nextOutboundMessageId;
nextOutboundMessageId += 1;
return { message_id: messageId };
},
editMessageText: async ({ text }) => {
if (text === '已选择生产环境') finalReplySent.resolve();
return true;

View file

@ -1,18 +1,211 @@
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 {
sendWecomImage,
WecomHarnessBridge,
wecomInboundMessage,
} 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=',
'base64',
);
test('Enterprise WeChat native image adapter uploads and sends an image media message', async () => {
const calls = [];
const result = { body: { msgid: 'wecom-image-1', errcode: 0 } };
const file = {
artifactId: 'image-artifact',
deliveryKey: 'image-delivery',
fileName: 'diagram.png',
mediaType: 'image/png',
bytes: PNG_1X1,
};
const returned = await sendWecomImage({
uploadMedia: async (bytes, options) => {
calls.push({ operation: 'upload', bytes: Buffer.from(bytes), options });
return { media_id: 'image-media-1' };
},
sendMediaMessage: async (chatId, type, mediaId) => {
calls.push({ operation: 'send', chatId, type, mediaId });
return result;
},
}, 'chat-1', file);
assert.equal(returned, result);
assert.deepEqual(calls, [{
operation: 'upload',
bytes: PNG_1X1,
options: { type: 'image', filename: 'diagram.png' },
}, {
operation: 'send',
chatId: 'chat-1',
type: 'image',
mediaId: 'image-media-1',
}]);
});
test('Enterprise WeChat native image adapter preserves definite upload errors', async () => {
let sends = 0;
await assert.rejects(sendWecomImage({
uploadMedia: async () => {
const error = new Error('forbidden');
error.httpStatus = 403;
throw error;
},
sendMediaMessage: async () => { sends += 1; },
}, 'chat-1', {
fileName: 'diagram.png',
bytes: PNG_1X1,
}), (error) => error.code === 'artifact-permission-required');
assert.equal(sends, 0);
});
test('Enterprise WeChat native image adapter marks an unacknowledged message as uncertain', async () => {
await assert.rejects(sendWecomImage({
uploadMedia: async () => ({ media_id: 'image-media-uncertain' }),
sendMediaMessage: async () => new Promise(() => {}),
}, 'chat-1', {
fileName: 'diagram.png',
bytes: PNG_1X1,
}, { timeoutMs: 20 }), (error) => error.code === 'artifact-delivery-uncertain');
});
test('Enterprise WeChat sends an image artifact as a native image message', async (t) => {
const artifact = await committedArtifact(t, 'native.png', PNG_1X1, 'native-image');
const uploads = [];
const messages = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_source, _streamId, _content, finish) => (
finish ? { body: { msgid: 'wecom-text' } } : undefined
),
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
uploadMedia: async (bytes, options) => {
uploads.push({ bytes: Buffer.from(bytes), options });
return { media_id: 'native-image-media' };
},
sendMediaMessage: async (chatId, type, mediaId) => {
messages.push({ chatId, type, mediaId });
return { body: { msgid: 'wecom-image', errcode: 0 } };
},
},
generateStreamId: () => 'native-image-stream',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
state: state(),
});
const receipt = await bridge.accept(frame({ msgid: 'wecom-native-image' }));
assert.deepEqual(uploads, [{
bytes: PNG_1X1,
options: { type: 'image', filename: 'native.png' },
}]);
assert.deepEqual(messages, [{
chatId: 'member-1',
type: 'image',
mediaId: 'native-image-media',
}]);
assert.equal(receipt.artifacts[0].artifactId, artifact.artifactId);
assert.equal(receipt.artifacts[0].outcome, 'sent');
assert.equal(receipt.providerMessageIds.includes('wecom-image'), true);
});
test('Enterprise WeChat falls back to a file after a definite native-image rejection', async (t) => {
const artifact = await committedArtifact(t, 'fallback.png', PNG_1X1, 'image-fallback');
const uploads = [];
const messages = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
uploadMedia: async (_bytes, options) => {
uploads.push(options.type);
if (options.type === 'image') {
const error = new Error('image format rejected');
error.providerCode = 40014;
throw error;
}
return { media_id: 'fallback-file-media' };
},
sendMediaMessage: async (_chatId, type, mediaId) => {
messages.push({ type, mediaId });
return { body: { msgid: 'wecom-file', errcode: 0 } };
},
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
state: state(),
});
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-fallback' }));
assert.deepEqual(uploads, ['image', 'file']);
assert.deepEqual(messages, [{ type: 'file', mediaId: 'fallback-file-media' }]);
assert.equal(receipt.artifacts[0].outcome, 'sent');
});
test('Enterprise WeChat does not file-fallback after an uncertain native image send', async (t) => {
const artifact = await committedArtifact(t, 'uncertain.png', PNG_1X1, 'image-uncertain');
const uploads = [];
const notices = [];
const bridge = new WecomHarnessBridge({
client: {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async (_chatId, body) => {
notices.push(body.markdown.content);
return { body: { msgid: 'wecom-uncertain-notice' } };
},
uploadMedia: async (_bytes, options) => {
uploads.push(options.type);
return { media_id: 'uncertain-image-media' };
},
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() {} },
});
const receipt = await bridge.accept(frame({ msgid: 'wecom-image-uncertain' }));
assert.deepEqual(uploads, ['image']);
assert.match(notices[0], /发送结果未能确认.*不要立即重试/);
assert.equal(receipt.artifacts[0].outcome, 'unknown');
});
function frame(overrides = {}) {
return {
headers: { req_id: 'req-1' },
@ -77,6 +270,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',
@ -358,6 +582,78 @@ test('Enterprise WeChat preserves mixed-message text and image order', async ()
]);
});
test('Enterprise WeChat exposes native file callbacks through the SDK downloader without file limits', async () => {
const calls = [];
const bytes = Buffer.from('wecom-native-file');
const message = wecomInboundMessage(frame({
msgtype: 'file',
text: undefined,
file: {
url: 'https://wecom.example/encrypted-file',
aeskey: 'file-specific-key',
},
}), {
downloadFile: async (url, aeskey) => {
calls.push({ url, aeskey });
return { buffer: bytes, filename: '企业微信报告.pdf' };
},
});
assert.equal(message.content, '');
assert.deepEqual(message.images, []);
assert.equal(message.files.length, 1);
assert.equal(message.files[0].name, 'file');
assert.equal(calls.length, 0, 'file download stays lazy at normalization time');
assert.deepEqual(await message.files[0].load({}), {
data: bytes,
name: '企业微信报告.pdf',
});
assert.deepEqual(calls, [{
url: 'https://wecom.example/encrypted-file',
aeskey: 'file-specific-key',
}]);
});
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
const transport = testClient();
const bytes = Buffer.from('wecom-bridge-file');
const downloads = [];
const prompts = [];
transport.client.downloadFile = async (url, aeskey) => {
downloads.push({ url, aeskey });
return { buffer: bytes, filename: '企微报告.docx' };
};
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: () => 'file-stream',
harness: {
sessionExists: async () => true,
ask: async (sessionId, prompt, options) => {
const loaded = await options.files[0].load({ signal: options.signal });
prompts.push({ sessionId, prompt, name: options.files[0].name, loaded });
return '文件已收到';
},
},
state: state(),
});
await bridge.accept(frame({
msgid: 'wecom-native-file',
msgtype: 'file',
text: undefined,
file: { url: 'https://wecom.example/file', aeskey: 'file-key' },
}));
assert.deepEqual(downloads, [{ url: 'https://wecom.example/file', aeskey: 'file-key' }]);
assert.deepEqual(prompts, [{
sessionId: 'session-existing',
prompt: '',
name: 'file',
loaded: { data: bytes, name: '企微报告.docx' },
}]);
assert.equal(transport.streamed.at(-1).content, '文件已收到');
});
test('Enterprise WeChat starts image download before an earlier conversation turn finishes', async () => {
const transport = testClient();
const firstStarted = deferred();
@ -1219,3 +1515,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

@ -50,7 +50,7 @@ test('Enterprise WeChat settings uses the shared compact channel toolbar', () =>
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
});
test('Enterprise WeChat bot cards match the shared two-metric card treatment', () => {
test('Enterprise WeChat cards keep check time with status and omit repeated channel details', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'wecom_bot',
@ -67,8 +67,8 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
}));
assert.match(markup, /class="ddt-card dim-botCard"/);
assert.match(markup, /data-im-channel-logo="wecom"/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /消息通道|dim-botMetric/);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);

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

@ -5,6 +5,7 @@ import test from 'node:test';
import {
createWeixinApi,
decryptWeixinImage,
extractWeixinFiles,
extractWeixinText,
normalizeWeixinApiBaseUrl,
parseWeixinImageAesKey,
@ -65,6 +66,42 @@ test('iLink image references download lazily from the canonical CDN and decrypt
assert.equal(calls[0].init.redirect, 'manual');
});
test('iLink native file items download lazily and decrypt without image size or type rules', async () => {
const key = randomBytes(16);
const plaintext = Buffer.from('weixin-native-file');
const ciphertext = encryptImage(plaintext, key);
const calls = [];
const files = extractWeixinFiles({
item_list: [{
type: 4,
file_item: {
media: {
encrypt_query_param: 'native-file-ticket',
aes_key: Buffer.from(key.toString('hex')).toString('base64'),
encrypt_type: 1,
},
file_name: '微信报告.zip',
len: String(plaintext.length),
},
}],
}, {
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
return new Response(ciphertext);
},
});
assert.equal(files.length, 1);
assert.equal(files[0].name, '微信报告.zip');
assert.equal(files[0].size, plaintext.length);
assert.equal(calls.length, 0, 'file download stays lazy');
assert.deepEqual(await files[0].load({}), plaintext);
assert.deepEqual(calls, [{
url: 'https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=native-file-ticket',
init: { method: 'GET', signal: undefined, redirect: 'manual' },
}]);
});
test('Weixin image keys support both documented CDN encodings and reject unsafe URLs', () => {
const key = randomBytes(16);
assert.equal(parseWeixinImageAesKey({ aeskey: key.toString('hex') }).equals(key), true);
@ -167,10 +204,337 @@ test('sendText emits the iLink message envelope without reflecting the token in
assert.equal(body.msg.context_token, 'message-context');
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
assert.equal(body.base_info.channel_version, '2.4.6');
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/1.0.2');
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/1.1.0');
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),
},
}]);
});
test('sendImage uses the encrypted CDN flow and sends a native image item', async () => {
const calls = [];
const api = createWeixinApi({
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
if (url.pathname.endsWith('/getuploadurl')) {
return jsonResponse({
ret: 0,
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=image-one',
});
}
if (url.pathname === '/c2c/upload') {
return new Response(null, {
status: 200,
headers: { 'x-encrypted-param': 'download-image-ticket' },
});
}
return jsonResponse({ ret: 0 });
},
});
const plaintext = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x01, 0x02, 0x03,
]);
const result = await api.sendImage({
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'host-only-token',
toUserId: 'wx-user',
contextToken: 'message-context',
runId: 'run-image',
file: {
artifactId: 'artifact-image',
deliveryKey: 'session:turn:artifact-image',
fileName: 'result.png',
mediaType: 'image/png',
bytes: plaintext,
},
});
assert.equal(calls.length, 3);
const ticket = JSON.parse(calls[0].init.body);
assert.equal(ticket.media_type, 1);
assert.equal(ticket.to_user_id, 'wx-user');
assert.equal(ticket.rawsize, plaintext.length);
assert.equal(ticket.filesize, 16);
assert.equal(ticket.no_need_thumb, true);
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
assert.equal(
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
true,
);
const sent = JSON.parse(calls[2].init.body).msg;
assert.equal(sent.context_token, 'message-context');
assert.equal(sent.run_id, 'run-image');
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
assert.equal(result.messageId, sent.client_id);
assert.deepEqual(sent.item_list, [{
type: 2,
image_item: {
media: {
encrypt_query_param: 'download-image-ticket',
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
encrypt_type: 1,
},
mid_size: 16,
},
}]);
});
function weixinFileRequest(overrides = {}) {
return {
baseUrl: 'https://ilinkai.weixin.qq.com',
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('sendImage preserves the uncertain final-delivery boundary', async () => {
const api = createWeixinApi({
fetchImpl: weixinFileFetch(async () => { throw new TypeError('private socket detail'); }),
});
await assert.rejects(
api.sendImage(weixinFileRequest({
file: {
artifactId: 'artifact-image-error',
deliveryKey: 'session:turn:artifact-image-error',
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('image-error-case'),
},
})),
(error) => error.code === 'artifact-delivery-uncertain'
&& !error.message.includes('private'),
);
});
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
const cases = [
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
{ 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,21 @@
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 {
createWeixinBridgeStatus,
weixinInboundMessage,
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 +82,155 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
test('Weixin bridge normalization keeps native file items separate from images', async () => {
const bytes = Buffer.from('weixin-file');
const source = { name: '微信文档.docx', load: async () => bytes };
const calls = [];
const inbound = weixinInboundMessage(message('weixin-file', '', {
item_list: [{ type: 4, file_item: { file_name: '微信文档.docx', media: {} } }],
}), {
inboundImages: () => [],
inboundFiles: (value) => {
calls.push(value);
return [source];
},
});
assert.equal(inbound.content, '');
assert.deepEqual(inbound.images, []);
assert.deepEqual(inbound.files, [source]);
assert.equal(calls.length, 1);
assert.deepEqual(await inbound.files[0].load({}), bytes);
});
test('Weixin bridge hands a native file source to the current Harness turn', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-native-file');
const bytes = Buffer.from('weixin-bridge-file');
const prompts = [];
const sent = [];
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
inboundFiles: () => [{ name: '微信报告.zip', load: async () => bytes }],
sendText: async ({ text }) => sent.push(text),
},
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async (sessionId, prompt, options) => {
prompts.push({
sessionId,
prompt,
name: options.files[0].name,
bytes: await options.files[0].load({ signal: options.signal }),
});
return '文件已收到';
},
},
state: fixture.state,
status: createWeixinBridgeStatus(),
});
await bridge.accept(message('weixin-native-file-turn', '', {
item_list: [{
type: 4,
file_item: { file_name: '微信报告.zip', media: {} },
}],
}));
assert.deepEqual(prompts, [{
sessionId: 'session-native-file',
prompt: '',
name: '微信报告.zip',
bytes,
}]);
assert.deepEqual(sent, ['文件已收到']);
});
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');
const firstTurn = deferred();
const bytes = Buffer.from('weixin-prefetched-file');
let asks = 0;
let downloads = 0;
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
inboundFiles: (value) => value.item_list?.some((item) => item.file_item)
? [{
name: 'queued.bin',
load: async () => {
downloads += 1;
return bytes;
},
}]
: [],
sendText: async () => {},
},
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _prompt, options) => {
asks += 1;
if (asks === 1) return firstTurn.promise;
assert.deepEqual(await options.files[0].load({ signal: options.signal }), bytes);
return '第二条完成';
},
},
state: fixture.state,
});
const first = bridge.accept(message('weixin-prefetch-first', '先等待'));
await eventually(() => asks === 1);
const second = bridge.accept(message('weixin-prefetch-second', '', {
item_list: [{
type: 4,
file_item: { file_name: 'queued.bin', media: {} },
}],
}));
await eventually(() => downloads === 1, 'queued Weixin file did not start downloading');
assert.equal(asks, 1, 'the second Harness turn must remain queued');
firstTurn.resolve('第一条完成');
await Promise.all([first, second]);
});
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 +260,242 @@ 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 routes Artifact images natively and preserves the shared fallback boundary', async (t) => {
const scenarios = [
{
name: 'native image',
fileName: 'native.png',
content: PNG_BYTES,
expectedCalls: ['image'],
expectedPresentation: 'weixin-image',
expectedProviderIds: ['weixin-native-image'],
},
{
name: 'ordinary file',
fileName: 'ordinary.txt',
content: 'ordinary file',
expectedCalls: ['file'],
expectedPresentation: 'weixin-file',
expectedProviderIds: ['weixin-native-file'],
},
{
name: 'definite image rejection falls back',
fileName: 'fallback.png',
content: PNG_BYTES,
imageError: 'artifact-provider-rejected',
expectedCalls: ['image', 'file'],
expectedPresentation: 'weixin-file',
expectedProviderIds: ['weixin-native-file'],
},
{
name: 'uncertain image never falls back',
fileName: 'uncertain.png',
content: PNG_BYTES,
imageError: 'artifact-delivery-uncertain',
expectedCalls: ['image'],
expectedPresentation: 'text-fallback',
expectedProviderIds: [],
expectedOutcome: 'unknown',
},
];
for (const [index, scenario] of scenarios.entries()) {
await t.test(scenario.name, async (subtest) => {
const artifact = await committedArtifact(subtest, scenario.fileName, scenario.content);
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', `session-image-route-${index}`);
const calls = [];
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [],
sendText: async () => { throw new Error('text intentionally unavailable'); },
sendImage: async ({ file }) => {
calls.push('image');
assert.equal(file.fileName, scenario.fileName);
assert.equal(file.mediaType, 'image/png');
if (scenario.imageError) {
const error = new Error('private image result');
error.code = scenario.imageError;
throw error;
}
return { messageId: 'weixin-native-image' };
},
sendFile: async ({ file }) => {
calls.push('file');
assert.equal(file.fileName, scenario.fileName);
return { messageId: 'weixin-native-file' };
},
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '';
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(message(`weixin-image-route-${index}`, '生成产物'));
assert.deepEqual(calls, scenario.expectedCalls);
assert.equal(receipt.presentation, scenario.expectedPresentation);
assert.deepEqual(receipt.providerMessageIds, scenario.expectedProviderIds);
assert.deepEqual(receipt.artifacts, [{
artifactId: artifact.artifactId,
outcome: scenario.expectedOutcome ?? 'sent',
...(scenario.imageError === 'artifact-delivery-uncertain'
? { reason: 'artifact-delivery-uncertain' }
: {}),
}]);
assert.equal(bridge.status.artifactsSent, scenario.expectedOutcome === 'unknown' ? 0 : 1);
assert.equal(bridge.status.artifactSendErrors, scenario.expectedOutcome === 'unknown' ? 1 : 0);
});
}
});
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
const fixture = stateFixture();
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

@ -10,6 +10,7 @@ import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccessSettings,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
@ -66,12 +67,74 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
testNotice: '测试消息已发送,请到 WhatsApp 自聊会话中确认。',
}));
assert.match(markup, /data-im-channel-logo="whatsapp"/);
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /WhatsApp Web|消息通道|dim-botMetric/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /仅自己模式(默认)/);
assert.match(markup, /指定联系人模式/);
assert.match(markup, /开放响应模式/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});
test('WhatsApp access settings save a normalized selected-contact allowlist', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: [] },
},
onSave: async (value) => saved.push(value),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
const textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
await act(async () => {
textarea.props.onChange({ target: { value: '+16505550999\n16505550999' } });
});
await act(async () => {
renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
await flushMicrotasks();
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedNumbers: ['16505550999'],
}]);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp access settings only show the allowlist for selected contacts', async () => {
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: ['16505550999'] },
},
onSave: async () => {},
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
const allowlistFields = () => renderer.root.findAllByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
assert.equal(allowlistFields().length, 0);
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
assert.equal(allowlistFields().length, 1);
await act(async () => {
select.props.onChange({ target: { value: 'open' } });
});
assert.equal(allowlistFields().length, 0);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
@ -80,6 +143,7 @@ test('WhatsApp connection check requests a test message from the existing reconn
import.meta.url,
), 'utf8');
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

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,6 +1,12 @@
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';
@ -8,13 +14,24 @@ import test from 'node:test';
import { DisconnectReason } from '@whiskeysockets/baileys';
import {
OUTBOUND_ARTIFACT_TOOL,
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
WHATSAPP_ACCESS_MODES,
WhatsappConfigStore,
deriveWhatsappBotId,
normalizeWhatsappAccessPolicy,
} from '../../../src/channels/whatsapp/config-store.mjs';
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
import {
WhatsappBotClient,
WhatsappRuntime,
createWhatsappMediaDownloader,
normalizeWhatsappMessage,
whatsappInboundAllowed,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
@ -25,12 +42,95 @@ 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),
accountJid: ACCOUNT_JID,
authDirectory: AUTH_DIRECTORY,
name: 'Harness WhatsApp',
accessMode: WHATSAPP_ACCESS_MODES.open,
allowedNumbers: [],
createdAt: new Date().toISOString(),
connectedAt: new Date().toISOString(),
...overrides,
@ -43,10 +143,37 @@ test('WhatsApp config stores only linked-device metadata with restrictive permis
const store = await new WhatsappConfigStore(path).load();
await store.save(linkedConfig());
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
assert.equal(store.list()[0].accessMode, WHATSAPP_ACCESS_MODES.open);
assert.equal((await stat(path)).mode & 0o777, 0o600);
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
});
test('WhatsApp migrates existing bots to self-only mode and validates access policies', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-access-migration-'));
const path = join(root, 'config.json');
const legacy = linkedConfig();
delete legacy.accessMode;
delete legacy.allowedNumbers;
await writeFile(path, `${JSON.stringify({ version: 2, bots: [legacy] })}\n`);
const store = await new WhatsappConfigStore(path).load();
assert.deepEqual(store.get(legacy.botId), {
...legacy,
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.deepEqual(normalizeWhatsappAccessPolicy({
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999', '16505550999'],
}), {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.throws(() => normalizeWhatsappAccessPolicy({
accessMode: 'compatible',
allowedNumbers: [],
}), /accessMode/);
});
test('WhatsApp Web session reports QR and linked identity without printing either', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-session-'));
const events = new EventEmitter();
@ -131,15 +258,21 @@ test('WhatsApp Web session accepts recent append events without replaying stale
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
const events = new EventEmitter();
const received = [];
const contexts = [];
const socket = {
ev: events,
updateMediaMessage: async () => {},
end: async () => {},
logout: async () => {},
};
const session = await createWhatsappWebSession({
authDir: root,
onQr: () => {},
onMessage: async (message) => { received.push(message.key.id); },
makeSocket: () => ({
ev: events,
end: async () => {},
logout: async () => {},
}),
onMessage: async (message, context) => {
received.push(message.key.id);
contexts.push(context);
},
makeSocket: () => socket,
loadAuthState: async () => ({
state: {
creds: {},
@ -162,9 +295,33 @@ test('WhatsApp Web session accepts recent append events without replaying stale
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(received, ['recent', 'notify']);
assert.equal(contexts[0].socket, socket);
assert.equal(contexts[1].socket, socket);
await session.close();
});
test('WhatsApp media downloader supplies Baileys reupload context', async () => {
const reuploaded = [];
const socket = {
updateMediaMessage: async (message) => {
reuploaded.push(message);
return { ...message, reuploaded: true };
},
};
let receivedContext;
const download = createWhatsappMediaDownloader({
socket,
logger: { info() {} },
download: async (_message, _type, _options, context) => {
receivedContext = context;
return context.reuploadRequest({ key: { id: 'expired-media' } });
},
});
assert.equal((await download({}, 'stream', {})).reuploaded, true);
assert.equal(typeof receivedContext.logger.info, 'function');
assert.deepEqual(reuploaded, [{ key: { id: 'expired-media' } }]);
});
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
const direct = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
@ -207,7 +364,47 @@ test('WhatsApp normalizes direct and explicitly mentioned group messages', () =>
}, ACCOUNT_JID), null);
});
test('WhatsApp exposes image media through a bounded Baileys download stream', async () => {
test('WhatsApp access modes allow self-chat, selected contacts, or the existing open behavior', () => {
const direct = normalizeWhatsappMessage({
key: {
remoteJid: '987654321098765@lid',
remoteJidAlt: '16505550999@s.whatsapp.net',
id: 'access-direct',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'access-group',
fromMe: false,
},
message: { conversation: 'hello' },
}, ACCOUNT_JID);
const selfChat = normalizeWhatsappMessage({
key: { remoteJid: ACCOUNT_JID, id: 'access-self', fromMe: true },
message: { conversation: 'hello' },
}, ACCOUNT_JID);
assert.equal(whatsappInboundAllowed(selfChat), true);
assert.equal(whatsappInboundAllowed(direct), false);
assert.equal(whatsappInboundAllowed(group), false);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550999']),
}), true);
assert.equal(whatsappInboundAllowed(direct, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: new Set(['16505550000']),
}), false);
assert.equal(whatsappInboundAllowed(group, {
accessMode: WHATSAPP_ACCESS_MODES.open,
}), true);
});
test('WhatsApp keeps native and document images as images and exposes ordinary documents as files', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const calls = [];
const controller = new AbortController();
@ -277,9 +474,52 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
url: 'https://mmg.whatsapp.net/document',
},
},
}, ACCOUNT_JID);
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield Buffer.from('document'); },
};
},
});
assert.equal(document.images[0].name, 'diagram.webp');
assert.equal(document.images[0].mediaType, 'image/webp');
assert.equal(document.images[0].size, 2_000);
assert.deepEqual(document.files, []);
const documentController = new AbortController();
assert.equal((await document.images[0].load({
signal: documentController.signal,
maxBytes: 5_000,
})).toString(), 'document');
assert.equal(calls[1].type, 'stream');
assert.equal(calls[1].options.options.signal instanceof AbortSignal, true);
const ordinaryDocument = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-pdf-1', fromMe: false },
message: {
documentMessage: {
mimetype: 'application/pdf', fileName: 'report.pdf', fileLength: 4_000,
url: 'https://mmg.whatsapp.net/document-pdf',
},
},
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield Buffer.from('pdf'); },
};
},
});
assert.deepEqual(ordinaryDocument.images, []);
assert.equal(ordinaryDocument.files[0].name, 'report.pdf');
assert.equal(ordinaryDocument.files[0].mediaType, 'application/pdf');
assert.equal(ordinaryDocument.files[0].size, 4_000);
const loadedDocument = await ordinaryDocument.files[0].load({ signal: documentController.signal });
const documentChunks = [];
for await (const chunk of loadedDocument.stream) documentChunks.push(Buffer.from(chunk));
assert.equal(Buffer.concat(documentChunks).toString(), 'pdf');
assert.equal(calls[2].type, 'stream');
assert.equal(calls[2].options.options.signal, documentController.signal);
for (const [index, wrapper] of [
'viewOnceMessage',
@ -328,7 +568,7 @@ test('WhatsApp exposes image media through a bounded Baileys download stream', a
});
});
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
let callbacks;
const calls = [];
const socket = {
@ -351,7 +591,7 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
ask: async () => 'Harness answer',
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
config: linkedConfig({ accessMode: WHATSAPP_ACCESS_MODES.selfOnly }),
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
@ -371,11 +611,463 @@ test('WhatsApp runtime connects a linked device and replies through Harness', as
message: { conversation: 'hello' },
});
assert.equal(runtime.status.ready, true);
assert.equal(runtime.status.messagesRejected, 1);
assert.equal(calls.length, 0);
runtime.setAccessPolicy({ accessMode: WHATSAPP_ACCESS_MODES.open, allowedNumbers: [] });
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-3', fromMe: false },
message: { conversation: 'hello again' },
});
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
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 bot client sends native images with stable id and early echo suppression', async () => {
const remembered = [];
const calls = [];
const outboundIds = {
remember: (id) => remembered.push(id),
};
const quoted = { key: { id: 'quoted-image-message' } };
const deliveryKey = 'whatsapp-native-image-delivery';
const expectedMessageId = createHash('sha256')
.update(`${deliveryKey}:image`)
.digest('hex')
.slice(0, 20)
.toUpperCase();
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async (jid, content, options) => {
assert.equal(remembered.includes(options.messageId), true);
calls.push({ jid, content, options });
return { key: { id: 'provider-image-message' } };
},
};
const client = new WhatsappBotClient(socket, outboundIds);
const bytes = Buffer.from('whatsapp-image');
assert.deepEqual(await client.sendImage({
jid: '16505550999@s.whatsapp.net',
quoted,
}, {
deliveryKey,
fileName: 'result.png',
mediaType: 'image/png',
bytes,
}), { key: { id: 'provider-image-message' } });
assert.equal(calls.length, 1);
assert.equal(calls[0].jid, '16505550999@s.whatsapp.net');
assert.equal(calls[0].content.image, bytes);
assert.equal(calls[0].content.mimetype, 'image/png');
assert.equal(calls[0].content.document, undefined);
assert.equal(calls[0].options.quoted, quoted);
assert.equal(calls[0].options.messageId, expectedMessageId);
assert.equal(calls[0].options.mediaUploadTimeoutMs, 120_000);
assert.deepEqual(remembered, [expectedMessageId, 'provider-image-message']);
});
test('WhatsApp classifies a definite image rejection and uses a distinct fallback file id', async () => {
const calls = [];
const deliveryKey = 'whatsapp-image-fallback-delivery';
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async (_jid, content, options) => {
calls.push({ content, options });
if (content.image) {
const error = new Error('unsupported image');
error.output = { statusCode: 415 };
throw error;
}
return { key: { id: 'provider-file-fallback' } };
},
};
const client = new WhatsappBotClient(socket, { remember() {} });
const file = {
artifactId: 'whatsapp-image-fallback',
deliveryKey,
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('whatsapp-image'),
};
await assert.rejects(
() => client.sendImage({ jid: '16505550999@s.whatsapp.net' }, file),
(error) => error.code === 'artifact-provider-rejected',
);
assert.deepEqual(
await client.sendFile({ jid: '16505550999@s.whatsapp.net' }, file),
{ key: { id: 'provider-file-fallback' } },
);
assert.equal(calls.length, 2);
assert.equal(calls[0].options.messageId, createHash('sha256')
.update(`${deliveryKey}:image`).digest('hex').slice(0, 20).toUpperCase());
assert.equal(calls[1].options.messageId, createHash('sha256')
.update(deliveryKey).digest('hex').slice(0, 20).toUpperCase());
assert.notEqual(calls[0].options.messageId, calls[1].options.messageId);
});
test('WhatsApp native image timeout remains an uncertain artifact delivery', async () => {
const socket = {
sendPresenceUpdate: async () => {},
sendMessage: async () => new Promise(() => {}),
};
const client = new WhatsappBotClient(socket, { remember() {} }, {
mediaUploadTimeoutMs: 10,
});
await assert.rejects(() => client.sendImage({
jid: '16505550999@s.whatsapp.net',
}, {
artifactId: 'whatsapp-image-timeout',
fileName: 'result.png',
mediaType: 'image/png',
bytes: Buffer.from('whatsapp-image'),
}), (error) => error.code === 'artifact-delivery-uncertain'
&& error.cause?.name === 'TimeoutError');
});
test('WhatsApp bridge routes outbound image artifacts natively with safe file fallback', async (t) => {
const scenarios = [{
name: 'native image',
suffix: 'native-image-route',
fileName: 'result.png',
content: Buffer.from([1, 2, 3]),
expectedCalls: ['image:result.png:image/png'],
expectedOutcome: 'sent',
}, {
name: 'ordinary file',
suffix: 'ordinary-file-route',
fileName: 'result.txt',
content: 'ordinary file',
expectedCalls: ['file:result.txt:text/plain'],
expectedOutcome: 'sent',
}, {
name: 'definitive image rejection',
suffix: 'image-fallback-route',
fileName: 'result.webp',
content: Buffer.from([4, 5, 6]),
imageErrorCode: 'artifact-provider-rejected',
expectedCalls: ['image:result.webp:image/webp', 'file:result.webp:image/webp'],
expectedOutcome: 'sent',
}, {
name: 'uncertain image result',
suffix: 'image-uncertain-route',
fileName: 'result.gif',
content: Buffer.from([7, 8, 9]),
imageErrorCode: 'artifact-delivery-uncertain',
expectedCalls: ['image:result.gif:image/gif'],
expectedOutcome: 'unknown',
}];
for (const scenario of scenarios) {
const { artifact } = await committedArtifact(t, scenario);
const calls = [];
const bot = {
sendText: async () => ({ key: { id: `text-${scenario.suffix}` } }),
sendImage: async (_target, file) => {
calls.push(`image:${file.fileName}:${file.mediaType}`);
if (scenario.imageErrorCode) {
const error = new Error(scenario.imageErrorCode);
error.code = scenario.imageErrorCode;
throw error;
}
return { key: { id: `image-${scenario.suffix}` } };
},
sendFile: async (_target, file) => {
calls.push(`file:${file.fileName}:${file.mediaType}`);
return { key: { id: `file-${scenario.suffix}` } };
},
};
const bridge = new WhatsappHarnessBridge({
bot,
state: artifactState(`session-${scenario.suffix}`),
logger: { warn() {}, error() {} },
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onArtifact(artifact);
return '图片已生成。';
},
},
});
const receipt = await bridge.accept({
messageId: `whatsapp:artifact-${scenario.suffix}`,
senderId: '16505550999@s.whatsapp.net',
kind: 'direct',
conversationId: `whatsapp-artifact-${scenario.suffix}`,
content: '生成结果',
addressed: true,
replyTarget: { jid: '16505550999@s.whatsapp.net' },
});
assert.deepEqual(calls, scenario.expectedCalls, scenario.name);
assert.equal(receipt.artifacts[0].outcome, scenario.expectedOutcome, scenario.name);
}
});
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;
@ -531,6 +1223,7 @@ test('WhatsApp reconnect RPC sends tests only for the connected target and keeps
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
setAccessPolicy: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
@ -596,6 +1289,7 @@ test('WhatsApp RPC never sends a connection test after reconnect is cancelled',
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
@ -620,6 +1314,7 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
let resolveReady;
const ready = new Promise((resolve) => { resolveReady = resolve; });
const deletedAuth = [];
const appliedPolicies = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
@ -637,6 +1332,10 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
},
start: async () => {},
stop: async () => {},
setAccessPolicy: (value) => appliedPolicies.push({
accessMode: value.accessMode,
allowedNumbers: value.allowedNumbers,
}),
}),
deleteAuth: async (name) => deletedAuth.push(name),
});
@ -657,7 +1356,32 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
}
assert.equal(status.ok, true);
assert.equal(status.value.bots[0].connected, true);
assert.deepEqual(status.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.selfOnly,
allowedNumbers: [],
});
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999'],
});
assert.equal(updated.ok, true);
assert.deepEqual(updated.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.deepEqual(appliedPolicies, [{
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
}]);
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: 'compatible',
allowedNumbers: [],
});
assert.equal(invalidPolicy.ok, false);
assert.equal(invalidPolicy.error.code, 'bad-request');
assert.equal(sessionOptions.signal.aborted, false);
assert.deepEqual(deletedAuth, []);
});

View file

@ -11,6 +11,7 @@ import {
inject as clientInject,
} from '../plugin-src/client/index.js';
import { CredentialBindingPanel } from '../plugin-src/client/credential-binding.js';
import { ChannelListHeading } from '../plugin-src/client/channel-card-meta.js';
import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js';
import {
AccountCard as DingtalkAccountCard,
@ -235,11 +236,11 @@ test('Feishu bot cards place the application identifier under the bot name', asy
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 3);
assert.doesNotMatch(markup, /连接状态:|bxf-divider/);
assert.doesNotMatch(markup, /custom-bot-avatar/);
assert.equal((markup.match(/class="bxf-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /消息通道|dim-botMetric/);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, />应用标识<|>飞书机器人</);
assert.match(styles, /\.bxf-statusGrid \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
assert.doesNotMatch(styles, /\.bxf-statusGrid|\.bxf-metric/);
});
test('Feishu keeps its heading controls on one row without a plus icon', async () => {
@ -369,9 +370,24 @@ test('bot list headings omit the total already shown by the online badge', async
for (const source of sources) {
assert.doesNotMatch(source, /length} 个/);
assert.match(source, /ChannelListHeading/);
}
});
test('channel connection details live in an accessible heading tooltip', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
const markup = renderToStaticMarkup(React.createElement(ChannelListHeading, {
className: 'dxw-listHeading',
title: '已接入的微信账号',
connectionLabel: 'iLink 长轮询',
}));
assert.match(markup, /<h3>已接入的微信账号<\/h3>/);
assert.match(markup, /aria-label="查看消息通道说明"/);
assert.match(markup, /role="tooltip"><span>消息通道<\/span><strong>iLink 长轮询<\/strong>/);
assert.match(styles, /\.dim-panel \.dim-channelHelp:hover \.dim-channelTooltip, \.dim-panel \.dim-channelHelp:focus-within \.dim-channelTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
});
test('all channel settings states use the DingTalk page treatment', async () => {
const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
readFile(STYLES_URL, 'utf8'),
@ -398,7 +414,6 @@ test('all channel settings states use the DingTalk page treatment', async () =>
'dim-emptyView',
'dim-qrLayout',
'dim-inlineError',
'dim-listHeading',
'dim-confirm',
]) {
assert.match(source, new RegExp(className));
@ -449,7 +464,8 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
assert.match(accountMarkup, /class="dim-presetSelect"/);
assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/);
assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(accountMarkup, /class="dim-botHealthGroup"[^]*class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(accountMarkup, /消息通道|dim-botMetric/);
assert.doesNotMatch(accountMarkup, /收到 \/ 回复/);
});
@ -464,7 +480,8 @@ test('Enterprise WeChat cards reuse the rail logo and compact action treatment',
}));
assert.match(markup, /data-im-channel-logo="wecom"/);
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, /class="dim-lastChecked"><span>最近检查<\/span>/);
assert.doesNotMatch(markup, /消息通道|dim-botMetric/);
});
test('DingTalk bot cards omit the redundant received and replied metric', () => {
@ -485,11 +502,11 @@ test('DingTalk bot cards omit the redundant received and replied metric', () =>
assert.match(markup, /class="ddt-card dim-botCard"/);
assert.match(markup, /class="ddt-health dim-botHealth"/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, /class="dim-lastChecked"><span>最近检查<\/span>/);
assert.match(markup, /class="ddt-accountFooter dim-cardFooter"/);
assert.doesNotMatch(markup, /dim-cardSummary|Stream 长连接运行正常/);
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.doesNotMatch(markup, /消息通道|dim-botMetric/);
assert.doesNotMatch(markup, /收到 \/ 回复/);
});
@ -562,10 +579,10 @@ test('all channel bot cards use the DingTalk card treatment', async () => {
assert.match(styles, /\.dim-panel \.dim-botCardTop \{[^}]*align-items: flex-start;[^}]*gap: 12px;/);
assert.match(styles, /\.dim-panel \.dim-botAvatar \{[^}]*width: 38px;[^}]*height: 38px;[^}]*border-radius: 11px;/);
assert.match(styles, /\.dim-panel \.dim-botName h3 \{[^}]*font-size: 15px;/);
assert.match(styles, /\.dim-panel \.dim-botHealthGroup \{[^}]*display: grid;[^}]*justify-items: end;[^}]*gap: 5px;/);
assert.match(styles, /\.dim-panel \.dim-botCard \.dim-botHealth \{[^}]*background: transparent;[^}]*font-size: 12px;[^}]*font-weight: 400;/);
assert.match(styles, /\.dim-panel \.dim-botMetrics \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);[^}]*gap: 8px;[^}]*margin: 6px 0;/);
assert.match(styles, /\.dim-panel \.dim-botMetric \{[^}]*padding: 6px 8px;[^}]*border: 0;[^}]*border-radius: 9px;/);
assert.match(styles, /\.dim-panel \.dim-botMetric dd \{[^}]*margin: 3px 0 0;[^}]*font-size: 12px;[^}]*font-weight: 400;/);
assert.match(styles, /\.dim-panel \.dim-lastChecked \{[^}]*display: inline-flex;[^}]*font-size: 11px;[^}]*white-space: nowrap;/);
assert.doesNotMatch(styles, /\.dim-panel \.dim-botMetrics|\.dim-panel \.dim-botMetric/);
});
test('bot cards keep the full workspace path on its own single line', async () => {
@ -779,7 +796,7 @@ test('all nine channel settings and connected cards render English copy', () =>
];
const cardMarkup = cards.map(renderToStaticMarkup).join('\n');
assert.match(cardMarkup, /Connected/);
assert.match(cardMarkup, /Message channel/);
assert.doesNotMatch(cardMarkup, /Message channel/);
assert.match(cardMarkup, /Last checked/);
assert.match(cardMarkup, /Check connection/);
assert.match(cardMarkup, /Remove connection/);

View file

@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
artifactOutcomeForError,
createArtifactFailureReceipt,
createDeliveryReceipt,
createTextDeliveryBlock,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from '../src/channels/shared/semantic/delivery.mjs';
test('text DeliveryBlocks preserve explicit format and legacy strings default to plain', () => {
const legacy = createTextDeliveryBlock('legacy *text*');
const markdown = createTextDeliveryBlock({
kind: 'text',
text: '# Harness answer',
format: 'markdown',
});
assert.deepEqual(legacy, {
kind: 'text',
text: 'legacy *text*',
format: 'plain',
});
assert.deepEqual(markdown, {
kind: 'text',
text: '# Harness answer',
format: 'markdown',
});
assert.equal(Object.isFrozen(legacy), true);
assert.equal(Object.isFrozen(markdown), true);
assert.throws(
() => createTextDeliveryBlock({ kind: 'text', text: 'x', format: 'html' }),
/plain or markdown/,
);
assert.throws(() => createTextDeliveryBlock(' '), /non-empty text/);
});
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({ body: { msgid: 'wecom-one' } }), ['wecom-one']);
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' },
],
});
});
test('DeliveryReceipt optionally preserves final delivery outcome without changing legacy receipts', () => {
const legacy = createDeliveryReceipt({
deliveryId: 'legacy',
presentation: 'telegram-text',
});
const uncertain = createDeliveryReceipt({
deliveryId: 'rich',
presentation: 'telegram-rich-final',
deliveryOutcome: 'unknown',
reason: 'telegram-timeout',
});
assert.equal(Object.hasOwn(legacy, 'deliveryOutcome'), false);
assert.deepEqual(uncertain, {
schemaVersion: 1,
deliveryId: 'rich',
presentation: 'telegram-rich-final',
providerMessageIds: [],
deliveryOutcome: 'unknown',
reason: 'telegram-timeout',
artifacts: [],
});
assert.throws(() => createDeliveryReceipt({
deliveryId: 'invalid',
presentation: 'telegram-rich-final',
deliveryOutcome: 'maybe',
}), /deliveryOutcome/);
});

View file

@ -1,4 +1,7 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import test from 'node:test';
import {
@ -88,14 +91,16 @@ test('Host control executor refuses idle, replaced, closed, and foreign turns wi
});
test('Host executors preserve HTTP fallback when AgentRegistry or attachment is absent', async () => {
assert.deepEqual(createHarnessSessionExecutors({}), {
controlExecutor: undefined,
sessionMaintenanceExecutor: undefined,
});
assert.deepEqual(createHarnessSessionExecutors({ get() { throw new Error('not injected'); } }), {
controlExecutor: undefined,
sessionMaintenanceExecutor: undefined,
const withoutRegistry = createHarnessSessionExecutors({});
assert.equal(withoutRegistry.controlExecutor, undefined);
assert.equal(withoutRegistry.sessionMaintenanceExecutor, undefined);
assert.equal(typeof withoutRegistry.fileIngressExecutor, 'function');
const inaccessibleRegistry = createHarnessSessionExecutors({
get() { throw new Error('not injected'); },
});
assert.equal(inaccessibleRegistry.controlExecutor, undefined);
assert.equal(inaccessibleRegistry.sessionMaintenanceExecutor, undefined);
assert.equal(typeof inaccessibleRegistry.fileIngressExecutor, 'function');
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(
contextWith({ get: () => undefined }),
@ -115,6 +120,63 @@ test('Host executors preserve HTTP fallback when AgentRegistry or attachment is
assert.equal(operated, true);
});
test('Host file ingress stages bytes in the exact attached Session cwd', async (t) => {
const defaultWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-default-cwd-'));
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-session-cwd-'));
t.after(() => Promise.all([
rm(defaultWorkspace, { recursive: true, force: true }),
rm(sessionWorkspace, { recursive: true, force: true }),
]));
const agent = {
session: {
header: { id: 'session-exact-cwd', cwd: sessionWorkspace },
events: [],
},
};
const registry = {
get: (sessionId) => sessionId === 'session-exact-cwd' ? agent : undefined,
};
const { fileIngressExecutor } = createHarnessSessionExecutors(contextWith(registry));
const staged = await fileIngressExecutor({
sessionId: 'session-exact-cwd',
workspace: defaultWorkspace,
files: [{ name: 'attached.txt', data: Buffer.from('session bytes') }],
});
assert.equal(staged.files.length, 1);
assert.equal(staged.files[0].name, 'attached.txt');
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-attached\.txt$/);
assert.equal(
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
'session bytes',
);
await assert.rejects(
readFile(resolve(defaultWorkspace, staged.files[0].path)),
/ENOENT/,
'the plugin/default workspace must not receive another Session attachment',
);
await staged.cleanup();
});
test('Host file ingress uses session.list cwd while the Session is still cold', async (t) => {
const sessionWorkspace = await mkdtemp(join(tmpdir(), 'dsh-im-cold-session-cwd-'));
t.after(() => rm(sessionWorkspace, { recursive: true, force: true }));
const { fileIngressExecutor } = createHarnessSessionExecutors({});
const staged = await fileIngressExecutor({
sessionId: 'session-cold',
workspace: sessionWorkspace,
files: [{ name: 'cold.txt', data: Buffer.from('cold session bytes') }],
});
assert.equal(
await readFile(resolve(sessionWorkspace, staged.files[0].path), 'utf8'),
'cold session bytes',
);
await staged.cleanup();
});
test('Host maintenance executor claims idle synchronously and reports busy with a stable code', async () => {
const maintenanceController = new AbortController();
let operationStarted = false;

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' }],

172
test/inbound-file.test.mjs Normal file
View file

@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import {
mkdtemp,
readdir,
readFile,
rm,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { Readable } from 'node:stream';
import test from 'node:test';
import {
InboundFileError,
stageInboundFiles,
} from '../src/channels/shared/inbound-file.mjs';
async function workspace(t) {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-inbound-file-'));
t.after(() => rm(directory, { recursive: true, force: true }));
return directory;
}
function absoluteStagedPath(root, file) {
assert.equal(isAbsolute(file.path), false, 'Harness receives a workspace-relative path');
const path = resolve(root, file.path);
assert.equal(path.startsWith(`${resolve(root)}/`), true, 'staged path stays in the Session cwd');
return path;
}
test('stageInboundFiles preserves exact bytes, including a zero-byte file', async (t) => {
const root = await workspace(t);
const binary = Buffer.from([0x00, 0xff, 0x01, 0x80, 0x0a, 0x0d]);
const staged = await stageInboundFiles({
files: [
{ name: 'binary.dat', data: binary, mediaType: 'application/octet-stream' },
{ name: 'empty.txt', data: Buffer.alloc(0), mediaType: 'text/plain' },
],
}, { workspace: root });
assert.deepEqual(staged.files.map(({ name, mediaType }) => ({ name, mediaType })), [
{ name: 'binary.dat', mediaType: 'application/octet-stream' },
{ name: 'empty.txt', mediaType: 'text/plain' },
]);
assert.deepEqual(
await readFile(absoluteStagedPath(root, staged.files[0])),
binary,
);
assert.deepEqual(
await readFile(absoluteStagedPath(root, staged.files[1])),
Buffer.alloc(0),
);
const turnDirectory = resolve(root, staged.files[0].path, '..');
await staged.cleanup();
await assert.rejects(readFile(resolve(turnDirectory, '01-binary.dat')), /ENOENT/);
});
test('stageInboundFiles writes async streams without changing their bytes', async (t) => {
const root = await workspace(t);
const chunks = [
Buffer.from([0xde, 0xad]),
new Uint8Array([0xbe, 0xef]),
Buffer.from('stream tail'),
];
const expected = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
const staged = await stageInboundFiles({
files: [{
name: 'stream.bin',
load: async () => ({
stream: Readable.from(chunks),
name: 'provider-stream.bin',
mimetype: 'application/x-provider-stream',
}),
}],
}, { workspace: root });
assert.deepEqual(staged.files, [{
name: 'provider-stream.bin',
path: staged.files[0].path,
mediaType: 'application/x-provider-stream',
}]);
assert.deepEqual(await readFile(absoluteStagedPath(root, staged.files[0])), expected);
await staged.cleanup();
});
test('stageInboundFiles reports failures raised while streaming provider bytes as file downloads', async (t) => {
const root = await workspace(t);
await assert.rejects(stageInboundFiles({
files: [{
name: 'broken.bin',
load: async () => ({
stream: Readable.from((async function* brokenStream() {
yield Buffer.from('partial');
throw new Error('provider stream disconnected');
}())),
}),
}],
}, { workspace: root }), (error) => (
error instanceof InboundFileError
&& error.code === 'inbound-file-download-failed'
&& error.userMessage === '文件下载失败,请重新发送后再试。'
&& error.cause?.message === 'provider stream disconnected'
));
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
});
test('stageInboundFiles removes the whole partial batch when any file download fails', async (t) => {
const root = await workspace(t);
let secondAttempted = false;
await assert.rejects(
stageInboundFiles({
files: [
{ name: 'first.txt', data: Buffer.from('must be removed') },
{
name: 'second.txt',
async load() {
secondAttempted = true;
throw new Error('private signed URL expired');
},
},
{
name: 'third.txt',
async load() {
assert.fail('files after a failed member must not be downloaded');
},
},
],
}, { workspace: root }),
(error) => error instanceof InboundFileError
&& error.code === 'inbound-file-download-failed'
&& error.userMessage === '文件下载失败,请重新发送后再试。',
);
assert.equal(secondAttempted, true);
assert.deepEqual(await readdir(join(root, '.dsh-im', 'inbound')), []);
});
test('stageInboundFiles keeps display names but makes traversal-like storage names path-safe', async (t) => {
const root = await workspace(t);
const staged = await stageInboundFiles({
files: [{
name: '../../private\\folder/..\\report:?*.txt\u0000\n',
data: Buffer.from('safe'),
}],
}, { workspace: root });
assert.equal(staged.files[0].name, 'report:?*.txt');
assert.match(staged.files[0].path, /^\.dsh-im\/inbound\/turn-[^/]+\/01-report___\.txt$/);
assert.equal(await readFile(absoluteStagedPath(root, staged.files[0]), 'utf8'), 'safe');
await staged.cleanup();
});
test('stageInboundFiles adds no count, extension, MIME, or declared-size acceptance limits', async (t) => {
const root = await workspace(t);
const sources = Array.from({ length: 33 }, (_, index) => ({
name: index === 0 ? 'no-extension' : `unknown-${index}.unsupported-${index}`,
mediaType: `application/x-unknown-${index}`,
size: Number.MAX_SAFE_INTEGER,
data: Buffer.from(`file-${index}`),
}));
const staged = await stageInboundFiles({ files: sources }, { workspace: root });
assert.equal(staged.files.length, 33);
assert.deepEqual(
await Promise.all(staged.files.map((file) => readFile(absoluteStagedPath(root, file), 'utf8'))),
sources.map((_source, index) => `file-${index}`),
);
assert.equal(staged.files[0].name, 'no-extension');
assert.equal(staged.files.at(-1).mediaType, 'application/x-unknown-32');
await staged.cleanup();
});

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