mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 07:53:17 +08:00
Add QQ bot channel
This commit is contained in:
parent
a422084eb5
commit
601271a7f8
33 changed files with 4971 additions and 642 deletions
81
test/channels/qq/bridge.test.mjs
Normal file
81
test/channels/qq/bridge.test.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
|
||||
function message(overrides = {}) {
|
||||
return {
|
||||
kind: 'c2c',
|
||||
rawEventType: 'C2C_MESSAGE_CREATE',
|
||||
senderId: 'owner-openid',
|
||||
senderIsBot: false,
|
||||
content: '请回答',
|
||||
messageId: 'msg-1',
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-1' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('QQ private messages stream Harness snapshots and finalize once', async () => {
|
||||
const frames = [];
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
openStream: () => ({
|
||||
update: async (text) => frames.push(text),
|
||||
complete: async () => frames.push('DONE'),
|
||||
cancel() {},
|
||||
}),
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-new',
|
||||
ensureRunning: async () => true,
|
||||
ask: async (_session, _text, { onUpdate }) => {
|
||||
await onUpdate({ type: 'text', text: '回答中' });
|
||||
return '最终回答';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message());
|
||||
assert.deepEqual(frames, ['回答中', '最终回答', 'DONE']);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(seen.has('msg-1'), true);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
|
||||
test('QQ bridge accepts only the scanner and requires an at-message event in groups', async () => {
|
||||
let asks = 0;
|
||||
const state = {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session',
|
||||
sessionExists: async () => true,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: { sessionExists: async () => true, ask: async () => { asks += 1; return 'ok'; } },
|
||||
state,
|
||||
});
|
||||
await bridge.accept(message({ messageId: 'other', senderId: 'other-openid' }));
|
||||
await bridge.accept(message({
|
||||
messageId: 'group', kind: 'group', groupOpenid: 'group-1', rawEventType: 'GROUP_MESSAGE_CREATE',
|
||||
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group' },
|
||||
}));
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(bridge.status.messagesRejected, 1);
|
||||
});
|
||||
22
test/channels/qq/client-api.test.mjs
Normal file
22
test/channels/qq/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
|
||||
|
||||
test('QQ 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: 'qq_abc', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: '运行正常' },
|
||||
}],
|
||||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
|
||||
});
|
||||
40
test/channels/qq/client-ui.test.mjs
Normal file
40
test/channels/qq/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js';
|
||||
|
||||
test('QQ settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定QQ机器人</);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
test('QQ bot cards match the shared two-metric card treatment', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ 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="qq"/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
});
|
||||
53
test/channels/qq/config-and-qr.test.mjs
Normal file
53
test/channels/qq/config-and-qr.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { deriveQqBotIdentity, QqConfigStore } from '../../../src/channels/qq/config-store.mjs';
|
||||
import { QqQrAuth } from '../../../src/channels/qq/qr-auth.mjs';
|
||||
|
||||
test('QQ config stores only non-secret bot identity with mode 0600', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-qq-config-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const path = join(root, 'config.json');
|
||||
const identity = deriveQqBotIdentity('102345678');
|
||||
const store = await new QqConfigStore(path).load();
|
||||
await store.save({
|
||||
...identity,
|
||||
appId: '102345678',
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
});
|
||||
const document = await readFile(path, 'utf8');
|
||||
assert.match(document, /102345678/);
|
||||
assert.match(document, /owner-openid/);
|
||||
assert.doesNotMatch(document, /appSecret|private-secret/);
|
||||
assert.deepEqual(store.get(identity.botId), {
|
||||
...identity,
|
||||
appId: '102345678',
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
connectedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ QR wrapper disables console output and identifies DeepSeek Harness', () => {
|
||||
let observed;
|
||||
const auth = new QqQrAuth({
|
||||
start(callbacks, options) {
|
||||
observed = { callbacks, options };
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const callbacks = { onSuccess() {}, onFailure() {} };
|
||||
const signal = new AbortController().signal;
|
||||
const dispose = auth.start(callbacks, { signal });
|
||||
assert.equal(typeof dispose, 'function');
|
||||
assert.equal(observed.callbacks, callbacks);
|
||||
assert.deepEqual(observed.options, {
|
||||
displayQrCodeToConsole: false,
|
||||
source: 'deepseek-harness',
|
||||
signal,
|
||||
});
|
||||
});
|
||||
73
test/channels/qq/controller-and-rpc.test.mjs
Normal file
73
test/channels/qq/controller-and-rpc.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
|
||||
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('QQ QR success stores the secret off-config and starts a scanner-owned runtime', async () => {
|
||||
const qr = deferred();
|
||||
let callbacks;
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new QqController({
|
||||
qrAuth: {
|
||||
start(next) {
|
||||
callbacks = next;
|
||||
queueMicrotask(() => next.onQrDisplayed('https://q.qq.com/connect?task=opaque'));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
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,
|
||||
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return { status: { ready: true, qqConnectionState: 'connected', harnessReachable: true }, start: async () => {}, stop: async () => {} };
|
||||
},
|
||||
});
|
||||
const started = await controller.startProvisioning();
|
||||
assert.equal(started.status, 'pending');
|
||||
callbacks.onSuccess([{ appId: 'app-id', appSecret: 'private-secret', userOpenid: 'scanner-openid' }]);
|
||||
while (controller.registrationStatus(started.attemptId).status !== 'connected') await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = controller.status();
|
||||
assert.equal(status.bots[0].connected, true);
|
||||
assert.equal(configs[0].ownerUserOpenid, 'scanner-openid');
|
||||
assert.equal(values.get(configs[0].secretRef), 'private-secret');
|
||||
assert.equal(runtimeArgs.appSecret, 'private-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /private-secret|scanner-openid|secretRef/);
|
||||
qr.resolve();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({
|
||||
attemptId: 'attempt_1', status: 'pending', verificationUrl: 'https://q.qq.com/opaque',
|
||||
appSecret: 'never-public', ownerUserOpenid: 'never-public', expiresAt: Date.now() + 1_000,
|
||||
}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(QQ_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), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
});
|
||||
72
test/channels/qq/runtime.test.mjs
Normal file
72
test/channels/qq/runtime.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
|
||||
|
||||
class FakeBot extends EventEmitter {
|
||||
middlewares = [];
|
||||
stopped = false;
|
||||
use(value) { this.middlewares.push(value); }
|
||||
async start(signal) {
|
||||
queueMicrotask(() => this.emit('ready', {}));
|
||||
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
}
|
||||
stop() { this.stopped = true; }
|
||||
async sendText() {}
|
||||
}
|
||||
|
||||
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
|
||||
const bot = new FakeBot();
|
||||
let botOptions;
|
||||
const sdkLogs = [];
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: (options) => {
|
||||
botOptions = options;
|
||||
return bot;
|
||||
},
|
||||
logger: {
|
||||
debug: (...args) => sdkLogs.push(['debug', ...args]),
|
||||
info: (...args) => sdkLogs.push(['info', ...args]),
|
||||
},
|
||||
typingMiddleware: (options) => ({ name: 'typing-middleware', options }),
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
const status = await runtime.start();
|
||||
assert.equal(status.ready, true);
|
||||
assert.equal(status.qqConnectionState, 'connected');
|
||||
assert.equal(bot.middlewares[0].name, 'typing-middleware');
|
||||
assert.equal(bot.middlewares[0].options.keepAlive, true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'owner' } }), true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'other' } }), false);
|
||||
botOptions.logger.debug('raw gateway payload');
|
||||
botOptions.logger.info('gateway ready');
|
||||
assert.deepEqual(sdkLogs, [['info', 'gateway ready']]);
|
||||
bot.emit('error', new Error('temporary disconnect'));
|
||||
bot.emit('resumed');
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.lastError, null);
|
||||
await runtime.stop();
|
||||
assert.equal(bot.stopped, true);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('QQ runtime never reports ready when the gateway does not emit ready', async () => {
|
||||
const bot = new FakeBot();
|
||||
bot.start = async (signal) => new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 5,
|
||||
});
|
||||
await assert.rejects(() => runtime.start(), /did not become ready/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
|
@ -47,11 +47,12 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders three compact logo channel tabs without enable switches', () => {
|
||||
test('IM settings renders four compact logo channel tabs without enable switches', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
|
|
@ -60,10 +61,12 @@ test('IM settings renders three compact logo channel tabs without enable switche
|
|||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 3);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 4);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,20 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', async () => {
|
||||
test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
|
||||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
feishu: { domain: 'feishu' },
|
||||
weixin: { timeout: 30 },
|
||||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -25,6 +27,7 @@ test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', asy
|
|||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
['dingtalk', ctx, config.dingtalk],
|
||||
['qq', ctx, config.qq],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => {
|
|||
applyFeishu: async () => { throw new Error('feishu unavailable'); },
|
||||
applyWeixin: async () => { weixinStarted = true; },
|
||||
applyDingtalk: async () => { throw new Error('DingTalk must not start'); },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /feishu unavailable/);
|
||||
|
|
@ -46,8 +50,22 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
|
|||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => { throw new Error('weixin unavailable'); },
|
||||
applyDingtalk: async () => { dingtalkStarted = true; },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /weixin unavailable/);
|
||||
assert.equal(dingtalkStarted, false);
|
||||
});
|
||||
|
||||
test('Host does not start QQ when DingTalk activation fails', async () => {
|
||||
let qqStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => {},
|
||||
applyDingtalk: async () => { throw new Error('dingtalk unavailable'); },
|
||||
applyQq: async () => { qqStarted = true; },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/);
|
||||
assert.equal(qqStarted, false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue