feat: add WhatsApp QR channel

This commit is contained in:
xmanrui 2026-08-16 17:30:38 +08:00
parent f8841da76f
commit c54463843f
29 changed files with 4565 additions and 224 deletions

View file

@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccountCard,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
const qr = renderToStaticMarkup(React.createElement(QrPanel, {
provision: {
qrCodeDataUrl: 'data:image/png;base64,QUJDRA==',
expiresAt: Date.now() + 60_000,
durationMs: 60_000,
},
now: Date.now(),
}));
assert.match(empty, /扫码绑定 WhatsApp 机器人/);
assert.match(empty, /生成二维码/);
assert.match(qr, /已关联设备/);
assert.match(qr, /关联设备/);
assert.doesNotMatch(`${empty}${qr}`, /Cloud API|Phone Number ID|Access Token|App Secret|Verify Token|Webhook/);
});
test('WhatsApp QR startup renders a neutral loading state instead of an error card', () => {
const markup = renderToStaticMarkup(React.createElement(ProvisionView, {
provision: { status: 'starting' },
busy: true,
}));
assert.match(markup, /正在生成 WhatsApp 二维码/);
assert.match(markup, /aria-busy="true"/);
assert.doesNotMatch(markup, /WhatsApp 没有接入完成|WHATSAPP_PROVISION_FAILED|ddt-inlineError/);
});
test('WhatsApp account card uses the unified compact channel layout', () => {
const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, {
account: {
botId: 'whatsapp-card',
state: 'connected',
connected: true,
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
error: null,
},
}));
assert.match(markup, /data-im-channel-logo="whatsapp"/);
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});

View file

