feat: add WhatsApp channel adapter (Phase 3c)
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user