Add WeCom QR bot channel

This commit is contained in:
xmanrui 2026-08-15 20:53:35 +08:00
parent 601271a7f8
commit 4874d3e354
33 changed files with 5200 additions and 440 deletions

View file

@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
function frame(overrides = {}) {
return {
headers: { req_id: 'req-1' },
body: {
msgid: 'msg-1',
chattype: 'single',
from: { userid: 'member-1' },
msgtype: 'text',
text: { content: '请回答' },
...overrides,
},
};
}
function state() {
const seen = new Set();
return {
seen,
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
sessionExists: async () => true,
setSession: async () => {},
clearSession: async () => {},
};
}
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
const replies = [];
const active = [];
const store = state();
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
replyStreamNonBlocking: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
sendMessage: async (chatId, body) => active.push({ chatId, body }),
},
generateStreamId: () => 'stream-1',
harness: {
sessionExists: async () => true,
createSession: async () => 'session-new',
ensureRunning: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'tool', name: '网页搜索' });
await onUpdate({ type: 'text', text: '回答中' });
return '最终回答';
},
},
state: store,
});
await bridge.accept(frame());
assert.deepEqual(replies, [
{ streamId: 'stream-1', content: '正在思考中…', finish: false },
{ streamId: 'stream-1', content: '正在使用网页搜索…', finish: false },
{ streamId: 'stream-1', content: '回答中', finish: false },
{ streamId: 'stream-1', content: '最终回答', finish: true },
]);
assert.deepEqual(active, []);
assert.equal(store.seen.has('msg-1'), true);
assert.equal(bridge.status.messagesReplied, 1);
});
test('Enterprise WeChat visibility scope accepts direct and group conversations without local approval', async () => {
let asks = 0;
const client = {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
};
const harness = {
sessionExists: async () => true,
ask: async () => { asks += 1; return 'ok'; },
};
const bridge = new WecomHarnessBridge({ client, harness, state: state(), generateStreamId: () => 'stream' });
await bridge.accept(frame({ msgid: 'direct', from: { userid: 'member-a' } }));
await bridge.accept(frame({ msgid: 'group', chattype: 'group', chatid: 'group-1', from: { userid: 'member-b' } }));
assert.equal(asks, 2);
});
test('Enterprise WeChat finalizes an existing progress stream when Harness fails', async () => {
const replies = [];
const store = state();
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
},
generateStreamId: () => 'stream-failure',
harness: {
sessionExists: async () => true,
ensureRunning: async () => true,
ask: async () => { throw new Error('Harness unavailable'); },
},
state: store,
logger: { error() {} },
});
await bridge.accept(frame());
assert.deepEqual(replies, [
{ streamId: 'stream-failure', content: '正在思考中…', finish: false },
{ streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true },
]);
assert.equal(store.seen.has('msg-1'), true);
});

View file

@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeProvisioning,
normalizeSnapshot,
safeQrSource,
} from '../../../plugin-src/client/channels/wecom/api.js';
test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR state', () => {
const qr = 'data:image/png;base64,YWJjZA==';
assert.equal(safeQrSource(qr), qr);
const provision = normalizeProvisioning({
attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr,
});
assert.equal(provision.qrCodeDataUrl, qr);
const snapshot = normalizeSnapshot({
bots: [{
botId: 'wecom_abc', connected: true, state: 'connected',
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
health: { summary: '运行正常' },
}],
});
assert.equal(snapshot.totals.connected, 1);
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
});

View file

@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
AccountCard,
WecomSettingsTab,
} from '../../../plugin-src/client/channels/wecom/index.js';
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
rpcCall: async () => ({ ok: true, value: {} }),
}));
assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/);
assert.match(markup, /class="ddt-button dim-scanButton"/);
assert.match(markup, />扫码绑定企业微信机器人</);
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
});
test('Enterprise WeChat bot cards match the shared two-metric card treatment', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'wecom_bot',
connected: true,
state: 'connected',
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
},
onReconnect() {},
onRequestRemove() {},
onConfirmRemove() {},
onCancelRemove() {},
}));
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, />检查连接<[^]*>移除接入</);
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
});

View file

