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

@ -1,15 +1,30 @@
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 {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
const { act, create } = TestRenderer;
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('') ?? '';
}
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
@ -48,10 +63,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
error: null,
},
testNotice: '测试消息已发送,请到 WhatsApp 自聊会话中确认。',
}));
assert.match(markup, /data-im-channel-logo="whatsapp"/);
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
const source = await readFile(new URL(
'../../../plugin-src/client/channels/whatsapp/index.js',
import.meta.url,
), 'utf8');
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});
test('WhatsApp reconnect failures render a fixed English-safe notice', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = {
setInterval() { return 1; },
clearInterval() {},
setTimeout() { return 1; },
clearTimeout() {},
};
setImTranslator((key) => en[key] ?? key);
t.after(() => {
setImTranslator(null);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = {
schemaVersion: 1,
bots: [{
botId: 'whatsapp_test',
state: 'connected',
connected: true,
workspace: '/workspace/current',
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'WhatsApp Web is healthy', lastCheckedAt: Date.now() },
error: null,
}],
totals: { configured: 1, connected: 1 },
};
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.reconnect') {
return {
ok: false,
error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' },
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'whatsapp_test' });
await act(async () => {
card.findAllByType('button')
.find((button) => textOf(button) === 'Check connection').props.onClick();
await flushMicrotasks();
});
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
assert.equal(notice, 'Connection check failed. Try again later.');
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
await act(async () => { renderer.unmount(); });
});

View file

@ -426,6 +426,193 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
await runtime.stop();
});
test('WhatsApp runtime sends a connection test to self and suppresses its outbound echo', async () => {
let callbacks;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content) => {
sent.push([jid, content]);
return { key: { id: 'connection-test-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-connection-test',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => { askCount += 1; return 'unexpected'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-connection-test',
sessionExists: async () => true,
},
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
message: { conversation: '连接测试' },
});
assert.equal(askCount, 0);
await runtime.stop();
});
test('WhatsApp controller delegates connection test copy to the current runtime', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-test-message-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
const config = await configStore.save(linkedConfig());
const sent = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
createSession: async () => { throw new Error('not used'); },
createRuntime: async () => ({
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
},
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => {
sent.push(text);
return { sent: true };
},
}),
});
t.after(() => controller.close());
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
]);
});
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
const botId = deriveWhatsappBotId(ACCOUNT_JID);
let connected = true;
let sendFailure = false;
let sendCalls = 0;
const snapshot = () => ({
schemaVersion: 1,
revision: 1,
bots: [{
botId,
state: connected ? 'connected' : 'offline',
connected,
configured: true,
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'status', lastCheckedAt: Date.now() },
}],
totals: { configured: 1, connected: connected ? 1 : 0 },
});
const controller = {
status: async () => snapshot(),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
return { sent: true };
},
};
const handler = createWhatsappRpcHandler(controller);
const legacy = await handler(WHATSAPP_ENDPOINTS.reconnectBot, { botId });
assert.equal(legacy.ok, true);
assert.equal('testMessage' in legacy.value, false);
assert.equal(sendCalls, 0);
const success = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.equal(sendCalls, 1);
sendFailure = true;
const failedSend = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false,
code: 'test-message-failed',
});
assert.doesNotMatch(JSON.stringify(failedSend), /private provider failure/);
connected = false;
const unavailable = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(unavailable.ok, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
assert.equal(sendCalls, 2);
const invalid = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: 'yes' },
);
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});
test('WhatsApp RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const botId = deriveWhatsappBotId(ACCOUNT_JID);
const controller = {
status: async () => ({ bots: [] }),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
botId,
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect({ bots: [{ botId, connected: true }] });
assert.deepEqual(await result, {
ok: false,
error: { code: 'cancelled', message: 'The request was cancelled.' },
});
assert.equal(sendCalls, 0);
});
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();