feat: send test messages during bot connection checks

This commit is contained in:
xmanrui 2026-08-19 03:52:52 +08:00
parent 94da24538f
commit fb61c4235f
78 changed files with 3460 additions and 210 deletions

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -258,6 +259,45 @@ test('QQ executes /compact for the bound Session without prompting the model', a
assert.equal(fixture.seen.has('compact-qq'), true);
});
test('QQ remembers a connection-test target only for an authorized private /status', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: { ensureRunning: async () => true },
state: fixture.state,
});
await bridge.accept(message({
messageId: 'status-rejected',
senderId: 'other-openid',
content: '/status',
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'status-rejected' },
}));
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-1',
messageId: 'status-group',
content: '/status',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'status-group' },
}));
assert.equal(connectionTestTarget(fixture.state), null);
const privateTarget = {
scope: 'c2c', targetId: 'owner-openid', msgId: 'status-private',
};
await bridge.accept(message({
messageId: 'status-private',
content: '/status',
replyTarget: privateTarget,
}));
assert.deepEqual(connectionTestTarget(fixture.state), privateTarget);
assert.equal(sent.filter(({ text }) => text.includes('连接正常')).length, 2);
});
test('QQ private messages stream Harness snapshots and finalize once', async () => {
const frames = [];
const sent = [];

View file

@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
import {
connectionTestFeedback,
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==';
@ -20,3 +25,26 @@ test('QQ client keeps only redacted bot and host-rendered QR state', () => {
assert.equal(snapshot.totals.connected, 1);
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
});
test('QQ client normalizes and presents connection-test outcomes', () => {
const sent = normalizeSnapshot({ bots: [], testMessage: { sent: true, code: 'ignored' } });
assert.deepEqual(sent.testMessage, { sent: true });
assert.equal(
connectionTestFeedback(sent.testMessage),
'测试消息已发送,请到对应机器人会话中确认。',
);
const unavailable = normalizeSnapshot({
bots: [], testMessage: { sent: false, code: 'test-target-unavailable' },
});
assert.deepEqual(unavailable.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.match(connectionTestFeedback(unavailable.testMessage), /\/status/);
const invalid = normalizeSnapshot({
bots: [], testMessage: { sent: false, code: 'private-provider-error' },
});
assert.deepEqual(invalid.testMessage, { sent: false, code: 'test-message-failed' });
assert.equal(connectionTestFeedback(undefined), null);
});

View file

@ -1,10 +1,14 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
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';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
const CLIENT_URL = new URL('../../../plugin-src/client/channels/qq/index.js', import.meta.url);
test('QQ settings uses the shared compact channel toolbar', () => {
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
@ -50,3 +54,56 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
}));
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
});
test('QQ connection checks request a test message and show concise card feedback', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
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,
},
feedback: '测试消息已发送,请到对应机器人会话中确认。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, /role="status"/);
assert.match(markup, /测试消息已发送/);
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot', connected: false, state: 'error',
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
health: { summary: 'QQ 连接尚未就绪', lastCheckedAt: Date.now() },
error: { code: 'offline', message: '连接凭据已失效' },
},
feedback: '测试消息已发送,请到对应机器人会话中确认。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(offlineMarkup, />连接凭据已失效</);
assert.match(offlineMarkup, /role="status"[^>]*>测试消息已发送/);
});
test('fixed reconnect failure copy renders fully in English', () => {
setImTranslator((key) => en[key] ?? key);
try {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot', connected: true, state: 'connected',
bot: { name: 'QQ Bot', appIdMasked: '123••••456' },
health: { summary: 'healthy', lastCheckedAt: Date.now() },
error: null,
},
feedback: '连接检查失败,请稍后重试。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, /Connection check failed\. Try again later\./);
assert.doesNotMatch(markup, /[\p{Script=Han}]/u);
} finally {
setImTranslator(null);
}
});

View file

