/** * WhatsApp channel provider using Baileys (WhiskeySockets). * Handles connection, QR code flow, message send/receive, and auth persistence. */ import makeWASocket, { makeCacheableSignalKeyStore, fetchLatestBaileysVersion, downloadMediaMessage, DisconnectReason, Browsers, type WASocket, type BaileysEventMap, type WAMessageKey, } from '@whiskeysockets/baileys'; import fs from 'fs'; import path from 'path'; import QRCode from 'qrcode'; import pino from 'pino'; import { DATA_DIR } from '../../shared/paths.js'; import { log } from '../../shared/logger.js'; import { useAtomicMultiFileAuthState, hasValidCredsFile, flushAuthWrites } from './whatsapp-auth.js'; import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js'; const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth'); /** Media attachment extracted from a WhatsApp message. * `type: 'image'` → inline vision; `type: 'file'` → a document the agent reads from disk. */ export interface WhatsAppMediaAttachment { type: 'image' | 'file'; mediaType: string; data: string; // base64 /** Original filename — present for documents (WhatsApp supplies it), absent for images. */ name?: string; } /** Callback when a new message arrives. * - sender: who sent it (phone JID, translated from LID where possible) * - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this * - isGroup: true when the chat is a WhatsApp group (@g.us) * - media: image and/or document attachments extracted from the message * - inboundKey: original Baileys message key — used to react/quote/ack the user's message */ export type OnWhatsAppMessage = ( sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean, chatJid: string, isGroup: boolean, media?: WhatsAppMediaAttachment[], inboundKey?: WAMessageKey, ) => void; /** Callback to transcribe audio via whisper */ export type TranscribeFn = (audioBase64: string) => Promise; export class WhatsAppChannel implements ChannelProvider { readonly type: ChannelType = 'whatsapp'; private sock: WASocket | null = null; private connected = false; private qrData: string | null = null; private qrSvg: string | null = null; private onMessage: OnWhatsAppMessage; private onStatusChange: (status: ChannelStatus) => void; private transcribe: TranscribeFn | null = null; private reconnectTimer: ReturnType | null = null; private intentionalDisconnect = false; /** Monotonic token — bumping it invalidates any in-flight connectInternal(), so a * pending reconnect timer and a manual connect can't race into two live sockets * (two sockets on one auth dir = 440 conflict loops + divergent creds writes). */ private connectGen = 0; /** Consecutive failed reconnects — drives backoff; reset on successful open */ private reconnectAttempts = 0; /** Auto-regenerated QR windows since the last explicit connect — caps the * unattended pairing loop instead of cycling QR codes in the background forever */ private pairingRetries = 0; /** Last pending creds write — drained on disconnect so process exit can't clip it */ private lastCredsWrite: Promise = Promise.resolve(); /** IDs of messages we sent — used to prevent echo loops */ private sentMessageIds = new Set(); private readonly MAX_SENT_IDS = 100; /** Active typing indicator intervals per chat JID */ private typingIntervals = new Map>(); /** Maps LID JIDs to phone JIDs (WhatsApp uses LIDs internally for self-chat) */ private lidToPhoneMap = new Map(); /** Our own phone JID (number@s.whatsapp.net) */ private ownPhoneJid: string | null = null; constructor( onMessage: OnWhatsAppMessage, onStatusChange: (status: ChannelStatus) => void, transcribe?: TranscribeFn, ) { this.onMessage = onMessage; this.onStatusChange = onStatusChange; this.transcribe = transcribe || null; } async connect(): Promise { this.intentionalDisconnect = false; this.reconnectAttempts = 0; // explicit connect starts a fresh backoff ladder this.pairingRetries = 0; await this.connectInternal(); } /** Arm the reconnect timer. connectInternal can reject (disk errors during auth * load) — without the catch + re-arm, a timer-driven failure would strand the * channel offline with no retry until someone manually reconnects. */ private scheduleReconnect(delay: number): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => { this.connectInternal().catch((err: any) => { if (this.intentionalDisconnect) return; const next = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000); log.warn(`[whatsapp] Reconnect attempt failed: ${err.message} — retrying in ${Math.round(next / 1000)}s`); this.scheduleReconnect(next); }); }, delay); } async disconnect(): Promise { this.intentionalDisconnect = true; this.connectGen++; // invalidate any connectInternal() still in flight if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.sock) { this.sock.end(undefined); this.sock = null; } // Clear all typing intervals for (const interval of this.typingIntervals.values()) clearInterval(interval); this.typingIntervals.clear(); // Let in-flight auth writes (creds + signal keys) land before callers proceed to // process.exit — writes are atomic now, but a lost update rolls session keys back. await Promise.race([ Promise.all([this.lastCredsWrite, flushAuthWrites(AUTH_DIR)]), new Promise((r) => setTimeout(r, 2000)), ]); this.connected = false; this.qrData = null; this.qrSvg = null; this.emitStatus(); } async sendMessage(to: string, text: string): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot send — not connected'); return; } // Normalize: ensure JID format (number@s.whatsapp.net) const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`; // Clear typing indicator before sending this.stopTyping(jid); const result = await this.sock.sendMessage(jid, { text }); // Track sent message ID to prevent echo loops if (result?.key?.id) { this.trackSentId(result.key.id); } log.info(`[whatsapp] Sent message to ${jid} (id=${result?.key?.id || 'unknown'})`); } /** Send an image via WhatsApp */ async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot send image — not connected'); return; } const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`; this.stopTyping(jid); const msg: any = { image, mimetype: mimetype || 'image/png' }; if (caption) msg.caption = caption; const result = await this.sock.sendMessage(jid, msg); if (result?.key?.id) this.trackSentId(result.key.id); log.info(`[whatsapp] Sent image to ${jid} (id=${result?.key?.id || 'unknown'})`); } /** Send an audio file via WhatsApp. Set voiceNote=true for push-to-talk bubble. */ async sendAudio(to: string, audio: Buffer, opts?: { mimetype?: string; voiceNote?: boolean }): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot send audio — not connected'); return; } const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`; this.stopTyping(jid); const msg: any = { audio, mimetype: opts?.mimetype || 'audio/mpeg', ptt: !!opts?.voiceNote, }; const result = await this.sock.sendMessage(jid, msg); if (result?.key?.id) this.trackSentId(result.key.id); log.info(`[whatsapp] Sent audio to ${jid} (ptt=${msg.ptt}, mime=${msg.mimetype}, id=${result?.key?.id || 'unknown'})`); } /** Send a video via WhatsApp */ async sendVideo(to: string, video: Buffer, caption?: string, mimetype?: string): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot send video — not connected'); return; } const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`; this.stopTyping(jid); const msg: any = { video, mimetype: mimetype || 'video/mp4' }; if (caption) msg.caption = caption; const result = await this.sock.sendMessage(jid, msg); if (result?.key?.id) this.trackSentId(result.key.id); log.info(`[whatsapp] Sent video to ${jid} (id=${result?.key?.id || 'unknown'})`); } /** Send an emoji reaction onto an existing message. * Pass an empty string to remove a previously-sent reaction (Baileys convention). */ async sendReaction(chatJid: string, key: WAMessageKey, emoji: string): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot react — not connected'); return; } if (!key || !key.id) { log.warn('[whatsapp] Cannot react — missing message key'); return; } try { await this.sock.sendMessage(chatJid, { react: { text: emoji, key } }); log.info(`[whatsapp] Reacted "${emoji}" to ${key.id} in ${chatJid}`); } catch (err: any) { log.warn(`[whatsapp] Reaction failed: ${err.message}`); } } /** Send a document (PDF, zip, etc.) via WhatsApp */ async sendDocument(to: string, document: Buffer, fileName: string, mimetype?: string, caption?: string): Promise { if (!this.sock || !this.connected) { log.warn('[whatsapp] Cannot send document — not connected'); return; } const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`; this.stopTyping(jid); const msg: any = { document, mimetype: mimetype || 'application/octet-stream', fileName, }; if (caption) msg.caption = caption; const result = await this.sock.sendMessage(jid, msg); if (result?.key?.id) this.trackSentId(result.key.id); log.info(`[whatsapp] Sent document to ${jid} (name=${fileName}, id=${result?.key?.id || 'unknown'})`); } /** Show "typing..." indicator in a chat. Re-sends every 20s to keep it visible. */ startTyping(jid: string): void { if (!this.sock || !this.connected) return; // Clear any existing interval for this chat this.stopTyping(jid); const send = () => { this.sock?.sendPresenceUpdate('composing', jid).catch(() => {}); }; send(); // immediate this.typingIntervals.set(jid, setInterval(send, 20_000)); // refresh every 20s } /** Clear "typing..." indicator in a chat */ stopTyping(jid: string): void { const interval = this.typingIntervals.get(jid); if (interval) { clearInterval(interval); this.typingIntervals.delete(jid); } this.sock?.sendPresenceUpdate('paused', jid).catch(() => {}); } /** Track a sent message ID, evicting oldest when at capacity */ private trackSentId(id: string) { this.sentMessageIds.add(id); if (this.sentMessageIds.size > this.MAX_SENT_IDS) { // Delete the first (oldest) entry const first = this.sentMessageIds.values().next().value; if (first) this.sentMessageIds.delete(first); } } getStatus(): ChannelStatus { return { channel: 'whatsapp', connected: this.connected, info: { hasQr: !!this.qrData, hasCredentials: this.hasCredentials(), phoneNumber: this.sock?.user?.id?.split(':')[0] || null, }, }; } getQrCode(): string | null { return this.qrSvg; } /** Request a pairing code for phone-number-based linking (alternative to QR scan) */ async requestPairingCode(phoneNumber: string): Promise { if (!this.sock) throw new Error('WhatsApp socket not initialized — call connect() first'); if (this.connected) throw new Error('Already connected — no pairing needed'); // Digits only, no + or dashes const cleaned = phoneNumber.replace(/[^0-9]/g, ''); if (cleaned.length < 8 || cleaned.length > 15) { throw new Error('Invalid phone number — use digits with country code (e.g. 5511999998888)'); } log.info(`[whatsapp] Requesting pairing code for ${cleaned}...`); const code = await this.sock.requestPairingCode(cleaned); log.info(`[whatsapp] Pairing code generated: ${code}`); return code; } hasCredentials(): boolean { // Validates content, not just existence — a 0-byte creds.json (interrupted legacy // write) must not count as "linked", or boot lands in a silent QR loop. return hasValidCredsFile(AUTH_DIR); } /** Delete stored credentials (for re-auth / logout) */ async deleteCredentials(): Promise { try { if (fs.existsSync(AUTH_DIR)) { fs.rmSync(AUTH_DIR, { recursive: true, force: true }); } } catch (err: any) { log.warn(`[whatsapp] Failed to delete credentials: ${err.message}`); } } // ── Internal ── /** Translate a JID from LID format to phone format if possible. * Falls back to the supplied `alt` (Baileys 7+ `key.participantAlt`/`remoteJidAlt`) which * carries the phone form when the primary identifier is a LID. */ private translateJid(jid: string, alt?: string | null): string { // If it's already a phone JID, return as-is if (jid.endsWith('@s.whatsapp.net')) return jid; // Check learned LID map first (covers our own LID + any pairs we've seen) const mapped = this.lidToPhoneMap.get(jid); if (mapped) return mapped; // Baileys 7 ships the alternate identifier on the message key. If the primary // is a LID, the alt is the phone-number form — adopt it and learn the mapping // for future messages on this conversation. if (alt && alt.endsWith('@s.whatsapp.net')) { this.lidToPhoneMap.set(jid, alt); // Also map the bare LID number → phone, so different LID encodings collapse const lidNum = jid.split(':')[0].split('@')[0]; if (lidNum) this.lidToPhoneMap.set(`${lidNum}@lid`, alt); return alt; } // Unknown LID — don't guess. Return as-is so isSelfChat stays false. return jid; } /** Build the LID-to-phone mapping from sock.user */ private buildLidMap() { if (!this.sock?.user) return; const user = this.sock.user; // user.id is "phone:device@s.whatsapp.net" — extract phone const phone = user.id.split(':')[0]; this.ownPhoneJid = `${phone}@s.whatsapp.net`; // user.lid (if available) is the LID JID — map it and variations const lid = (user as any).lid; if (lid) { // Map the exact LID this.lidToPhoneMap.set(lid, this.ownPhoneJid); // Also map the numeric part without @lid suffix (remoteJid may use @lid format) const lidNum = lid.split(':')[0].split('@')[0]; this.lidToPhoneMap.set(`${lidNum}@lid`, this.ownPhoneJid); log.info(`[whatsapp] LID map: ${lid} (and ${lidNum}@lid) → ${this.ownPhoneJid}`); } log.info(`[whatsapp] Own phone JID: ${this.ownPhoneJid}`); } private async connectInternal(): Promise { const gen = ++this.connectGen; // A pending reconnect must not fire on top of this attempt — clear it so the // timer and a manual connect can't produce two sockets on the same auth dir. if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } // Clean up any existing socket before creating a new one if (this.sock) { try { this.sock.ev.removeAllListeners('creds.update'); } catch {} try { this.sock.ev.removeAllListeners('connection.update'); } catch {} try { this.sock.ev.removeAllListeners('messages.upsert'); } catch {} try { this.sock.end(undefined); } catch {} this.sock = null; } // Ensure auth directory exists fs.mkdirSync(AUTH_DIR, { recursive: true }); // Atomic-write auth state — survives the kills/crashes that used to truncate // creds.json, and recovers from creds.json.bak when the primary is corrupt. const { state, saveCreds, freshIdentity } = await useAtomicMultiFileAuthState(AUTH_DIR); if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded while loading auth if (freshIdentity) log.info('[whatsapp] No valid credentials — starting pairing flow'); // Suppress Baileys' noisy logging const logger = pino({ level: 'silent' }) as any; let version: [number, number, number] | undefined; try { // fetchLatestBaileysVersion = newest WA Web version the LIBRARY supports. // (fetchLatestWaWebVersion scrapes web.whatsapp.com and can return a protocol // revision newer than Baileys handles — a disconnect source.) const result = await fetchLatestBaileysVersion(); version = result.version; } catch { log.warn('[whatsapp] Could not fetch latest WA version — using default'); } if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded during version fetch const sock = makeWASocket({ auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger), }, version, browser: Browsers.macOS('Chrome'), logger, markOnlineOnConnect: false, // keep message notifications on the phone generateHighQualityLinkPreview: false, // Baileys 7.x retry handshake: without this, inbound messages that need an // E2EE session re-establishment are silently dropped (msg.message === null). getMessage: async () => ({ conversation: '' }), }); this.sock = sock; // Persist credential updates (fires constantly while connected — key rotations, // app-state sync). Track the promise so disconnect() can drain it before exit. sock.ev.on('creds.update', () => { this.lastCredsWrite = saveCreds().catch((err: any) => { log.warn(`[whatsapp] Failed to persist credentials: ${err.message}`); }); }); // Connection state changes sock.ev.on('connection.update', async (update) => { if (this.sock !== sock) return; // stale event from a superseded socket const { connection, lastDisconnect, qr } = update; // QR code received — render to SVG if (qr) { this.qrData = qr; try { this.qrSvg = await QRCode.toString(qr, { type: 'svg' }); } catch { this.qrSvg = null; } log.info('[whatsapp] QR code generated — waiting for scan'); this.emitStatus(); } if (connection === 'open') { this.connected = true; this.reconnectAttempts = 0; this.pairingRetries = 0; this.qrData = null; this.qrSvg = null; this.buildLidMap(); // Set presence to unavailable so the phone doesn't show "online" constantly sock.sendPresenceUpdate('unavailable').catch(() => {}); log.ok(`[whatsapp] Connected as ${sock.user?.id}`); this.emitStatus(); } if (connection === 'close') { this.connected = false; this.qrData = null; this.qrSvg = null; const statusCode = (lastDisconnect?.error as any)?.output?.statusCode; const reason = DisconnectReason[statusCode] || `code ${statusCode}`; log.warn(`[whatsapp] Disconnected: ${reason}`); if (this.intentionalDisconnect) return; // Logged out (401) — credentials are invalid, user must re-scan if (statusCode === DisconnectReason.loggedOut) { log.warn('[whatsapp] Logged out — credentials cleared. Re-scan QR to reconnect.'); await this.deleteCredentials(); this.emitStatus(); return; } // Connection replaced (440) — another instance took over intentionally, don't fight it if (statusCode === DisconnectReason.connectionReplaced) { log.info('[whatsapp] Connection replaced by a new instance — not reconnecting'); this.emitStatus(); return; } // restartRequired (515) is the server's reconnect-now handoff. In the QR flow it // arrives right after pair-success, while creds.registered is STILL false (that // flag is only set in the pairing-code flow, messages-recv link_code_pairing_ref) // — so it MUST be handled before any unpaired/phantom logic, or a successful // scan gets thrown away. Backoff guards a pathological repeated-515 loop. if (statusCode === DisconnectReason.restartRequired) { const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 60_000); log.info(`[whatsapp] Restart required (normal after pairing) — reconnecting in ${delay}ms...`); this.emitStatus(); this.scheduleReconnect(delay); return; } // Pure QR wait expired without anyone claiming an identity — regenerate a fresh // QR a couple of times (the link page is likely still open), then stop instead // of cycling QR codes in the background forever. if (!state.creds.registered && !state.creds.me) { if (this.pairingRetries < 2) { this.pairingRetries++; log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`); this.emitStatus(); this.scheduleReconnect(2000); } else { log.info('[whatsapp] Pairing window closed without a scan — connect again to retry'); this.emitStatus(); } return; } // Anything else reconnects with backoff (5s → 60s). This includes the // me-set-but-unregistered states: a real post-pairing identity completes its // login on reconnect, and a stale pairing-code placeholder gets a 401 from the // server — which lands in the loggedOut branch above and wipes it cleanly. const delay = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000); log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`); this.emitStatus(); this.scheduleReconnect(delay); } }); // Incoming messages sock.ev.on('messages.upsert', async (m: BaileysEventMap['messages.upsert']) => { if (m.type !== 'notify') return; for (const msg of m.messages) { // Skip status broadcasts and protocol-only messages. // Groups are passed through — the manager filters them based on channel config (allowGroups). if (msg.key.remoteJid === 'status@broadcast') continue; if (msg.key.remoteJid?.endsWith('@newsletter')) continue; // channels/newsletters if (!msg.message) continue; // Echo prevention: skip messages we sent ourselves if (msg.key.id && this.sentMessageIds.has(msg.key.id)) { this.sentMessageIds.delete(msg.key.id); continue; } // Extract text — or transcribe audio if it's a voice note let rawText = this.extractText(msg.message); const media: WhatsAppMediaAttachment[] = []; // Download image if present if (this.isImageMessage(msg.message)) { try { const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; const mimeType = this.getImageMimeType(msg.message) || 'image/jpeg'; const base64 = buffer.toString('base64'); media.push({ type: 'image', mediaType: mimeType, data: base64 }); log.info(`[whatsapp] Downloaded image (${Math.round(buffer.length / 1024)}KB, ${mimeType})`); } catch (err: any) { log.warn(`[whatsapp] Image download failed: ${err.message}`); } } // Download document if present (PDF, docx, zip, etc.) — the binary is downloaded // here (the caption, if any, is already covered by extractText above). const docInfo = this.getDocumentInfo(msg.message); if (docInfo) { try { const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; const base64 = buffer.toString('base64'); media.push({ type: 'file', mediaType: docInfo.mimetype || 'application/octet-stream', data: base64, name: docInfo.fileName, }); log.info(`[whatsapp] Downloaded document (${Math.round(buffer.length / 1024)}KB, ${docInfo.mimetype || 'unknown'}, ${docInfo.fileName || 'unnamed'})`); } catch (err: any) { log.warn(`[whatsapp] Document download failed: ${err.message}`); } } if (!rawText && this.isAudioMessage(msg.message)) { // Voice note / audio — download and transcribe if (!this.transcribe) { log.info('[whatsapp] Audio message received but no transcribe function configured — skipping'); await this.sendMessage(msg.key.remoteJid!, 'Whisper not enabled, click on the 3 dots settings on the Chat of your bloby and provide an OpenAI API key.'); continue; } try { const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; const base64 = buffer.toString('base64'); log.info(`[whatsapp] Transcribing audio (${Math.round(buffer.length / 1024)}KB)...`); const transcript = await this.transcribe(base64); if (!transcript) { log.warn('[whatsapp] Transcription returned empty — skipping'); await this.sendMessage(msg.key.remoteJid!, 'Whisper not enabled, click on the 3 dots settings on the Chat of your bloby and provide an OpenAI API key.'); continue; } rawText = transcript; log.info(`[whatsapp] Transcribed: "${rawText.slice(0, 80)}"`); } catch (err: any) { log.warn(`[whatsapp] Audio transcription failed: ${err.message}`); continue; } } // Skip if no text AND no media; otherwise default text for media-only // messages. Collapsing both branches also narrows `rawText` to `string`. if (!rawText) { if (media.length === 0) continue; rawText = media.some((m) => m.type === 'image') ? '(image)' : '(document)'; } // Escape special characters to prevent prompt injection via message content const text = this.escapeMessageText(rawText); const fromMe = msg.key.fromMe || false; const rawSender = msg.key.remoteJid || ''; const participant = msg.key.participant || ''; const isGroup = rawSender.endsWith('@g.us'); // Baileys 7 exposes the alternate identifier on the key — when the primary is a LID, // the alt is the phone-number form (and vice versa). Use these to translate cleanly. const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined; const participantAlt = (msg.key as any).participantAlt as string | undefined; // chatJid: where to reply (group JID for groups, peer JID otherwise). // For peer chats, prefer the phone-form JID so reactions/replies hit the canonical chat. const chatJid = isGroup ? rawSender : this.translateJid(rawSender, remoteJidAlt); // The actual sender JID: // - groups: always `participant` (remoteJid is the group) // - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid const actualSender = isGroup ? participant || rawSender : (participant || rawSender); const senderAlt = isGroup ? participantAlt : (participantAlt || remoteJidAlt); // Translate LID → phone via the learned map + the message's `*Alt` fallback. const sender = this.translateJid(actualSender, senderAlt); const pushName = msg.pushName || undefined; // Self-chat: only meaningful for 1:1. True when the (translated) chat JID is our own number // AND no group participant. Both `participant` and `participantAlt` must be absent — Baileys // sets `participant` on newer 1:1 messages too, so we additionally accept when the participant // (or its alt) resolves to our own phone. const participantResolved = participant ? this.translateJid(participant, senderAlt) : ''; const ownsChat = this.ownPhoneJid !== null && chatJid === this.ownPhoneJid; const ownsParticipant = !participant || participantResolved === this.ownPhoneJid; const isSelfChat = !isGroup && ownsChat && ownsParticipant; log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, media=${media.length}): ${text.slice(0, 80)}`); this.onMessage( sender, pushName, text, fromMe, isSelfChat, chatJid, isGroup, media.length > 0 ? media : undefined, msg.key, ); } }); } /** Extract text content from a Baileys message object */ private extractText(message: any): string | null { if (!message) return null; // Direct text if (message.conversation) return message.conversation; if (message.extendedTextMessage?.text) return message.extendedTextMessage.text; // Image/video/document captions if (message.imageMessage?.caption) return message.imageMessage.caption; if (message.videoMessage?.caption) return message.videoMessage.caption; if (message.documentMessage?.caption) return message.documentMessage.caption; // Captioned documents arrive wrapped in documentWithCaptionMessage. if (message.documentWithCaptionMessage?.message?.documentMessage?.caption) { return message.documentWithCaptionMessage.message.documentMessage.caption; } // View-once wrappers if (message.viewOnceMessage?.message) return this.extractText(message.viewOnceMessage.message); if (message.viewOnceMessageV2?.message) return this.extractText(message.viewOnceMessageV2.message); // Ephemeral wrapper if (message.ephemeralMessage?.message) return this.extractText(message.ephemeralMessage.message); // Edited message if (message.editedMessage?.message) return this.extractText(message.editedMessage.message); if (message.protocolMessage?.editedMessage?.message) { return this.extractText(message.protocolMessage.editedMessage.message); } return null; } /** Check if a message contains an image */ private isImageMessage(message: any): boolean { if (!message) return false; if (message.imageMessage) return true; if (message.viewOnceMessage?.message?.imageMessage) return true; if (message.viewOnceMessageV2?.message?.imageMessage) return true; if (message.ephemeralMessage?.message?.imageMessage) return true; return false; } /** Extract the MIME type from an image message */ private getImageMimeType(message: any): string | null { if (!message) return null; if (message.imageMessage?.mimetype) return message.imageMessage.mimetype; if (message.viewOnceMessage?.message?.imageMessage?.mimetype) return message.viewOnceMessage.message.imageMessage.mimetype; if (message.viewOnceMessageV2?.message?.imageMessage?.mimetype) return message.viewOnceMessageV2.message.imageMessage.mimetype; if (message.ephemeralMessage?.message?.imageMessage?.mimetype) return message.ephemeralMessage.message.imageMessage.mimetype; return null; } /** Extract document metadata (mimetype + fileName) from a message, unwrapping the * common containers. Returns null when there is no document. * * WhatsApp wraps a captioned document in `documentWithCaptionMessage.message.documentMessage` * while a bare document is `documentMessage` directly — both must resolve. (The actual binary * is fetched via downloadMediaMessage on the outer `msg`, which Baileys unwraps itself.) */ private getDocumentInfo(message: any): { mimetype?: string; fileName?: string } | null { if (!message) return null; const doc = message.documentMessage || message.documentWithCaptionMessage?.message?.documentMessage || message.viewOnceMessage?.message?.documentMessage || message.viewOnceMessageV2?.message?.documentMessage || message.ephemeralMessage?.message?.documentMessage || message.ephemeralMessage?.message?.documentWithCaptionMessage?.message?.documentMessage; if (!doc) return null; return { mimetype: doc.mimetype || undefined, fileName: doc.fileName || undefined }; } /** Check if a message contains audio (voice note or audio file) */ private isAudioMessage(message: any): boolean { if (!message) return false; if (message.audioMessage) return true; // Check inside wrappers if (message.viewOnceMessage?.message?.audioMessage) return true; if (message.viewOnceMessageV2?.message?.audioMessage) return true; if (message.ephemeralMessage?.message?.audioMessage) return true; return false; } /** Escape message text to prevent prompt injection via special characters */ private escapeMessageText(text: string): string { return text .replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|') // prevent faking channel context tags .replace(/\[Telegram\s*\|/gi, '(Telegram|') .replace(/\[\s*(admin|customer)\s*\]/gi, '($1)'); // prevent faking role tags } private emitStatus() { this.onStatusChange(this.getStatus()); } }