@ -0,0 +1,355 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { DisconnectReason } from '@whiskeysockets/baileys';
import {
WhatsappConfigStore,
deriveWhatsappBotId,
} from '../../../src/channels/whatsapp/config-store.mjs';
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
import {
WhatsappRuntime,
normalizeWhatsappMessage,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
import {
WHATSAPP_ENDPOINTS,
createWhatsappRpcHandler,
} from '../../../plugin-src/host/channels/whatsapp/rpc.mjs';
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
function linkedConfig(overrides = {}) {
return {
botId: deriveWhatsappBotId(ACCOUNT_JID),
accountJid: ACCOUNT_JID,
authDirectory: AUTH_DIRECTORY,
name: 'Harness WhatsApp',
createdAt: new Date().toISOString(),
connectedAt: new Date().toISOString(),
...overrides,
};
}
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
const path = join(root, 'config.json');
const store = await new WhatsappConfigStore(path).load();
await store.save(linkedConfig());
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
assert.equal((await stat(path)).mode & 0o777, 0o600);
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
});
test('WhatsApp Web session reports QR and linked identity without printing either', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-session-'));
const events = new EventEmitter();
let ended = false;
const socket = {
ev: events,
user: { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' },
end: async () => { ended = true; },
logout: async () => {},
};
const qrValues = [];
const session = await createWhatsappWebSession({
authDir: root,
onQr: (value) => qrValues.push(value),
makeSocket: () => socket,
loadAuthState: async () => ({
state: {
creds: { me: socket.user },
keys: { get: async () => ({}), set: async () => {} },
},
saveCreds: async () => {},
}),
});
events.emit('connection.update', { qr: 'host-only-qr-value' });
events.emit('connection.update', { connection: 'open' });
assert.deepEqual(qrValues, ['host-only-qr-value']);
assert.deepEqual(await session.ready, {
accountJid: ACCOUNT_JID,
name: 'Harness WhatsApp',
});
assert.equal((await stat(root)).mode & 0o777, 0o700);
await session.close();
assert.equal(ended, true);
});
test('WhatsApp Web session restarts the socket after first-time QR pairing', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-restart-'));
const sockets = Array.from({ length: 2 }, (_, index) => ({
ev: new EventEmitter(),
user: index === 1
? { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' }
: undefined,
end: async () => {},
logout: async () => {},
}));
let socketIndex = 0;
let saveCount = 0;
const authState = {
creds: {},
keys: { get: async () => ({}), set: async () => {} },
};
const session = await createWhatsappWebSession({
authDir: root,
onQr: () => {},
makeSocket: () => sockets[socketIndex++],
loadAuthState: async () => ({
state: authState,
saveCreds: async () => { saveCount += 1; },
}),
});
authState.creds.me = sockets[1].user;
sockets[0].ev.emit('creds.update', { me: sockets[1].user });
sockets[0].ev.emit('connection.update', {
connection: 'close',
lastDisconnect: { error: { output: { statusCode: DisconnectReason.restartRequired } } },
});
for (let index = 0; index < 20 && socketIndex < 2; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(socketIndex, 2);
assert.equal(saveCount, 1);
assert.equal(session.socket, sockets[1]);
sockets[1].ev.emit('connection.update', { connection: 'open' });
assert.deepEqual(await session.ready, {
accountJid: ACCOUNT_JID,
name: 'Harness WhatsApp',
});
await session.close();
});
test('WhatsApp Web session accepts recent append events without replaying stale history', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
const events = new EventEmitter();
const received = [];
const session = await createWhatsappWebSession({
authDir: root,
onQr: () => {},
onMessage: async (message) => { received.push(message.key.id); },
makeSocket: () => ({
ev: events,
end: async () => {},
logout: async () => {},
}),
loadAuthState: async () => ({
state: {
creds: {},
keys: { get: async () => ({}), set: async () => {} },
},
saveCreds: async () => {},
}),
});
void session.ready.catch(() => undefined);
events.emit('messages.upsert', {
type: 'append',
messages: [
{ key: { id: 'recent' }, messageTimestamp: Math.floor(Date.now() / 1_000) },
{ key: { id: 'stale' }, messageTimestamp: Math.floor(Date.now() / 1_000) - 300 },
],
});
events.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'notify' } }],
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(received, ['recent', 'notify']);
await session.close();
});
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
const direct = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
message: { conversation: 'hello' },
}, ACCOUNT_JID);
assert.equal(direct.kind, 'direct');
assert.equal(direct.addressed, true);
assert.equal(direct.content, 'hello');
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'group-1',
fromMe: false,
},
message: {
extendedTextMessage: {
text: 'question',
contextInfo: { mentionedJid: [ACCOUNT_JID] },
},
},
}, ACCOUNT_JID);
assert.equal(group.kind, 'group');
assert.equal(group.addressed, true);
assert.equal(normalizeWhatsappMessage({
key: { remoteJid: 'status@broadcast', id: 'ignored', fromMe: false },
message: { conversation: 'ignored' },
}, ACCOUNT_JID), null);
const selfChat = normalizeWhatsappMessage({
key: { remoteJid: ACCOUNT_JID, id: 'self-1', fromMe: true },
message: { conversation: 'message yourself' },
}, ACCOUNT_JID);
assert.equal(selfChat.selfChat, true);
assert.equal(selfChat.addressed, true);
assert.equal(normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'outbound-1', fromMe: true },
message: { conversation: 'ordinary outbound message' },
}, ACCOUNT_JID), null);
});
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
let callbacks;
const calls = [];
const socket = {
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
readMessages: async () => {},
sendMessage: async (jid, content) => {
calls.push(['message', jid, content]);
return { key: { id: 'reply-1' } };
},
};
const state = {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-1',
sessionExists: async () => true,
};
const harness = {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => 'Harness answer',
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-2', fromMe: false },
message: { conversation: 'hello' },
});
assert.equal(runtime.status.ready, true);
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
await runtime.stop();
});
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
let callbacks;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => { throw new Error('self-chat must not send a read receipt'); },
sendMessage: async (jid, content) => {
sent.push([jid, content]);
return { key: { id: 'bot-reply-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-self-chat',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => { askCount += 1; return 'Harness self-chat answer'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-self',
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();
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'owner-message-1', fromMe: true },
message: { conversation: 'hello from message yourself' },
});
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'bot-reply-1', fromMe: true },
message: { conversation: 'Harness self-chat answer' },
});
assert.equal(askCount, 1);
assert.deepEqual(sent, [[ACCOUNT_JID, { text: 'Harness self-chat answer' }]]);
await runtime.stop();
});
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();
let sessionOptions;
let resolveReady;
const ready = new Promise((resolve) => { resolveReady = resolve; });
const deletedAuth = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
createSession: async (options) => {
sessionOptions = options;
queueMicrotask(() => options.onQr('raw-linked-device-qr'));
return { ready, close: async () => {} };
},
createRuntime: async () => ({
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
lastCheckedAt: Date.now(),
},
start: async () => {},
stop: async () => {},
}),
deleteAuth: async (name) => deletedAuth.push(name),
});
t.after(() => controller.close());
const handler = createWhatsappRpcHandler(controller, {
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
});
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
assert.equal(started.ok, true);
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
assert.doesNotMatch(JSON.stringify(started.value), /raw-linked-device-qr|accountJid|authDirectory/);
resolveReady({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' });
let status;
for (let index = 0; index < 20; index += 1) {
status = await handler(WHATSAPP_ENDPOINTS.status, {});
if (status.value?.bots?.length) break;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.equal(status.ok, true);
assert.equal(status.value.bots[0].connected, true);
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
assert.equal(sessionOptions.signal.aborted, false);
assert.deepEqual(deletedAuth, []);
});

View file

@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
import.meta.url,
);
test('IM settings renders seven compact logo channel tabs without enable switches', async () => {
test('IM settings renders eight compact logo channel tabs without enable switches', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
feishuRpcCall: async () => ({ ok: true, value: {} }),
@ -75,6 +75,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
qqRpcCall: async () => ({ ok: true, value: {} }),
telegramRpcCall: async () => ({ ok: true, value: {} }),
discordRpcCall: async () => ({ ok: true, value: {} }),
whatsappRpcCall: async () => ({ ok: true, value: {} }),
}));
assert.match(markup, /IM机器人/);
@ -87,6 +88,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
assert.match(markup, />QQ</);
assert.match(markup, />Telegram</);
assert.match(markup, />Discord</);
assert.match(markup, />WhatsApp</);
assert.match(markup, /dim-logoWeixin/);
assert.match(markup, /dim-logoFeishu/);
assert.match(markup, /dim-logoDingtalk/);
@ -94,8 +96,9 @@ test('IM settings renders seven compact logo channel tabs without enable switche
assert.match(markup, /dim-logoQq/);
assert.match(markup, /dim-logoTelegram/);
assert.match(markup, /dim-logoDiscord/);
assert.match(markup, /dim-logoWhatsapp/);
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
assert.equal((markup.match(/role="tab"/g) ?? []).length, 7);
assert.equal((markup.match(/role="tab"/g) ?? []).length, 8);
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);

View file

@ -3,7 +3,7 @@ import test from 'node:test';
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
test('Host composes all seven IM channels inside one plugin context', async () => {
test('Host composes all eight IM channels inside one plugin context', async () => {
const calls = [];
const plugin = createImHostPlugin({
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
@ -13,6 +13,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
});
const ctx = { marker: 'shared-context' };
const config = {
@ -24,6 +25,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
qq: { replyTimeoutMs: 60_000 },
telegram: { replyTimeoutMs: 60_000 },
discord: { replyTimeoutMs: 60_000 },
whatsapp: { replyTimeoutMs: 60_000 },
};
await plugin.apply(ctx, config);
@ -38,6 +40,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
]);
});