dsh-im-ops/test/channels/whatsapp/whatsapp.test.mjs
2026-08-16 17:30:38 +08:00

355 lines
12 KiB
JavaScript

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, []);
});