@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { maskQqAppId } from '../../../src/channels/qq/config-store.mjs';
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
import { connectionTestMessage } from '../../../src/channels/shared/connection-test.mjs';
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
function deferred() {
@ -90,6 +92,41 @@ test('QQ AppID and AppSecret binding stores the secret and accepts the platform
await controller.close();
});
test('QQ controller delegates the shared connection-test message to the selected runtime', async () => {
const config = {
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
};
const sent = [];
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
resolve: async () => ({ value: 'secret' }),
set: async () => {},
unset: async () => {},
},
configStore: {
list: () => [config],
get: (botId) => botId === config.botId ? config : null,
getByAppId: () => config,
save: async () => {},
remove: async () => null,
},
createRuntime: async () => ({
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => sent.push(text),
}),
});
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
]);
await controller.close();
});
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 } }),
@ -100,13 +137,22 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async () => ({}),
reconnectBot: async (botId) => ({
bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 },
}),
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/);
const legacyReconnect = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(legacyReconnect.ok, true);
assert.deepEqual(legacyReconnect.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
});
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
@ -128,3 +174,71 @@ test('QQ credential RPC validates the pair and never returns AppSecret', async (
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
});
test('QQ reconnect RPC reports test-message outcomes without discarding the snapshot', async () => {
const calls = [];
let sendError = null;
let connected = true;
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({}),
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({}),
reconnectBot: async (botId) => {
calls.push(['reconnect', botId]);
return {
revision: 7,
bots: [{ botId, connected, bot: { name: 'QQ机器人' } }],
totals: { configured: 1, connected: connected ? 1 : 0 },
};
},
sendConnectionTest: async (botId) => {
calls.push(['test', botId]);
if (sendError) throw sendError;
return { sent: true };
},
deleteBot: async () => ({}),
});
const success = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(success.ok, true);
assert.equal(success.value.revision, 7);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(calls, [['reconnect', 'qq_bot'], ['test', 'qq_bot']]);
sendError = Object.assign(new Error('no recent target'), { code: 'test-target-unavailable' });
const unavailable = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(unavailable.ok, true);
assert.equal(unavailable.value.bots[0].connected, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
sendError = new Error('QQ API rejected the message');
const failed = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(failed.ok, true);
assert.deepEqual(failed.value.testMessage, { sent: false, code: 'test-message-failed' });
calls.length = 0;
connected = false;
sendError = null;
const offline = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(offline.ok, true);
assert.deepEqual(offline.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
calls.length = 0;
connected = true;
const reconnectOnly = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot' });
assert.equal(reconnectOnly.ok, true);
assert.equal(reconnectOnly.value.testMessage, undefined);
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
assert.equal((await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: false,
})).ok, false);
});

View file

@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import test from 'node:test';
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -12,6 +13,7 @@ function deferred() {
class FakeBot extends EventEmitter {
middlewares = [];
sent = [];
stopped = false;
use(value) { this.middlewares.push(value); }
async start(signal) {
@ -19,7 +21,7 @@ class FakeBot extends EventEmitter {
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
}
stop() { this.stopped = true; }
async sendText() {}
async sendText(target, text) { this.sent.push({ target, text }); }
}
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
@ -77,6 +79,56 @@ test('QQ runtime never reports ready when the gateway does not emit ready', asyn
assert.equal(runtime.status.ready, false);
});
test('QQ runtime sends a proactive connection test to the explicit owner fallback', async () => {
const bot = new FakeBot();
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner-openid' },
appSecret: 'secret',
harness: { ensureRunning: async () => true },
state: {},
createBot: () => bot,
typingMiddleware: () => 'typing',
connectTimeoutMs: 100,
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('connection-test'), { sent: true });
assert.deepEqual(bot.sent, [{
target: { scope: 'c2c', targetId: 'owner-openid' },
text: 'connection-test',
}]);
await runtime.stop();
});
test('QQ runtime requires /status for wildcard owners and strips the remembered reply id', async () => {
const bot = new FakeBot();
const state = {};
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: '*' },
appSecret: 'secret',
harness: { ensureRunning: async () => true },
state,
createBot: () => bot,
typingMiddleware: () => 'typing',
connectTimeoutMs: 100,
});
await runtime.start();
await assert.rejects(
() => runtime.sendConnectionTest('unavailable'),
(error) => error?.code === 'test-target-unavailable',
);
rememberConnectionTestTarget(state, {
scope: 'c2c', targetId: 'recent-user', msgId: 'old-status-message',
});
await runtime.sendConnectionTest('remembered-target');
assert.deepEqual(bot.sent, [{
target: { scope: 'c2c', targetId: 'recent-user' },
text: 'remembered-target',
}]);
await runtime.stop();
});
test('QQ runtime aborts an in-flight Harness interaction when stopped', async () => {
const bot = new FakeBot();
const askStarted = deferred();