@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
deriveWecomBotIdentity,
WecomConfigStore,
} from '../../../src/channels/wecom/config-store.mjs';
import { WecomQrAuth } from '../../../src/channels/wecom/qr-auth.mjs';
test('Enterprise WeChat config stores only non-secret bot identity with mode 0600', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-wecom-config-'));
t.after(() => rm(root, { recursive: true, force: true }));
const path = join(root, 'config.json');
const identity = deriveWecomBotIdentity('bot-enterprise-1');
const store = await new WecomConfigStore(path).load();
await store.save({
...identity,
remoteBotId: 'bot-enterprise-1',
createdAt: '2026-08-15T00:00:00.000Z',
});
const document = await readFile(path, 'utf8');
assert.match(document, /bot-enterprise-1/);
assert.doesNotMatch(document, /private-secret|"secret"/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.equal(store.get(identity.botId).remoteBotId, 'bot-enterprise-1');
});
test('Enterprise WeChat QR auth uses fixed official endpoints and returns credentials only after success', async () => {
const requests = [];
const replies = [
{ data: { scode: 'opaque-scode', auth_url: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque' } },
{ data: { status: 'init' } },
{ data: { status: 'success', bot_info: { botid: 'bot-enterprise-1', secret: 'private-secret' } } },
];
const auth = new WecomQrAuth({
clock: () => 1_000,
fetch: async (url, options) => {
requests.push({ url: String(url), options });
return { ok: true, status: 200, json: async () => replies.shift() };
},
});
const started = await auth.start();
assert.equal(started.verificationUrl, 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque');
assert.equal(started.expiresAt, 301_000);
assert.equal(await auth.poll({ scode: started.scode }).then((value) => value.status), 'waiting');
const completed = await auth.poll({ scode: started.scode });
assert.deepEqual(completed, {
status: 'success', remoteBotId: 'bot-enterprise-1', secret: 'private-secret',
});
assert.match(requests[0].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/generate\?/);
assert.match(requests[0].url, /source=deepseek-harness/);
assert.match(requests[1].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/query_result\?/);
assert.equal(requests.every(({ options }) => options.redirect === 'error'), true);
});
test('Enterprise WeChat QR auth rejects an authorization URL outside the official host', async () => {
const auth = new WecomQrAuth({
fetch: async () => ({
ok: true,
status: 200,
json: async () => ({ data: { scode: 'opaque', auth_url: 'https://example.com/steal' } }),
}),
});
await assert.rejects(() => auth.start(), /invalid data/);
});

View file

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WecomController } from '../../../src/channels/wecom/wecom-controller.mjs';
import {
createWecomRpcHandler,
WECOM_ENDPOINTS,
} from '../../../plugin-src/host/channels/wecom/rpc.mjs';
test('Enterprise WeChat QR success stores Secret off-config and starts its runtime', async () => {
const values = new Map();
const configs = [];
let runtimeArgs;
const controller = new WecomController({
qrAuth: {
start: async () => ({
scode: 'host-only-code',
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
expiresAt: Date.now() + 10_000,
pollIntervalMs: 500,
}),
poll: async () => ({ status: 'success', remoteBotId: 'remote-bot', secret: 'private-secret' }),
},
credentials: {
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
set: async (ref, value) => values.set(ref, value),
unset: async (ref) => values.delete(ref),
},
configStore: {
list: () => [...configs],
get: (id) => configs.find((value) => value.botId === id) ?? null,
getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null,
save: async (value) => { configs.splice(0, configs.length, value); },
remove: async () => null,
},
createRuntime: async (args) => {
runtimeArgs = args;
return {
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
start: async () => {},
stop: async () => {},
};
},
});
const started = await controller.startProvisioning();
const completed = await controller.registrationStatus(started.attemptId);
assert.equal(completed.status, 'connected');
const status = controller.status();
assert.equal(status.bots[0].connected, true);
assert.equal(values.get(configs[0].secretRef), 'private-secret');
assert.equal(runtimeArgs.secret, 'private-secret');
assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/);
});
test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => {
const handler = createWecomRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({
attemptId: 'attempt_1',
status: 'pending',
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
scode: 'never-public',
secret: 'never-public',
remoteBotId: 'never-public',
expiresAt: Date.now() + 1_000,
}),
registrationStatus: async () => null,
cancelProvisioning: async () => ({}),
reconnectBot: async () => ({}),
deleteBot: async () => ({}),
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
assert.equal(result.ok, true);
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/);
});

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
class FakeClient extends EventEmitter {
disconnected = false;
connect() { queueMicrotask(() => this.emit('authenticated')); }
disconnect() { this.disconnected = true; }
async replyStream() {}
async replyStreamNonBlocking() {}
async sendMessage() {}
}
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
const client = new FakeClient();
let options;
const logs = [];
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: (value) => { options = value; return client; },
logger: { debug: (...args) => logs.push(args), warn() {} },
connectTimeoutMs: 100,
});
const status = await runtime.start();
assert.equal(status.ready, true);
assert.equal(status.wecomConnectionState, 'connected');
assert.equal(options.botId, 'remote-bot');
assert.equal(options.secret, 'private-secret');
options.logger.debug('raw message payload');
options.logger.warn('raw unknown frame');
assert.deepEqual(logs, []);
client.emit('disconnected', 'network');
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.wecomConnectionState, 'connecting');
client.emit('authenticated');
assert.equal(runtime.status.ready, true);
await runtime.stop();
assert.equal(client.disconnected, true);
assert.equal(runtime.status.ready, false);
});
test('Enterprise WeChat runtime never reports ready without SDK authentication', async () => {
const client = new FakeClient();
client.connect = () => {};
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: () => client,
connectTimeoutMs: 5,
});
await assert.rejects(() => runtime.start(), /authentication timed out/);
assert.equal(runtime.status.ready, false);
assert.equal(client.disconnected, true);
});
test('Enterprise WeChat runtime stop cancels an in-flight authentication wait', async () => {
const client = new FakeClient();
client.connect = () => {};
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: () => client,
connectTimeoutMs: 60_000,
});
const starting = runtime.start();
await new Promise((resolve) => setImmediate(resolve));
await runtime.stop();
await assert.rejects(starting, { name: 'AbortError' });
assert.equal(client.disconnected, true);
assert.equal(runtime.status.wecomConnectionState, 'idle');
});