mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 06:33:17 +08:00
355 lines
12 KiB
JavaScript
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, []);
|
|
});
|