/** * 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 WhatsApp from 'whatsapp-web.js'; const { Client, LocalAuth, MessageMedia } = WhatsApp; import type { Attachment, InboundMessage, OutboundMessage, OutboundAttachment, ChannelAdapter, ChannelStatus, } from '../types.js'; import { allowTrustedOrPairedSender, buildResetInboundMessage, normalizeResetCommandText, shouldIgnoreForMissingMention, splitMessage, } from '../utils.js'; import type { PairingManager } from '../pairing.js'; /** Configuration for the WhatsApp channel adapter. */ export interface WhatsAppAdapterConfig { /** Phone numbers allowed to interact. Empty = all numbers. */ allowedNumbers?: string[]; /** Group IDs (without @g.us suffix) allowed to interact. Empty = no groups. */ allowedGroupIds?: string[]; /** Require bot mention to respond in group chats (default: true). DMs always respond. */ requireMention?: boolean; /** Directory for session persistence (LocalAuth data path). */ dataDir?: string; /** Optional pairing manager for DM pairing codes. */ pairingManager?: PairingManager; /** Allow launching Chromium without sandbox (unsafe; use only in high-trust/containerized setups). */ allowNoSandbox?: boolean; } /** 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 }; /** Whether this message contains media (image, video, audio, document). */ hasMedia?: boolean; /** Message type (e.g. "image", "video", "chat"). */ type?: string; /** Download the media attached to this message. */ downloadMedia?: () => Promise<{ mimetype: string; data: string; filename?: string } | null>; /** Chat handle for typing indicator. */ getChat?: () => Promise<{ sendStateTyping: () => Promise }>; /** Mentioned user IDs in message metadata. */ mentionedIds?: string[]; } /** * 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: InstanceType | null = null; private messageHandler?: (msg: InboundMessage) => void; private config: WhatsAppAdapterConfig; private botId?: string; 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 { this._status = 'connecting'; try { const authStrategy = new LocalAuth({ dataPath: this.config.dataDir, }); const puppeteerArgs = this.config.allowNoSandbox ? ['--no-sandbox', '--disable-setuid-sandbox'] : []; if (this.config.allowNoSandbox) { console.warn('WhatsApp adapter: Chromium sandbox disabled via config (unsafe).'); } this.client = new Client({ authStrategy, puppeteer: { headless: true, args: puppeteerArgs, }, }); const client = this.client; if (!client) { throw new Error('WhatsApp client initialization failed'); } // Promise that resolves on 'ready' or rejects on 'auth_failure' const readyPromise = new Promise((resolve, reject) => { client.on('ready', () => { console.log('WhatsApp bot connected'); this._status = 'connected'; // Capture bot's own JID for mention detection const clientInfo = client as InstanceType & { info?: { wid?: { _serialized?: string } }; }; this.botId = clientInfo.info?.wid?._serialized; resolve(); }); client.on('auth_failure', (msg: string) => { this._status = 'error'; reject(new Error(`WhatsApp auth failure: ${msg}`)); }); client.on('qr', (qr: string) => { console.log('WhatsApp QR code received. Scan with your phone:'); console.log(qr); }); }); // Register message event handler client.on('message', (message: unknown) => { this.handleMessage(message as WhatsAppMessage); }); await client.initialize(); await readyPromise; } catch (error) { this._status = 'error'; throw error; } } /** Stop the client and clean up. */ async disconnect(): Promise { 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 { 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); } } // Send outbound attachments after text if (message.attachments && message.attachments.length > 0) { for (const attachment of message.attachments) { await this.sendAttachment(peerId, attachment); } } } /** Send a single outbound attachment via WhatsApp using MessageMedia. */ private async sendAttachment(peerId: string, attachment: OutboundAttachment): Promise { if (!this.client) {return;} try { if (attachment.data) { const media = new MessageMedia( attachment.mimeType, attachment.data, attachment.filename, ); await this.client.sendMessage(peerId, media); } else if (attachment.url) { // Download from URL and send as MessageMedia const media = await MessageMedia.fromUrl(attachment.url); await this.client.sendMessage(peerId, media); } } catch (error) { console.error( `WhatsApp: failed to send ${attachment.mimeType} attachment:`, error instanceof Error ? error.message : 'Unknown error', ); } } /** Internal: process an inbound WhatsApp message. */ private async handleMessage(message: WhatsAppMessage): Promise { if (!this.messageHandler) {return;} // Ignore messages from the bot itself if (message.fromMe) {return;} const from = message.from; // Group message handling const isGroup = from.endsWith('@g.us'); if (isGroup) { // Check allowed group IDs const groupId = from.replace(/@g\.us$/, ''); if ( !this.config.allowedGroupIds || this.config.allowedGroupIds.length === 0 || !this.config.allowedGroupIds.includes(groupId) ) { return; // Group not allowed (empty list = no groups) } // Mention requirement in group chats if (this.botId && shouldIgnoreForMissingMention({ requireMention: this.config.requireMention, defaultRequireMention: true, mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || message.mentionedIds?.some((id) => id === this.botId) === true, })) { // WhatsApp mentions use @phone_number format in body // Also check for mentions in the message mentionedIds return; } } // Check allowed numbers for DMs (strip @c.us suffix for comparison) if (!isGroup) { const phoneNumber = from.replace(/@c\.us$/, ''); if ( this.config.allowedNumbers && this.config.allowedNumbers.length > 0 && !this.config.allowedNumbers.includes(phoneNumber) ) { const allowed = await allowTrustedOrPairedSender({ pairingManager: this.config.pairingManager, channel: 'whatsapp', senderId: phoneNumber, text: message.body ?? '', isTrusted: false, onPaired: async () => { if (!this.client) {return;} await this.client.sendMessage(from, 'Pairing successful! You can now chat with Flynn.'); }, }); if (!allowed) { return; } } } // Send typing indicator try { const chat = await message.getChat?.(); await chat?.sendStateTyping(); } catch { /* ignore typing errors */ } // Strip bot mention from message body for group messages let text = message.body ?? ''; if (isGroup && this.botId) { const botNumber = this.botId.replace(/@c\.us$/, ''); text = text.replace(new RegExp(`@${botNumber}\\b`, 'g'), '').trim(); } const senderName = message._data?.notifyName; // Extract media attachments if the message has media const attachments: Attachment[] = []; if (message.hasMedia) { try { const media = await message.downloadMedia?.(); if (media && typeof media.mimetype === 'string') { const mimeType = media.mimetype; const isAudio = mimeType.startsWith('audio/'); const isImage = mimeType.startsWith('image/'); const isVoice = message.type === 'ptt'; if (isAudio || isImage || isVoice) { attachments.push({ mimeType: mimeType, data: media.data, filename: media.filename, }); } } } catch (error) { console.error( 'Failed to download WhatsApp media:', error instanceof Error ? error.message : 'Unknown error', ); } } text = normalizeResetCommandText(text); // Detect reset command if (text === '!reset') { this.messageHandler(buildResetInboundMessage({ id: message.id.id, channel: 'whatsapp', senderId: from, senderName, attachments, })); return; } // Regular message this.messageHandler({ id: message.id.id, channel: 'whatsapp', senderId: from, senderName, text, timestamp: Date.now(), ...(attachments.length > 0 ? { attachments } : {}), }); } }