mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 18:43:17 +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
131
test/channels/weixin/client-api.test.mjs
Normal file
131
test/channels/weixin/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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 TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
|
||||
import {
|
||||
AccountCard,
|
||||
WeixinSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/weixin/index.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const CLIENT_URL = new URL('../../../plugin-src/client/channels/weixin/index.js', import.meta.url);
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function buttonNamed(root, name) {
|
||||
return root.findAllByType('button').find((button) => textOf(button) === name);
|
||||
}
|
||||
|
||||
function account(botId, name) {
|
||||
return {
|
||||
botId,
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
workspace: '/workspace/current',
|
||||
bot: { name, accountIdMasked: `${botId}•••` },
|
||||
health: { summary: '微信消息长轮询运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('Weixin client keeps only the public connection-test result', () => {
|
||||
const snapshot = normalizeSnapshot({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
state: 'connected',
|
||||
testMessage: {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
providerDetail: 'must-not-cross-client-normalization',
|
||||
},
|
||||
bots: [{
|
||||
botId: 'wx_0123456789abcdef01234567',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
|
||||
}],
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot.testMessage, {
|
||||
sent: false,
|
||||
code: 'test-target-unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('Weixin card feedback stays visible without hiding connection errors', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
...account('wx_first', '微信机器人'),
|
||||
connected: false,
|
||||
state: 'error',
|
||||
error: { code: 'offline', message: '连接凭据已失效' },
|
||||
},
|
||||
feedback: '微信连接检查完成,测试消息已发送。',
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
|
||||
assert.match(markup, />连接凭据已失效</);
|
||||
assert.match(markup, /role="status"[^>]*>微信连接检查完成,测试消息已发送。</);
|
||||
});
|
||||
|
||||
test('Weixin connection feedback is scoped to the checked bot', async (t) => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = {
|
||||
setInterval() { return 1; }, clearInterval() {},
|
||||
setTimeout() { return 1; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
});
|
||||
|
||||
const bots = [account('wx_first', 'First Bot'), account('wx_second', 'Second Bot')];
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: { revision: 1, bots } };
|
||||
if (endpoint === 'bot.reconnect') {
|
||||
calls.push(payload);
|
||||
return { ok: true, value: { revision: 2, bots, testMessage: { sent: true } } };
|
||||
}
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
};
|
||||
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(WeixinSettingsTab, { rpcCall }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
const first = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
await act(async () => {
|
||||
buttonNamed(first, '检查连接').props.onClick();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
|
||||
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_second' });
|
||||
assert.match(textOf(firstAfter), /测试消息已发送/);
|
||||
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
|
||||
assert.deepEqual(calls, [{ botId: 'wx_first', sendTest: true }]);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
|
||||
test('Weixin reconnect failure uses fixed translatable copy', async () => {
|
||||
const source = await readFile(CLIENT_URL, 'utf8');
|
||||
assert.match(source, /'连接检查失败,请稍后重试。'/);
|
||||
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ function controllerFixture() {
|
|||
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
|
||||
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
|
||||
reconnectBot: async () => snapshot(),
|
||||
sendConnectionTest: async () => ({ sent: true }),
|
||||
deleteBot: async () => snapshot(),
|
||||
};
|
||||
return controller;
|
||||
|
|
@ -105,3 +106,60 @@ test('RPC requires explicit confirmation before removing a Weixin account', asyn
|
|||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('RPC sends a Weixin connection test only after reconnect reports the account connected', async () => {
|
||||
const botId = 'wx_0123456789abcdef01234567';
|
||||
const sent = [];
|
||||
const connected = controllerFixture();
|
||||
connected.reconnectBot = async () => snapshot({
|
||||
state: 'connected',
|
||||
bots: [{ botId, connected: true }],
|
||||
totals: { configured: 1, connected: 1 },
|
||||
});
|
||||
connected.sendConnectionTest = async (id) => { sent.push(id); };
|
||||
const success = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.deepEqual(success.value.testMessage, { sent: true });
|
||||
assert.deepEqual(sent, [botId]);
|
||||
|
||||
connected.sendConnectionTest = async () => { throw new Error('send rejected'); };
|
||||
const failedSend = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(failedSend.ok, true);
|
||||
assert.deepEqual(failedSend.value.testMessage, {
|
||||
sent: false, code: 'test-message-failed',
|
||||
});
|
||||
|
||||
let offlineSendCalled = false;
|
||||
const offline = controllerFixture();
|
||||
offline.reconnectBot = async () => snapshot({
|
||||
state: 'offline',
|
||||
bots: [{ botId, connected: false }],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
});
|
||||
offline.sendConnectionTest = async () => { offlineSendCalled = true; };
|
||||
const unavailable = await createWeixinRpcHandler(offline)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(unavailable.ok, true);
|
||||
assert.deepEqual(unavailable.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal(offlineSendCalled, false);
|
||||
const missingMethod = controllerFixture();
|
||||
missingMethod.reconnectBot = connected.reconnectBot;
|
||||
delete missingMethod.sendConnectionTest;
|
||||
const missing = await createWeixinRpcHandler(missingMethod)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: true,
|
||||
});
|
||||
assert.equal(missing.ok, true);
|
||||
assert.deepEqual(missing.value.testMessage, {
|
||||
sent: false, code: 'test-target-unavailable',
|
||||
});
|
||||
assert.equal((await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
|
||||
botId, sendTest: false,
|
||||
})).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
|
||||
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
|
|
@ -68,6 +69,35 @@ const PNG_BYTES = Buffer.from([
|
|||
0x01, 0x02, 0x03,
|
||||
]);
|
||||
|
||||
test('Weixin remembers only the authorized owner /status as a connection-test target', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async (request) => sent.push(request) },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('status-owner', '/status'));
|
||||
assert.deepEqual(connectionTestTarget(fixture.state), { toUserId: 'owner-user' });
|
||||
assert.equal(sent.at(-1).text, '微信与 DeepSeek Harness 连接正常。');
|
||||
|
||||
const rejectedFixture = stateFixture();
|
||||
const rejectedBridge = new WeixinHarnessBridge({
|
||||
api: { sendText: async () => assert.fail('unauthorized status must not be answered') },
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: rejectedFixture.state,
|
||||
});
|
||||
await rejectedBridge.accept(message('status-other', '/status', { from_user_id: 'other-user' }));
|
||||
assert.equal(connectionTestTarget(rejectedFixture.state), null);
|
||||
});
|
||||
|
||||
test('Weixin sends image-only messages to Harness as structured content', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-image');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function configFixture() {
|
|||
|
||||
function runtimeFactory({ failStart = false } = {}) {
|
||||
const runtimes = [];
|
||||
const connectionTests = [];
|
||||
const createRuntime = async ({ config, token }) => {
|
||||
let ready = false;
|
||||
const runtime = {
|
||||
|
|
@ -67,11 +68,12 @@ function runtimeFactory({ failStart = false } = {}) {
|
|||
ready = true;
|
||||
},
|
||||
async stop() { ready = false; },
|
||||
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
};
|
||||
return { runtimes, createRuntime };
|
||||
return { runtimes, connectionTests, createRuntime };
|
||||
}
|
||||
|
||||
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||
|
|
@ -114,6 +116,11 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
|
|||
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
|
||||
assert.equal(controller.status().totals.connected, 1);
|
||||
|
||||
await controller.sendConnectionTest(completed.botId);
|
||||
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
|
||||
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
|
||||
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
|
||||
|
||||
await controller.deleteBot(completed.botId);
|
||||
assert.equal(credentials.values.size, 0);
|
||||
assert.equal(configs.accounts.size, 0);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,37 @@ function abortable(promise, signal) {
|
|||
});
|
||||
}
|
||||
|
||||
test('runtime sends a connection test to the bound Weixin owner without reply context', async () => {
|
||||
const sends = [];
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => {},
|
||||
notifyStop: async () => {},
|
||||
sendText: async (request) => sends.push(request),
|
||||
getUpdates: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
},
|
||||
config: {
|
||||
botId: 'wx_owner',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner-user',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: { getUpdatesBuf: () => '' },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
|
||||
assert.equal(sends.length, 1);
|
||||
assert.equal(sends[0].toUserId, 'owner-user');
|
||||
assert.equal(sends[0].text, '连接测试');
|
||||
assert.equal(sends[0].contextToken, undefined);
|
||||
assert.equal(sends[0].runId, undefined);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||
const calls = [];
|
||||
let pollCount = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue