dsh-im-ops/test/channels/whatsapp/whatsapp.test.mjs

663 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 exposes image media through a bounded Baileys download stream', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const calls = [];
const controller = new AbortController();
const group = normalizeWhatsappMessage({
key: {
remoteJid: '120363000000000000@g.us',
participant: '16505550999@s.whatsapp.net',
id: 'group-image-1',
fromMe: false,
},
message: {
imageMessage: {
mimetype: 'image/png',
caption: '看看这张图',
fileLength: { toString: () => String(png.length) },
url: 'https://mmg.whatsapp.net/image',
contextInfo: { mentionedJid: [ACCOUNT_JID] },
},
},
}, ACCOUNT_JID, {
download: async (raw, type, options) => {
calls.push({ raw, type, options });
return {
async *[Symbol.asyncIterator]() { yield png; },
};
},
});
assert.equal(group.addressed, true);
assert.equal(group.content, '看看这张图');
assert.equal(group.images.length, 1);
assert.equal(group.images[0].size, png.length);
assert.deepEqual(await group.images[0].load({ signal: controller.signal, maxBytes: 100 }), png);
assert.equal(calls[0].type, 'stream');
assert.equal(calls[0].options.options.signal instanceof AbortSignal, true);
const downloadStarted = Promise.withResolvers();
const lateStream = Promise.withResolvers();
let lateStreamDestroyed = false;
const cancelled = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'cancelled-1', fromMe: false },
message: {
imageMessage: { mimetype: 'image/png', url: 'https://mmg.whatsapp.net/cancelled' },
},
}, ACCOUNT_JID, {
download: async () => {
downloadStarted.resolve();
return lateStream.promise;
},
});
const cancelledController = new AbortController();
const cancelledLoad = cancelled.images[0].load({
signal: cancelledController.signal,
maxBytes: 100,
});
await downloadStarted.promise;
cancelledController.abort(new DOMException('Stopped', 'AbortError'));
await assert.rejects(cancelledLoad, { name: 'AbortError' });
lateStream.resolve({ destroy() { lateStreamDestroyed = true; } });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(lateStreamDestroyed, true);
const document = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'document-image-1', fromMe: false },
message: {
documentMessage: {
mimetype: 'image/webp', fileName: 'diagram.webp', fileLength: 2_000,
url: 'https://mmg.whatsapp.net/document',
},
},
}, ACCOUNT_JID);
assert.equal(document.images[0].name, 'diagram.webp');
assert.equal(document.images[0].mediaType, 'image/webp');
for (const [index, wrapper] of [
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
].entries()) {
const wrappedMessage = {
[wrapper]: {
message: {
imageMessage: {
mimetype: 'image/jpeg', url: `https://mmg.whatsapp.net/view-once-${index}`,
},
},
},
};
const viewOnce = normalizeWhatsappMessage({
key: {
remoteJid: '16505550999@s.whatsapp.net',
id: `view-once-${index}`,
fromMe: false,
},
message: index === 1
? { ephemeralMessage: { message: wrappedMessage } }
: wrappedMessage,
}, ACCOUNT_JID);
assert.deepEqual(viewOnce.images, []);
}
const oversized = normalizeWhatsappMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'oversized-1', fromMe: false },
message: {
imageMessage: { mimetype: 'image/jpeg', url: 'https://mmg.whatsapp.net/oversized' },
},
}, ACCOUNT_JID, {
download: async () => ({
async *[Symbol.asyncIterator]() {
yield Buffer.alloc(4);
yield Buffer.alloc(4);
},
destroy() {},
}),
});
await assert.rejects(() => oversized.images[0].load({ maxBytes: 5 }), (error) => {
assert.equal(error.code, 'image-too-large');
return true;
});
});
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 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();
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, []);
});