mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 22:03:18 +08:00
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:
commit
044ab74c28
167 changed files with 22497 additions and 1793 deletions
346
test/artifact-delivery.test.mjs
Normal file
346
test/artifact-delivery.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
64
test/channels/discord/production.test.mjs
Normal file
64
test/channels/discord/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, '先创建再观察:');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 长连接运行正常/);
|
||||
|
|
|
|||
201
test/channels/qq/markdown-reply.test.mjs
Normal file
201
test/channels/qq/markdown-reply.test.mjs
Normal 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, []);
|
||||
});
|
||||
72
test/channels/qq/production.test.mjs
Normal file
72
test/channels/qq/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
228
test/channels/shared/delivery-message-ids.test.mjs
Normal file
228
test/channels/shared/delivery-message-ids.test.mjs
Normal 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',
|
||||
]);
|
||||
});
|
||||
|
|
@ -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' };
|
||||
|
|
|
|||
127
test/channels/shared/i18n.test.mjs
Normal file
127
test/channels/shared/i18n.test.mjs
Normal 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');
|
||||
});
|
||||
72
test/channels/shared/production.test.mjs
Normal file
72
test/channels/shared/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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, />移除接入</);
|
||||
});
|
||||
|
|
|
|||
78
test/channels/slack/production.test.mjs
Normal file
78
test/channels/slack/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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, />访问设置</);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
949
test/channels/telegram/rich-message.test.mjs
Normal file
949
test/channels/telegram/rich-message.test.mjs
Normal 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>',
|
||||
'<tg-thinking>encoded</tg-thinking>',
|
||||
].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&b=2\)/);
|
||||
assert.doesNotMatch(rich, /<tg-thinking>/);
|
||||
assert.match(rich, /<tg-thinking>hidden<\/tg-thinking>/);
|
||||
assert.match(rich, /&lt;tg-thinking&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, '<b>**answer**</b>');
|
||||
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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 长连接运行正常/);
|
||||
|
|
|
|||
72
test/channels/wecom/production.test.mjs
Normal file
72
test/channels/wecom/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
76
test/channels/weixin/production.test.mjs
Normal file
76
test/channels/weixin/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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\}/);
|
||||
});
|
||||
|
|
|
|||
75
test/channels/whatsapp/production.test.mjs
Normal file
75
test/channels/whatsapp/production.test.mjs
Normal 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();
|
||||
});
|
||||
|
|
@ -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, []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
161
test/delivery-receipt.test.mjs
Normal file
161
test/delivery-receipt.test.mjs
Normal 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/);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
172
test/inbound-file.test.mjs
Normal 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();
|
||||
});
|
||||
391
test/outbound-artifact.test.mjs
Normal file
391
test/outbound-artifact.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue