Internalize all IM channel implementations

This commit is contained in:
xmanrui 2026-08-15 15:40:53 +08:00
parent 8996476693
commit bd469f58f8
95 changed files with 25012 additions and 100 deletions

View 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);
});

View 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);
});

View 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');
});

View 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);
});

View 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']);
});

View 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/);
});

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

View 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');
});