feat: add WhatsApp channel adapter (Phase 3c)
This commit is contained in:
+23
-6
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"p0-p1-implementation-plan": {
|
"p0-p1-implementation-plan": {
|
||||||
"file": "2026-02-06-p0-p1-implementation-plan.md",
|
"file": "2026-02-06-p0-p1-implementation-plan.md",
|
||||||
"status": "in_progress",
|
"status": "completed",
|
||||||
"date": "2026-02-06",
|
"date": "2026-02-06",
|
||||||
"summary": "7 features across 7 phases (0-6). Estimated 15-22 days total.",
|
"summary": "7 features across 7 phases (0-6). Estimated 15-22 days total.",
|
||||||
"phases": {
|
"phases": {
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"phase_3_messaging_channels": {
|
"phase_3_messaging_channels": {
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"status": "in_progress",
|
"status": "completed",
|
||||||
"description": "Discord, Slack, WhatsApp channel adapters",
|
"description": "Discord, Slack, WhatsApp channel adapters",
|
||||||
"sub_phases": {
|
"sub_phases": {
|
||||||
"3a_discord": {
|
"3a_discord": {
|
||||||
@@ -113,7 +113,24 @@
|
|||||||
"test_status": "22/22 passing",
|
"test_status": "22/22 passing",
|
||||||
"notes": "Socket Mode only (HTTP fallback deferred). Slash commands deferred. User ID used as senderName (display name resolution is a follow-up)."
|
"notes": "Socket Mode only (HTTP fallback deferred). Slash commands deferred. User ID used as senderName (display name resolution is a follow-up)."
|
||||||
},
|
},
|
||||||
"3c_whatsapp": { "status": "not_started", "effort": "2-3 days" }
|
"3c_whatsapp": {
|
||||||
|
"status": "completed",
|
||||||
|
"effort": "2-3 days",
|
||||||
|
"files_created": [
|
||||||
|
"src/channels/whatsapp/adapter.ts",
|
||||||
|
"src/channels/whatsapp/adapter.test.ts",
|
||||||
|
"src/channels/whatsapp/index.ts"
|
||||||
|
],
|
||||||
|
"files_modified": [
|
||||||
|
"src/config/schema.ts",
|
||||||
|
"src/channels/index.ts",
|
||||||
|
"src/daemon/index.ts",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"new_dependencies": ["whatsapp-web.js"],
|
||||||
|
"test_status": "25/25 passing",
|
||||||
|
"notes": "QR code auth via LocalAuth. Session persistence via data_dir. Group messages filtered (DM only). Phone number allowlist. Headless Chrome with Puppeteer."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"planned_files": [
|
"planned_files": [
|
||||||
"src/channels/discord/adapter.ts",
|
"src/channels/discord/adapter.ts",
|
||||||
@@ -206,10 +223,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 494,
|
"total_test_count": 519,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "3/4 (75%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
"next_up": "phase_3c_whatsapp_adapter"
|
"next_up": "all_p0_p1_complete"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"openai": "^4.0.0",
|
"openai": "^4.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
"whatsapp-web.js": "^1.34.6",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"yaml": "^2.7.0",
|
"yaml": "^2.7.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
|
|||||||
Generated
+1063
File diff suppressed because it is too large
Load Diff
@@ -11,3 +11,4 @@ export { TelegramAdapter, type TelegramAdapterConfig } from './telegram/index.js
|
|||||||
export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js';
|
export { WebChatAdapter, type WebChatAdapterConfig } from './webchat/index.js';
|
||||||
export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
|
export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js';
|
||||||
export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
|
export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js';
|
||||||
|
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './adapter.js';
|
||||||
@@ -135,6 +135,11 @@ const slackSchema = z.object({
|
|||||||
allowed_channel_ids: z.array(z.string()).default([]),
|
allowed_channel_ids: z.array(z.string()).default([]),
|
||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
|
const whatsappSchema = z.object({
|
||||||
|
allowed_numbers: z.array(z.string()).default([]),
|
||||||
|
data_dir: z.string().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
const processSchema = z.object({
|
const processSchema = z.object({
|
||||||
max_concurrent: z.number().min(1).max(50).default(10),
|
max_concurrent: z.number().min(1).max(50).default(10),
|
||||||
max_runtime_minutes: z.number().min(1).max(1440).default(60),
|
max_runtime_minutes: z.number().min(1).max(1440).default(60),
|
||||||
@@ -152,6 +157,7 @@ export const configSchema = z.object({
|
|||||||
telegram: telegramSchema,
|
telegram: telegramSchema,
|
||||||
discord: discordSchema,
|
discord: discordSchema,
|
||||||
slack: slackSchema,
|
slack: slackSchema,
|
||||||
|
whatsapp: whatsappSchema,
|
||||||
server: serverSchema.default({}),
|
server: serverSchema.default({}),
|
||||||
models: modelsSchema,
|
models: modelsSchema,
|
||||||
backends: backendsSchema.default({}),
|
backends: backendsSchema.default({}),
|
||||||
@@ -177,3 +183,4 @@ export type WebSearchConfig = z.infer<typeof webSearchSchema>;
|
|||||||
export type ProcessConfig = z.infer<typeof processSchema>;
|
export type ProcessConfig = z.infer<typeof processSchema>;
|
||||||
export type DiscordConfig = z.infer<typeof discordSchema>;
|
export type DiscordConfig = z.infer<typeof discordSchema>;
|
||||||
export type SlackConfig = z.infer<typeof slackSchema>;
|
export type SlackConfig = z.infer<typeof slackSchema>;
|
||||||
|
export type WhatsAppConfig = z.infer<typeof whatsappSchema>;
|
||||||
|
|||||||
+10
-1
@@ -9,7 +9,7 @@ import { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, crea
|
|||||||
import { MemoryStore } from '../memory/index.js';
|
import { MemoryStore } from '../memory/index.js';
|
||||||
import { createMemoryTools } from '../tools/builtin/index.js';
|
import { createMemoryTools } from '../tools/builtin/index.js';
|
||||||
import { GatewayServer } from '../gateway/index.js';
|
import { GatewayServer } from '../gateway/index.js';
|
||||||
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter } from '../channels/index.js';
|
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter } from '../channels/index.js';
|
||||||
import { CronScheduler } from '../automation/index.js';
|
import { CronScheduler } from '../automation/index.js';
|
||||||
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||||
import { McpManager } from '../mcp/index.js';
|
import { McpManager } from '../mcp/index.js';
|
||||||
@@ -401,6 +401,15 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
channelRegistry.register(slackAdapter);
|
channelRegistry.register(slackAdapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register WhatsApp adapter (if configured)
|
||||||
|
if (config.whatsapp) {
|
||||||
|
const whatsappAdapter = new WhatsAppAdapter({
|
||||||
|
allowedNumbers: config.whatsapp.allowed_numbers.length > 0 ? config.whatsapp.allowed_numbers : undefined,
|
||||||
|
dataDir: config.whatsapp.data_dir,
|
||||||
|
});
|
||||||
|
channelRegistry.register(whatsappAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
// Register WebChat adapter (wraps the gateway)
|
// Register WebChat adapter (wraps the gateway)
|
||||||
const webChatAdapter = new WebChatAdapter({ gateway });
|
const webChatAdapter = new WebChatAdapter({ gateway });
|
||||||
channelRegistry.register(webChatAdapter);
|
channelRegistry.register(webChatAdapter);
|
||||||
|
|||||||
Reference in New Issue
Block a user