mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-14 03:30:52 +08:00
feat: send test messages during bot connection checks
This commit is contained in:
parent
94da24538f
commit
fb61c4235f
78 changed files with 3460 additions and 210 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue