mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 06:33:18 +08:00
feat: add native result-file delivery
This commit is contained in:
parent
2803bbcbab
commit
d472dd5a75
65 changed files with 10205 additions and 255 deletions
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();
|
||||
});
|
||||
|
|
@ -171,6 +171,243 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
|||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=one',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-ticket' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
const plaintext = Buffer.from('weixin-native-file');
|
||||
const result = await api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-1',
|
||||
file: {
|
||||
artifactId: 'artifact-one',
|
||||
deliveryKey: 'session:turn:artifact-one',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: plaintext,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
const ticket = JSON.parse(calls[0].init.body);
|
||||
assert.equal(ticket.media_type, 3);
|
||||
assert.equal(ticket.to_user_id, 'wx-user');
|
||||
assert.equal(ticket.rawsize, plaintext.length);
|
||||
assert.equal(ticket.rawfilemd5, 'ae8e1f4207c0468828419884f7329cb8');
|
||||
assert.equal(ticket.filesize, 32);
|
||||
assert.equal(ticket.no_need_thumb, true);
|
||||
assert.match(ticket.aeskey, /^[0-9a-f]{32}$/);
|
||||
|
||||
assert.equal(calls[1].init.headers.Authorization, undefined);
|
||||
assert.equal(calls[1].init.headers['content-type'], 'application/octet-stream');
|
||||
assert.equal(calls[1].init.body.byteLength, 32);
|
||||
assert.equal(
|
||||
decryptWeixinImage(calls[1].init.body, Buffer.from(ticket.aeskey, 'hex')).equals(plaintext),
|
||||
true,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(calls[2].init.body).msg;
|
||||
assert.equal(sent.context_token, 'message-context');
|
||||
assert.equal(sent.run_id, 'run-1');
|
||||
assert.match(sent.client_id, /^dsh-weixin-[0-9a-f]{32}$/);
|
||||
assert.equal(result.messageId, sent.client_id);
|
||||
assert.deepEqual(sent.item_list, [{
|
||||
type: 4,
|
||||
file_item: {
|
||||
media: {
|
||||
encrypt_query_param: 'download-ticket',
|
||||
aes_key: Buffer.from(ticket.aeskey).toString('base64'),
|
||||
encrypt_type: 1,
|
||||
},
|
||||
file_name: 'result.txt',
|
||||
len: String(plaintext.length),
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
function weixinFileRequest(overrides = {}) {
|
||||
return {
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: {
|
||||
artifactId: 'artifact-error-case',
|
||||
deliveryKey: 'session:turn:artifact-error-case',
|
||||
fileName: 'result.txt',
|
||||
mediaType: 'text/plain',
|
||||
bytes: Buffer.from('weixin-error-case'),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function weixinFileFetch(finalResponse) {
|
||||
return async (url, init) => {
|
||||
if (url.pathname.endsWith('/getuploadurl')) {
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://novac2c.cdn.weixin.qq.com/c2c/upload?ticket=error-case',
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/c2c/upload') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-encrypted-param': 'download-error-case' },
|
||||
});
|
||||
}
|
||||
return finalResponse(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
test('sendFile marks every ambiguous sendmessage result as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'network failure',
|
||||
finalResponse: async () => { throw new TypeError('private socket detail'); },
|
||||
},
|
||||
{
|
||||
name: 'timeout',
|
||||
finalResponse: async () => {
|
||||
throw new WeixinApiError('timeout', 'private timeout detail');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
finalResponse: async () => new Response('{not-json', { status: 200 }),
|
||||
},
|
||||
{
|
||||
name: 'HTTP 5xx',
|
||||
finalResponse: async () => jsonResponse({ ret: -1 }, { status: 503 }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(scenario.finalResponse),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-delivery-uncertain'
|
||||
&& !error.message.includes('private'),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('sendFile maps definitive sendmessage rejection statuses without treating them as uncertain', async (t) => {
|
||||
const cases = [
|
||||
{ name: 'permission', status: 403, code: 'artifact-permission-required' },
|
||||
{ name: 'too large', status: 413, code: 'artifact-too-large' },
|
||||
{ name: 'rate limited', status: 429, code: 'artifact-rate-limited' },
|
||||
{ name: 'provider rejected', status: 400, code: 'artifact-provider-rejected' },
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse(
|
||||
{ ret: scenario.status },
|
||||
{ status: scenario.status },
|
||||
)),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === scenario.code,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const permissionApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: 403 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
permissionApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-permission-required'
|
||||
&& error.providerCode === '403',
|
||||
);
|
||||
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => jsonResponse({ ret: -2001 })),
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-rejected'
|
||||
&& error.providerCode === '-2001',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile preserves caller abort and never marks a pre-send preparation failure uncertain', async () => {
|
||||
let preparationCalls = 0;
|
||||
const preparationApi = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
preparationCalls += 1;
|
||||
throw new TypeError('private upload preparation failure');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
preparationApi.sendFile(weixinFileRequest()),
|
||||
(error) => error.code === 'artifact-provider-failed'
|
||||
&& error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
assert.equal(preparationCalls, 1);
|
||||
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller stopped the turn');
|
||||
const abortApi = createWeixinApi({
|
||||
fetchImpl: weixinFileFetch(async () => {
|
||||
controller.abort(reason);
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
abortApi.sendFile(weixinFileRequest({ signal: controller.signal })),
|
||||
(error) => error === reason && error.code !== 'artifact-delivery-uncertain',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile rejects a provider upload URL outside the canonical Weixin CDN', async () => {
|
||||
let calls = 0;
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return jsonResponse({
|
||||
ret: 0,
|
||||
upload_full_url: 'https://attacker.example/c2c/upload?ticket=one',
|
||||
});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
api.sendFile({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
file: { fileName: 'result.txt', bytes: Buffer.from('safe') },
|
||||
}),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'untrusted-upload-url',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
OUTBOUND_ARTIFACT_TOOL,
|
||||
OutboundArtifactRegistry,
|
||||
createOutboundArtifactTool,
|
||||
releaseOutboundArtifact,
|
||||
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -69,6 +78,36 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
async function committedArtifact(t, fileName, content) {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-weixin-artifact-'));
|
||||
t.after(() => rm(workspace, { recursive: true, force: true }));
|
||||
const registry = new OutboundArtifactRegistry({ uuid: () => 'weixin-artifact-one' });
|
||||
t.after(() => registry.clear());
|
||||
const agent = {
|
||||
session: {
|
||||
header: { id: 'artifact-session', cwd: workspace },
|
||||
events: [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'user/message', data: { turn: 1, source: { rpcId: 'artifact-rpc' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeFile(join(workspace, fileName), content);
|
||||
const tool = createOutboundArtifactTool({ registry });
|
||||
const exec = {
|
||||
name: OUTBOUND_ARTIFACT_TOOL,
|
||||
callId: 'weixin-artifact-call',
|
||||
rootCallId: 'weixin-artifact-call',
|
||||
token: Symbol('weixin-artifact-call'),
|
||||
agent,
|
||||
};
|
||||
await tool.definition.execute({ path: fileName }, exec);
|
||||
tool.onResult(exec, { isError: false });
|
||||
const artifact = registry.take('artifact-session', 1)[0];
|
||||
t.after(() => releaseOutboundArtifact(artifact));
|
||||
return artifact;
|
||||
}
|
||||
|
||||
test('Weixin remembers any authorized private inbound as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
|
|
@ -98,6 +137,144 @@ test('Weixin remembers any authorized private inbound as a connection-test targe
|
|||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin returns a registered result file with native context after its existing text path', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'result.txt', 'weixin-result');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact');
|
||||
const order = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
order.push(`text:${text}`);
|
||||
return { messageId: 'weixin-text-one' };
|
||||
},
|
||||
sendFile: async (request) => {
|
||||
order.push(`file:${request.file.fileName}`);
|
||||
assert.equal(request.toUserId, 'owner-user');
|
||||
assert.equal(request.contextToken, 'context-weixin-artifact');
|
||||
assert.equal(request.runId, 'run-artifact');
|
||||
assert.equal(request.file.bytes.toString(), 'weixin-result');
|
||||
return { messageId: 'weixin-file-one' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message(
|
||||
'weixin-artifact',
|
||||
'生成文件',
|
||||
{ run_id: 'run-artifact' },
|
||||
));
|
||||
|
||||
assert.deepEqual(order, ['text:结果文件已生成。', 'file:result.txt']);
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt, {
|
||||
schemaVersion: 1,
|
||||
deliveryId: 'weixin-artifact',
|
||||
presentation: 'weixin-text-and-files',
|
||||
providerMessageIds: ['weixin-text-one', 'weixin-file-one'],
|
||||
artifacts: [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin still attempts a registered file when the final text transport fails', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-text-failed.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-text-failed');
|
||||
const files = [];
|
||||
let textAttempts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async () => {
|
||||
textAttempts += 1;
|
||||
throw new Error('private text failure');
|
||||
},
|
||||
sendFile: async ({ file }) => {
|
||||
files.push(file.fileName);
|
||||
return { messageId: 'weixin-file-after-text-failure' };
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '文字回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-text-failed', '生成文件'));
|
||||
|
||||
assert.deepEqual(files, ['weixin-text-failed.txt']);
|
||||
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
|
||||
assert.equal(bridge.status.artifactsSent, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['weixin-file-after-text-failure']);
|
||||
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
|
||||
});
|
||||
|
||||
test('Weixin tells users to inspect the chat instead of retrying an uncertain file delivery', async (t) => {
|
||||
const artifact = await committedArtifact(t, 'weixin-uncertain.txt', 'weixin-file');
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-artifact-uncertain');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
inboundImages: () => [],
|
||||
sendText: async ({ text }) => {
|
||||
sent.push(text);
|
||||
return { messageId: `weixin-text-${sent.length}` };
|
||||
},
|
||||
sendFile: async () => {
|
||||
const error = new Error('private provider transport detail');
|
||||
error.code = 'artifact-delivery-uncertain';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
await options.onArtifact(artifact);
|
||||
return '';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const receipt = await bridge.accept(message('weixin-artifact-uncertain', '生成文件'));
|
||||
|
||||
assert.equal(
|
||||
sent.at(-1),
|
||||
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
|
||||
);
|
||||
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
|
||||
assert.equal(bridge.status.artifactSendErrors, 1);
|
||||
assert.deepEqual(receipt.artifacts, [{
|
||||
artifactId: 'weixin-artifact-one',
|
||||
outcome: 'unknown',
|
||||
reason: 'artifact-delivery-uncertain',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue