mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 06:33:18 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
68
test/channels/weixin/connection-supervisor.test.mjs
Normal file
68
test/channels/weixin/connection-supervisor.test.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/weixin/connection-supervisor.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function scheduler() {
|
||||
const pending = [];
|
||||
return {
|
||||
pending,
|
||||
setTimeoutImpl(callback, delay) {
|
||||
const handle = { callback, delay, cancelled: false, unref() {} };
|
||||
pending.push(handle);
|
||||
return handle;
|
||||
},
|
||||
clearTimeoutImpl(handle) { handle.cancelled = true; },
|
||||
async runNext() {
|
||||
const handle = pending.shift();
|
||||
assert.ok(handle);
|
||||
assert.equal(handle.cancelled, false);
|
||||
handle.callback();
|
||||
await flush();
|
||||
await flush();
|
||||
return handle.delay;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('supervisor waits for the in-process Harness API and retries offline Weixin accounts', async () => {
|
||||
const timers = scheduler();
|
||||
let healthChecks = 0;
|
||||
let initializations = 0;
|
||||
const supervisor = new ConnectionSupervisor({
|
||||
harness: {
|
||||
async ensureRunning() {
|
||||
healthChecks += 1;
|
||||
if (healthChecks === 1) throw new Error('Host is still starting');
|
||||
},
|
||||
},
|
||||
controller: {
|
||||
async initialize() {
|
||||
initializations += 1;
|
||||
return {
|
||||
totals: { configured: 1, connected: initializations > 1 ? 1 : 0 },
|
||||
};
|
||||
},
|
||||
status() {},
|
||||
},
|
||||
logger: { warn() {} },
|
||||
retryDelaysMs: [5, 10],
|
||||
healthyIntervalMs: 100,
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
}).start();
|
||||
|
||||
assert.equal(await timers.runNext(), 0);
|
||||
assert.equal(timers.pending[0].delay, 5);
|
||||
await timers.runNext();
|
||||
assert.equal(initializations, 1);
|
||||
assert.equal(timers.pending[0].delay, 10);
|
||||
await timers.runNext();
|
||||
assert.equal(initializations, 2);
|
||||
assert.equal((await supervisor.ready).totals.connected, 0);
|
||||
assert.equal(timers.pending[0].delay, 100);
|
||||
await supervisor.close();
|
||||
assert.equal(timers.pending[0].cancelled, true);
|
||||
});
|
||||
48
test/channels/weixin/harness-client.test.mjs
Normal file
48
test/channels/weixin/harness-client.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
||||
|
||||
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
|
||||
const first = tracker.consume([
|
||||
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
|
||||
{ event: {
|
||||
seq: 4,
|
||||
type: 'user/message',
|
||||
data: { turn: 9, source: { rpcId: 'weixin-prompt' } },
|
||||
} },
|
||||
{ event: {
|
||||
seq: 5,
|
||||
type: 'assistant/chunk',
|
||||
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '微信' } },
|
||||
} },
|
||||
]);
|
||||
assert.deepEqual(first, { type: 'text', text: '微信' });
|
||||
tracker.consume([
|
||||
{ event: {
|
||||
seq: 6,
|
||||
type: 'assistant/message',
|
||||
data: { turn: 9, message: { content: [{ type: 'text', text: '微信回复完成' }] } },
|
||||
} },
|
||||
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
|
||||
]);
|
||||
assert.equal(tracker.finished, true);
|
||||
assert.equal(tracker.answer, '微信回复完成');
|
||||
});
|
||||
|
||||
test('reply tracker ignores interleaved turns and older events', () => {
|
||||
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
|
||||
tracker.consume([
|
||||
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
|
||||
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
|
||||
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
|
||||
{ event: {
|
||||
seq: 13,
|
||||
type: 'assistant/message',
|
||||
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
|
||||
} },
|
||||
]);
|
||||
assert.equal(tracker.answer, '');
|
||||
assert.equal(tracker.finished, false);
|
||||
});
|
||||
92
test/channels/weixin/plugin-host.test.mjs
Normal file
92
test/channels/weixin/plugin-host.test.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
WEIXIN_ENDPOINTS,
|
||||
apply,
|
||||
createWeixinRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/weixin/index.mjs';
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
state: 'disconnected',
|
||||
bots: [],
|
||||
totals: { configured: 0, connected: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function controllerFixture() {
|
||||
const attempts = new Map();
|
||||
const controller = {
|
||||
status: () => snapshot(),
|
||||
startProvisioning: async () => {
|
||||
const value = {
|
||||
attemptId: 'attempt-1',
|
||||
status: 'pending',
|
||||
verificationUrl: 'https://liteapp.weixin.qq.com/q/test',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
pollIntervalMs: 1_000,
|
||||
};
|
||||
attempts.set(value.attemptId, value);
|
||||
return value;
|
||||
},
|
||||
registrationStatus: (id) => attempts.get(id) ?? null,
|
||||
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
|
||||
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
|
||||
reconnectBot: async () => snapshot(),
|
||||
deleteBot: async () => snapshot(),
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
test('Host plugin registers the Weixin RPC channel as loopback-only', async () => {
|
||||
let registration;
|
||||
const dispose = async () => {};
|
||||
const ctx = {
|
||||
connection: { rpc: { handle: (channel, handler, options) => {
|
||||
registration = { channel, handler, options };
|
||||
return dispose;
|
||||
} } },
|
||||
};
|
||||
const returned = await apply(ctx, { controller: controllerFixture() });
|
||||
assert.equal(returned, dispose);
|
||||
assert.equal(registration.channel, '/weixin');
|
||||
assert.deepEqual(registration.options, { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => {
|
||||
const controller = controllerFixture();
|
||||
const handler = createWeixinRpcHandler(controller, {
|
||||
encodeQr: async (url) => `data:image/png;base64,${Buffer.from(url).toString('base64')}`,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const begun = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {}, signal);
|
||||
assert.equal(begun.ok, true);
|
||||
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||
const verified = await handler(WEIXIN_ENDPOINTS.submitVerification, {
|
||||
attemptId: 'attempt-1', verifyCode: '123456',
|
||||
}, signal);
|
||||
assert.equal(verified.ok, true);
|
||||
assert.equal(verified.value.status, 'scanned');
|
||||
|
||||
const secretAttempt = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {
|
||||
bot_token: 'must-never-cross-the-browser-boundary',
|
||||
}, signal);
|
||||
assert.equal(secretAttempt.ok, false);
|
||||
assert.equal(secretAttempt.error.code, 'bad-request');
|
||||
assert.doesNotMatch(JSON.stringify(secretAttempt), /must-never-cross/);
|
||||
});
|
||||
|
||||
test('RPC requires explicit confirmation before removing a Weixin account', async () => {
|
||||
const handler = createWeixinRpcHandler(controllerFixture());
|
||||
const result = await handler(WEIXIN_ENDPOINTS.deleteBot, {
|
||||
botId: 'wx_0123456789abcdef01234567',
|
||||
confirm: false,
|
||||
}, new AbortController().signal);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'bad-request');
|
||||
});
|
||||
62
test/channels/weixin/stores.test.mjs
Normal file
62
test/channels/weixin/stores.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
deriveWeixinBotIdentity,
|
||||
WeixinConfigStore,
|
||||
} from '../../../src/channels/weixin/config-store.mjs';
|
||||
import { WeixinStateStore } from '../../../src/channels/weixin/state-store.mjs';
|
||||
|
||||
test('config store persists non-secret account facts atomically with restrictive permissions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-config-'));
|
||||
const path = join(root, 'nested', 'config.json');
|
||||
const store = await new WeixinConfigStore(path).load();
|
||||
const identity = deriveWeixinBotIdentity('account@im.bot');
|
||||
await store.save({
|
||||
...identity,
|
||||
accountId: 'account@im.bot',
|
||||
ownerUserId: 'owner-user',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const raw = await readFile(path, 'utf8');
|
||||
assert.match(raw, /"accountId": "account@im\.bot"/);
|
||||
assert.doesNotMatch(raw, /bot_token|secret-token/);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.deepEqual((await new WeixinConfigStore(path).load()).list(), store.list());
|
||||
});
|
||||
|
||||
test('config store rejects duplicate or tampered identities', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-invalid-'));
|
||||
const path = join(root, 'config.json');
|
||||
await writeFile(path, JSON.stringify({
|
||||
version: 1,
|
||||
accounts: [{
|
||||
botId: 'wx_000000000000000000000000',
|
||||
accountId: 'real@im.bot',
|
||||
tokenRef: 'DSH_WEIXIN_BOT_TOKEN_000000000000000000000000',
|
||||
ownerUserId: 'owner',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
}],
|
||||
}));
|
||||
await assert.rejects(new WeixinConfigStore(path).load(), /invalid account data/);
|
||||
});
|
||||
|
||||
test('state store retains sessions, deduplication, and the getUpdates cursor', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-state-'));
|
||||
const path = join(root, 'account', 'state.json');
|
||||
const state = await new WeixinStateStore(path).load();
|
||||
await state.setSession('p2p:user', 'session-1');
|
||||
await state.markSeen('message-1');
|
||||
await state.setGetUpdatesBuf('cursor-2');
|
||||
|
||||
const restored = await new WeixinStateStore(path).load();
|
||||
assert.equal(restored.sessionFor('p2p:user'), 'session-1');
|
||||
assert.equal(restored.hasSeen('message-1'), true);
|
||||
assert.equal(restored.getUpdatesBuf(), 'cursor-2');
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
});
|
||||
124
test/channels/weixin/weixin-api.test.mjs
Normal file
124
test/channels/weixin/weixin-api.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createWeixinApi,
|
||||
extractWeixinText,
|
||||
normalizeWeixinApiBaseUrl,
|
||||
splitWeixinText,
|
||||
WeixinApiError,
|
||||
} from '../../../src/channels/weixin/weixin-api.mjs';
|
||||
|
||||
function jsonResponse(value, init) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
test('QR login uses the Tencent iLink headers and keeps local tokens out of the URL', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return jsonResponse({
|
||||
qrcode: 'private-qr-token',
|
||||
qrcode_img_content: 'https://liteapp.weixin.qq.com/q/example',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const login = await api.beginLogin({ localTokens: [' token-a ', 'token-a', 'token-b'] });
|
||||
|
||||
assert.deepEqual(login, {
|
||||
qrcode: 'private-qr-token',
|
||||
qrcodeUrl: 'https://liteapp.weixin.qq.com/q/example',
|
||||
});
|
||||
assert.match(calls[0].url, /ilink\/bot\/get_bot_qrcode\?bot_type=3$/);
|
||||
assert.doesNotMatch(calls[0].url, /token-a|token-b/);
|
||||
assert.equal(calls[0].init.headers['iLink-App-Id'], 'bot');
|
||||
assert.equal(calls[0].init.headers['iLink-App-ClientVersion'], String((2 << 16) | (4 << 8) | 6));
|
||||
assert.equal(calls[0].init.headers.Authorization, undefined);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), { local_token_list: ['token-a', 'token-b'] });
|
||||
});
|
||||
|
||||
test('login polling submits a verification code only to an approved Weixin host', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return jsonResponse({ status: 'scaned' });
|
||||
},
|
||||
});
|
||||
|
||||
const status = await api.pollLogin({
|
||||
qrcode: 'secret-qr',
|
||||
baseUrl: 'https://shard.ilinkai.weixin.qq.com',
|
||||
verifyCode: '123456',
|
||||
});
|
||||
|
||||
assert.equal(status.status, 'scaned');
|
||||
assert.match(calls[0].url, /qrcode=secret-qr&verify_code=123456$/);
|
||||
assert.equal(calls[0].init.method, 'GET');
|
||||
assert.equal(calls[0].init.headers.AuthorizationType, undefined);
|
||||
|
||||
await assert.rejects(
|
||||
api.pollLogin({ qrcode: 'secret', baseUrl: 'https://attacker.test' }),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'untrusted-base-url',
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('sendText emits the iLink message envelope without reflecting the token in its body', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
await api.sendText({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
text: 'Harness reply',
|
||||
contextToken: 'message-context',
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
assert.equal(calls[0].init.headers.Authorization, 'Bearer host-only-token');
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.msg.to_user_id, 'wx-user');
|
||||
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/0.1.0');
|
||||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
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) => {
|
||||
init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||
}),
|
||||
});
|
||||
const result = await api.getUpdates({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'token',
|
||||
getUpdatesBuf: 'cursor',
|
||||
timeoutMs: 2,
|
||||
});
|
||||
assert.deepEqual(result, { ret: 0, msgs: [], get_updates_buf: 'cursor' });
|
||||
});
|
||||
|
||||
test('Weixin URL, inbound text, and reply chunk helpers enforce their narrow formats', () => {
|
||||
assert.equal(
|
||||
normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com/path'),
|
||||
'https://ilinkai.weixin.qq.com/path/',
|
||||
);
|
||||
assert.throws(() => normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com:444/'));
|
||||
assert.equal(extractWeixinText({ item_list: [{ type: 1, text_item: { text: ' 你好 ' } }] }), '你好');
|
||||
assert.equal(extractWeixinText({ item_list: [{ type: 3, voice_item: { text: '语音转写' } }] }), '语音转写');
|
||||
assert.deepEqual(splitWeixinText('abcdefgh', 5), ['abcde', 'fgh']);
|
||||
});
|
||||
113
test/channels/weixin/weixin-bridge.test.mjs
Normal file
113
test/channels/weixin/weixin-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
|
||||
function message(id, text, overrides = {}) {
|
||||
return {
|
||||
message_id: id,
|
||||
message_type: 1,
|
||||
from_user_id: 'owner-user',
|
||||
context_token: `context-${id}`,
|
||||
item_list: [{ type: 1, text_item: { text } }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stateFixture() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessions,
|
||||
seen,
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||||
clearSession: async (key) => sessions.delete(key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
|
||||
const sent = [];
|
||||
const asked = [];
|
||||
const fixture = stateFixture();
|
||||
const status = createWeixinBridgeStatus();
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async (sessionId) => sessionId === 'session-1',
|
||||
createSession: async () => 'session-1',
|
||||
ask: async (sessionId, text) => {
|
||||
asked.push({ sessionId, text });
|
||||
return 'Harness 的回答';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
});
|
||||
|
||||
await bridge.accept(message('1', '你好'));
|
||||
await bridge.accept(message('2', '继续'));
|
||||
|
||||
assert.deepEqual(asked, [
|
||||
{ sessionId: 'session-1', text: '你好' },
|
||||
{ sessionId: 'session-1', text: '继续' },
|
||||
]);
|
||||
assert.equal(fixture.sessions.get('p2p:owner-user'), 'session-1');
|
||||
assert.deepEqual(sent.map(({ toUserId, text, contextToken }) => ({ toUserId, text, contextToken })), [
|
||||
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-1' },
|
||||
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-2' },
|
||||
]);
|
||||
assert.equal(status.messagesReceived, 2);
|
||||
assert.equal(status.messagesReplied, 2);
|
||||
});
|
||||
|
||||
test('bridge rejects every user except the account owner returned by QR login', async () => {
|
||||
const fixture = stateFixture();
|
||||
let asked = 0;
|
||||
const status = createWeixinBridgeStatus();
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async () => assert.fail('unauthorized users must not receive a reply') },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ask: async () => { asked += 1; } },
|
||||
state: fixture.state,
|
||||
status,
|
||||
});
|
||||
|
||||
await bridge.accept(message('unauthorized', '越权', { from_user_id: 'other-user' }));
|
||||
assert.equal(asked, 0);
|
||||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('bridge commands are local and internal failures return a generic message', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'old-session');
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request.text) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { throw new Error('private path /secret and token-shaped detail'); },
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('new', '/new'));
|
||||
assert.equal(fixture.sessions.has('p2p:owner-user'), false);
|
||||
await bridge.accept(message('failure', '触发失败'));
|
||||
assert.match(sent.at(-1), /消息处理失败/);
|
||||
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
|
||||
});
|
||||
218
test/channels/weixin/weixin-controller.test.mjs
Normal file
218
test/channels/weixin/weixin-controller.test.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WeixinController } from '../../../src/channels/weixin/weixin-controller.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
async function waitFor(read, predicate, attempts = 100) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = read();
|
||||
if (predicate(value)) return value;
|
||||
await flush();
|
||||
}
|
||||
throw new Error('condition was not reached');
|
||||
}
|
||||
|
||||
function credentialsFixture() {
|
||||
const values = new Map();
|
||||
const calls = [];
|
||||
return {
|
||||
values,
|
||||
calls,
|
||||
provider: {
|
||||
resolve: async (ref) => values.has(ref)
|
||||
? { configured: true, source: 'settings', value: values.get(ref) }
|
||||
: { configured: false },
|
||||
set: async (ref, value) => { calls.push(['set', ref]); values.set(ref, value); },
|
||||
unset: async (ref) => { calls.push(['unset', ref]); values.delete(ref); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function configFixture() {
|
||||
const accounts = new Map();
|
||||
return {
|
||||
accounts,
|
||||
store: {
|
||||
list: () => [...accounts.values()].map((account) => structuredClone(account)),
|
||||
get: (botId) => accounts.has(botId) ? structuredClone(accounts.get(botId)) : null,
|
||||
getByAccountId: (accountId) => {
|
||||
const found = [...accounts.values()].find((account) => account.accountId === accountId);
|
||||
return found ? structuredClone(found) : null;
|
||||
},
|
||||
save: async (account) => { accounts.set(account.botId, structuredClone(account)); return account; },
|
||||
remove: async (botId) => accounts.delete(botId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeFactory({ failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
let ready = false;
|
||||
const runtime = {
|
||||
config,
|
||||
token,
|
||||
get status() {
|
||||
return {
|
||||
ready,
|
||||
weixinConnectionState: ready ? 'connected' : 'idle',
|
||||
harnessReachable: ready,
|
||||
lastCheckedAt: ready ? 100 : null,
|
||||
};
|
||||
},
|
||||
async start() {
|
||||
if (failStart) throw new Error('runtime start failed with host-only detail');
|
||||
ready = true;
|
||||
},
|
||||
async stop() { ready = false; },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
return { runtimes, createRuntime };
|
||||
}
|
||||
|
||||
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const runtimes = runtimeFactory();
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async ({ localTokens }) => {
|
||||
assert.deepEqual(localTokens, []);
|
||||
return { qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' };
|
||||
},
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'private-bot-token',
|
||||
ilink_bot_id: 'account@im.bot',
|
||||
ilink_user_id: 'owner-user',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
const completed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'connected',
|
||||
);
|
||||
|
||||
assert.match(completed.botId, /^wx_[a-f0-9]{24}$/);
|
||||
assert.equal(credentials.values.size, 1);
|
||||
assert.equal([...credentials.values.values()][0], 'private-bot-token');
|
||||
const stored = [...configs.accounts.values()][0];
|
||||
assert.equal(stored.ownerUserId, 'owner-user');
|
||||
assert.equal('token' in stored, false);
|
||||
assert.equal(runtimes.runtimes[0].token, 'private-bot-token');
|
||||
const publicJson = JSON.stringify(controller.status());
|
||||
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
|
||||
await controller.deleteBot(completed.botId);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('verification-code state pauses polling and resumes with the submitted digits', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const runtimes = runtimeFactory();
|
||||
const verifyCodes = [];
|
||||
let polls = 0;
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async ({ verifyCode }) => {
|
||||
polls += 1;
|
||||
verifyCodes.push(verifyCode);
|
||||
if (polls === 1) return { status: 'need_verifycode' };
|
||||
return {
|
||||
status: 'confirmed',
|
||||
bot_token: 'token-after-code',
|
||||
ilink_bot_id: 'verify@im.bot',
|
||||
ilink_user_id: 'verify-owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
};
|
||||
},
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
});
|
||||
|
||||
const begun = await controller.startProvisioning();
|
||||
await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'needs_verification',
|
||||
);
|
||||
assert.equal(polls, 1);
|
||||
await controller.submitVerification(begun.attemptId, '123456');
|
||||
await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'connected',
|
||||
);
|
||||
assert.deepEqual(verifyCodes, [null, '123456']);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('activation failure rolls credentials and non-secret config back', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const runtimes = runtimeFactory({ failStart: true });
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async () => ({
|
||||
status: 'confirmed',
|
||||
bot_token: 'must-be-rolled-back',
|
||||
ilink_bot_id: 'rollback@im.bot',
|
||||
ilink_user_id: 'owner',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
logger: { error() {}, warn() {} },
|
||||
});
|
||||
const begun = await controller.startProvisioning();
|
||||
const failed = await waitFor(
|
||||
() => controller.registrationStatus(begun.attemptId),
|
||||
(value) => value.status === 'failed',
|
||||
);
|
||||
|
||||
assert.equal(failed.error.code, 'activation-failed');
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
|
||||
const credentials = credentialsFixture();
|
||||
const configs = configFixture();
|
||||
const controller = new WeixinController({
|
||||
api: {
|
||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||
pollLogin: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||
}),
|
||||
},
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimeFactory().createRuntime,
|
||||
});
|
||||
const begun = await controller.startProvisioning();
|
||||
const cancelled = await controller.cancelProvisioning(begun.attemptId);
|
||||
assert.equal(cancelled.status, 'cancelled');
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
await controller.close();
|
||||
});
|
||||
91
test/channels/weixin/weixin-runtime.test.mjs
Normal file
91
test/channels/weixin/weixin-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||
const calls = [];
|
||||
let pollCount = 0;
|
||||
const stateData = { cursor: '', seen: new Set(), session: null };
|
||||
const api = {
|
||||
notifyStart: async (request) => calls.push(['start', request.token]),
|
||||
notifyStop: async (request) => calls.push(['stop', request.token]),
|
||||
sendText: async (request) => calls.push(['send', request.text, request.contextToken]),
|
||||
getUpdates: async ({ signal }) => {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
return {
|
||||
ret: 0,
|
||||
get_updates_buf: 'cursor-next',
|
||||
msgs: [{
|
||||
message_id: 7,
|
||||
message_type: 1,
|
||||
from_user_id: 'owner',
|
||||
context_token: 'context-7',
|
||||
item_list: [{ type: 1, text_item: { text: '问题' } }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||
});
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
getUpdatesBuf: () => stateData.cursor,
|
||||
setGetUpdatesBuf: async (value) => { stateData.cursor = value; },
|
||||
hasSeen: (id) => stateData.seen.has(id),
|
||||
markSeen: async (id) => stateData.seen.add(id),
|
||||
sessionFor: () => stateData.session,
|
||||
setSession: async (_key, value) => { stateData.session = value; },
|
||||
clearSession: async () => { stateData.session = null; },
|
||||
};
|
||||
const runtime = new WeixinRuntime({
|
||||
api,
|
||||
config: {
|
||||
botId: 'wx_bot',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-1',
|
||||
ask: async () => '回答',
|
||||
},
|
||||
state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const started = await runtime.start();
|
||||
assert.equal(started.ready, true);
|
||||
await flush();
|
||||
await flush();
|
||||
assert.equal(stateData.cursor, 'cursor-next');
|
||||
assert.deepEqual(calls.slice(0, 2), [
|
||||
['start', 'bot-token'],
|
||||
['send', '回答', 'context-7'],
|
||||
]);
|
||||
await runtime.stop();
|
||||
assert.deepEqual(calls.at(-1), ['stop', 'bot-token']);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('runtime refuses to report ready when notifyStart rejects the stored token', async () => {
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => { throw new Error('rejected'); },
|
||||
notifyStop: async () => {},
|
||||
},
|
||||
config: { botId: 'wx_bad', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
|
||||
token: 'bad-token',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
});
|
||||
await assert.rejects(runtime.start(), /rejected/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue