349 lines
11 KiB
TypeScript
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 } : {}),
|
|
});
|
|
}
|
|
}
|