feat: add WhatsApp channel adapter (Phase 3c)

This commit is contained in:
William Valentin
2026-02-06 14:42:07 -08:00
parent 7a35b22458
commit de68deb1b2
9 changed files with 1756 additions and 7 deletions
+1
View File
@@ -11,3 +11,4 @@ export { TelegramAdapter, type TelegramAdapterConfig } from './telegram/index.js
export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js';
export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js';
+428
View File
@@ -0,0 +1,428 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mock whatsapp-web.js before importing adapter ───────────────
// We need to intercept the Client constructor and its event handlers.
let capturedEventHandlers: Record<string, ((...args: unknown[]) => void)[]> = {};
const mockSendMessage = vi.fn();
const mockInitialize = vi.fn();
const mockDestroy = vi.fn();
interface MockClientInfo {
pushname?: string;
}
/** Create a fresh mock Client instance. */
function createMockClient() {
capturedEventHandlers = {};
return {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
if (!capturedEventHandlers[event]) {
capturedEventHandlers[event] = [];
}
capturedEventHandlers[event].push(handler);
}),
initialize: mockInitialize,
destroy: mockDestroy,
sendMessage: mockSendMessage,
info: { pushname: 'Flynn' } as MockClientInfo,
};
}
let mockClient = createMockClient();
vi.mock('whatsapp-web.js', () => ({
Client: vi.fn().mockImplementation(() => mockClient),
LocalAuth: vi.fn().mockImplementation((opts: Record<string, unknown>) => ({
type: 'local',
...opts,
})),
}));
import { WhatsAppAdapter, type WhatsAppAdapterConfig } from './adapter.js';
import type { InboundMessage } from '../types.js';
const baseConfig: WhatsAppAdapterConfig = {};
/** Helper: simulate a WhatsApp event through the captured handler. */
function simulateEvent(event: string, ...args: unknown[]) {
const handlers = capturedEventHandlers[event];
if (!handlers || handlers.length === 0) {
throw new Error(`No handler captured for event "${event}" — call connect() first`);
}
for (const handler of handlers) {
handler(...args);
}
}
/** Create a mock WhatsApp message object. */
function createMockMessage(overrides: Record<string, unknown> = {}) {
return {
id: { id: 'MSG001', fromMe: false, ...(overrides._id as Record<string, unknown> ?? {}) },
from: '5511999999999@c.us',
body: 'Hello Flynn',
timestamp: 1700000000,
fromMe: false,
author: undefined,
...overrides,
};
}
describe('WhatsAppAdapter', () => {
let adapter: WhatsAppAdapter;
beforeEach(async () => {
vi.clearAllMocks();
mockClient = createMockClient();
// Re-wire the Client mock to return the fresh mockClient
const { Client } = vi.mocked(await import('whatsapp-web.js'));
(Client as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockClient);
adapter = new WhatsAppAdapter(baseConfig);
});
// ── Basic properties ──────────────────────────────────────────
it('has name "whatsapp"', () => {
expect(adapter.name).toBe('whatsapp');
});
it('starts as disconnected', () => {
expect(adapter.status).toBe('disconnected');
});
// ── connect / disconnect ──────────────────────────────────────
it('connect creates Client and sets connected status after ready event', async () => {
const connectPromise = adapter.connect();
// Simulate the ready event
simulateEvent('ready');
await connectPromise;
expect(adapter.status).toBe('connected');
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('connect registers message and ready event handlers', async () => {
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
// Should have registered at least 'ready' and 'message' handlers
expect(capturedEventHandlers['ready']).toBeDefined();
expect(capturedEventHandlers['message']).toBeDefined();
});
it('connect sets error status on auth_failure', async () => {
const connectPromise = adapter.connect();
// Simulate auth failure
simulateEvent('auth_failure', 'Invalid session');
await expect(connectPromise).rejects.toThrow();
expect(adapter.status).toBe('error');
});
it('disconnect destroys client and sets disconnected', async () => {
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
expect(adapter.status).toBe('connected');
await adapter.disconnect();
expect(mockDestroy).toHaveBeenCalledTimes(1);
expect(adapter.status).toBe('disconnected');
});
it('disconnect is safe when not connected', async () => {
await adapter.disconnect();
expect(adapter.status).toBe('disconnected');
// No client to destroy — should not throw
});
// ── send ──────────────────────────────────────────────────────
it('send throws when not connected', async () => {
await expect(
adapter.send('5511999999999@c.us', { text: 'hello' }),
).rejects.toThrow('WhatsApp adapter not connected');
});
it('send delivers a short message to the correct peer', async () => {
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
await adapter.send('5511999999999@c.us', { text: 'Hello there' });
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledWith('5511999999999@c.us', 'Hello there');
});
it('send chunks long messages (>4096 chars)', async () => {
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
// Create a message longer than 4096 chars — two halves joined by a newline
const half = 'A'.repeat(3000);
const longMessage = `${half}\n${'B'.repeat(3000)}`;
await adapter.send('5511999999999@c.us', { text: longMessage });
// Should have been split into at least 2 chunks
expect(mockSendMessage.mock.calls.length).toBeGreaterThanOrEqual(2);
// All calls should target the same peer
for (const call of mockSendMessage.mock.calls) {
expect(call[0]).toBe('5511999999999@c.us');
}
});
// ── onMessage / inbound handling ──────────────────────────────
it('inbound message triggers handler with correct peerId (phone@c.us)', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello Flynn',
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.channel).toBe('whatsapp');
expect(msg.senderId).toBe('5511999999999@c.us');
expect(msg.text).toBe('Hello Flynn');
expect(msg.id).toBe('MSG001');
});
it('ignores messages from the bot itself (fromMe === true)', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
fromMe: true,
}));
expect(handler).not.toHaveBeenCalled();
});
it('ignores messages from numbers not in allowed_numbers list', async () => {
const restrictedAdapter = new WhatsAppAdapter({
allowedNumbers: ['5511888888888'],
});
const handler = vi.fn();
restrictedAdapter.onMessage(handler);
const connectPromise = restrictedAdapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello',
}));
expect(handler).not.toHaveBeenCalled();
});
it('allows messages when allowed_numbers is empty (no restriction)', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello',
}));
expect(handler).toHaveBeenCalledTimes(1);
});
it('allows messages from numbers in the allowed list', async () => {
const restrictedAdapter = new WhatsAppAdapter({
allowedNumbers: ['5511999999999'],
});
const handler = vi.fn();
restrictedAdapter.onMessage(handler);
const connectPromise = restrictedAdapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello',
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('Hello');
});
it('!reset command delivers reset metadata', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
body: '!reset',
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('!reset');
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
});
it('"reset" text (without !) delivers reset metadata', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
body: 'reset',
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('!reset');
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
});
it('does nothing when no message handler is registered', async () => {
// Don't call onMessage — no handler registered
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
// Should not throw
simulateEvent('message', createMockMessage());
});
it('messages with empty body are handled', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
body: '',
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('');
});
it('extracts sender name from message contact when available', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello',
_data: { notifyName: 'John' },
}));
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.senderName).toBe('John');
});
it('strips @c.us suffix when checking allowed numbers', async () => {
const restrictedAdapter = new WhatsAppAdapter({
allowedNumbers: ['5511999999999'],
});
const handler = vi.fn();
restrictedAdapter.onMessage(handler);
const connectPromise = restrictedAdapter.connect();
simulateEvent('ready');
await connectPromise;
// Message comes with @c.us suffix
simulateEvent('message', createMockMessage({
from: '5511999999999@c.us',
body: 'Hello',
}));
expect(handler).toHaveBeenCalledTimes(1);
});
it('ignores group messages (from addresses ending in @g.us)', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
simulateEvent('message', createMockMessage({
from: '120363025555555555@g.us',
body: 'Group message',
}));
// WhatsApp adapter should only handle direct messages for now
expect(handler).not.toHaveBeenCalled();
});
it('passes data_dir to LocalAuth strategy', async () => {
const adapterWithDir = new WhatsAppAdapter({
dataDir: '/tmp/whatsapp-session',
});
const connectPromise = adapterWithDir.connect();
simulateEvent('ready');
await connectPromise;
const { LocalAuth } = await import('whatsapp-web.js');
expect(LocalAuth).toHaveBeenCalledWith(
expect.objectContaining({ dataPath: '/tmp/whatsapp-session' }),
);
});
it('connect sets error status when initialize() rejects', async () => {
mockInitialize.mockRejectedValueOnce(new Error('Browser launch failed'));
await expect(adapter.connect()).rejects.toThrow('Browser launch failed');
expect(adapter.status).toBe('error');
});
it('disconnect cleans up even when destroy() throws', async () => {
const connectPromise = adapter.connect();
simulateEvent('ready');
await connectPromise;
mockDestroy.mockRejectedValueOnce(new Error('Cleanup failed'));
// Should not throw — wrapped in try/finally
await adapter.disconnect();
expect(adapter.status).toBe('disconnected');
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* WhatsApp channel adapter.
*
* Implements the ChannelAdapter interface using whatsapp-web.js with headless Chrome.
* QR code auth flow: prints QR to console for scanning on first run.
* Session persistence via LocalAuth strategy with configurable data_dir.
* Messages are chunked at 4096 chars (same as Telegram).
*/
import { Client, LocalAuth } from 'whatsapp-web.js';
import type {
InboundMessage,
OutboundMessage,
ChannelAdapter,
ChannelStatus,
} from '../types.js';
/** Configuration for the WhatsApp channel adapter. */
export interface WhatsAppAdapterConfig {
/** Phone numbers allowed to interact. Empty = all numbers. */
allowedNumbers?: string[];
/** Directory for session persistence (LocalAuth data path). */
dataDir?: string;
}
/** Minimal shape of a whatsapp-web.js message. */
interface WhatsAppMessage {
id: { id: string; fromMe: boolean };
from: string;
body: string;
timestamp: number;
fromMe: boolean;
author?: string;
_data?: { notifyName?: string };
}
/**
* Split a long message into chunks that respect WhatsApp's readability limit.
* Prefers splitting at newlines, then spaces, then hard-cuts.
*/
function splitMessage(text: string, maxLength: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to split at a newline within the allowed window
let splitIndex = remaining.lastIndexOf('\n', maxLength);
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = remaining.lastIndexOf(' ', maxLength);
}
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = maxLength;
}
chunks.push(remaining.slice(0, splitIndex));
remaining = remaining.slice(splitIndex).trimStart();
}
return chunks;
}
/**
* WhatsApp channel adapter backed by whatsapp-web.js.
*
* Handles QR code authentication, phone number allowlist filtering,
* session persistence, and message chunking for 4096-char limit.
*/
export class WhatsAppAdapter implements ChannelAdapter {
readonly name = 'whatsapp';
private _status: ChannelStatus = 'disconnected';
private client: Client | null = null;
private messageHandler?: (msg: InboundMessage) => void;
private config: WhatsAppAdapterConfig;
get status(): ChannelStatus {
return this._status;
}
constructor(config: WhatsAppAdapterConfig) {
this.config = config;
}
/** Register the inbound message handler. Called by the registry before connect(). */
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
/** Create the whatsapp-web.js client, wire up event handlers, and initialize. */
async connect(): Promise<void> {
this._status = 'connecting';
try {
const authStrategy = new LocalAuth({
dataPath: this.config.dataDir,
});
this.client = new Client({
authStrategy,
puppeteer: {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
},
});
// Promise that resolves on 'ready' or rejects on 'auth_failure'
const readyPromise = new Promise<void>((resolve, reject) => {
this.client!.on('ready', () => {
console.log('WhatsApp bot connected');
this._status = 'connected';
resolve();
});
this.client!.on('auth_failure', (msg: string) => {
this._status = 'error';
reject(new Error(`WhatsApp auth failure: ${msg}`));
});
this.client!.on('qr', (qr: string) => {
console.log('WhatsApp QR code received. Scan with your phone:');
console.log(qr);
});
});
// Register message event handler
this.client.on('message', (message: unknown) => {
this.handleMessage(message as WhatsAppMessage);
});
await this.client.initialize();
await readyPromise;
} catch (error) {
this._status = 'error';
throw error;
}
}
/** Stop the client and clean up. */
async disconnect(): Promise<void> {
if (this.client) {
try {
await this.client.destroy();
} catch {
// Swallow destroy errors — cleanup must complete
} finally {
this.client = null;
}
}
this._status = 'disconnected';
}
/** Send an outbound message, automatically chunking if it exceeds 4096 chars. */
async send(peerId: string, message: OutboundMessage): Promise<void> {
if (!this.client) throw new Error('WhatsApp adapter not connected');
const text = message.text;
if (text.length <= 4096) {
await this.client.sendMessage(peerId, text);
} else {
const chunks = splitMessage(text, 4096);
for (const chunk of chunks) {
await this.client.sendMessage(peerId, chunk);
}
}
}
/** Internal: process an inbound WhatsApp message. */
private handleMessage(message: WhatsAppMessage): void {
if (!this.messageHandler) return;
// Ignore messages from the bot itself
if (message.fromMe) return;
const from = message.from;
// Ignore group messages (only handle direct messages)
if (from.endsWith('@g.us')) return;
// Check allowed numbers (strip @c.us suffix for comparison)
const phoneNumber = from.replace(/@c\.us$/, '');
if (
this.config.allowedNumbers &&
this.config.allowedNumbers.length > 0 &&
!this.config.allowedNumbers.includes(phoneNumber)
) {
return;
}
const text = message.body ?? '';
const senderName = message._data?.notifyName;
// Detect reset command
if (text === '!reset' || text === 'reset') {
this.messageHandler({
id: message.id.id,
channel: 'whatsapp',
senderId: from,
senderName,
text: '!reset',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'reset' },
});
return;
}
// Regular message
this.messageHandler({
id: message.id.id,
channel: 'whatsapp',
senderId: from,
senderName,
text,
timestamp: Date.now(),
});
}
}
+1
View File
@@ -0,0 +1 @@
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './adapter.js';