Files
flynn/src/channels/whatsapp/adapter.ts
T

349 lines
11 KiB
TypeScript

/**
* 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<void> }>;
/** 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<typeof Client> | 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<void> {
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<void>((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<typeof Client> & {
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<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);
}
}
// 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<void> {
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<void> {
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 } : {}),
});
}
}