/** * WhatsApp channel adapter (v2) — native Baileys v6 implementation. * * Implements ChannelAdapter directly (no Chat SDK bridge) using * @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure: * getMessage fallback, outgoing queue, group metadata cache, LID mapping, * reconnection with backoff. * * Auth credentials persist in store/auth/. On first run: * - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log) * - Otherwise → QR code (printed to log) * Subsequent restarts reuse the saved session automatically. */ import fs from 'fs'; import path from 'path'; import pino from 'pino'; import { makeWASocket, Browsers, DisconnectReason, fetchLatestWaWebVersion, downloadMediaMessage, makeCacheableSignalKeyStore, normalizeMessageContent, useMultiFileAuthState, } from '@whiskeysockets/baileys'; import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js'; import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; import { normalizeOptions, type NormalizedOption } from './ask-question.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, ConversationInfo, InboundMessage, OutboundMessage, } from './adapter.js'; // Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1). // Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with // "couldn't link device" because WhatsApp receives an invalid platform ID. // Must use createRequire — ESM `import *` creates a read-only namespace. // proto is not available as a named ESM export — use createRequire (same as v1) import { createRequire } from 'module'; const _require = createRequire(import.meta.url); const { proto } = _require('@whiskeysockets/baileys') as { proto: any }; try { const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record; _generics.getPlatformId = (browser: string): string => { const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType]; return platformType ? platformType.toString() : '1'; }; } catch { // If CJS require fails (Node version mismatch), pairing codes may not work // but QR auth will still function fine. log.warn('Could not patch getPlatformId — pairing code auth may fail'); } const baileysLogger = pino({ level: 'silent' }); const AUTH_DIR = path.join(process.cwd(), 'store', 'auth'); const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends const SENT_MESSAGE_CACHE_MAX = 256; const RECONNECT_DELAY_MS = 5000; const PENDING_QUESTIONS_MAX = 64; /** Normalize an option label to a slash command: "Approve" → "/approve" */ function optionToCommand(option: string): string { return '/' + option.toLowerCase().replace(/\s+/g, '-'); } // --- Markdown → WhatsApp formatting --- interface TextSegment { content: string; isProtected: boolean; } /** Split text into code-block-protected and unprotected regions. */ function splitProtectedRegions(text: string): TextSegment[] { const segments: TextSegment[] = []; const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = codeBlockRegex.exec(text)) !== null) { if (match.index > lastIndex) { segments.push({ content: text.slice(lastIndex, match.index), isProtected: false }); } segments.push({ content: match[0], isProtected: true }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { segments.push({ content: text.slice(lastIndex), isProtected: false }); } return segments; } /** Apply WhatsApp-native formatting to an unprotected text segment. */ function transformForWhatsApp(text: string): string { // Order matters: italic before bold to avoid **bold** → *bold* → _bold_ // 1. Italic: *text* (not **) → _text_ text = text.replace(/(? (isProtected ? content : transformForWhatsApp(content))).join(''); } /** Map file extension to Baileys media message type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any { const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const videoExts = ['.mp4', '.mov', '.avi', '.mkv']; const audioExts = ['.mp3', '.ogg', '.m4a', '.wav', '.aac', '.opus']; if (imageExts.includes(ext)) { return { image: data, caption, mimetype: `image/${ext.slice(1) === 'jpg' ? 'jpeg' : ext.slice(1)}` }; } if (videoExts.includes(ext)) { return { video: data, caption, mimetype: `video/${ext.slice(1)}` }; } if (audioExts.includes(ext)) { return { audio: data, mimetype: `audio/${ext.slice(1) === 'mp3' ? 'mpeg' : ext.slice(1)}` }; } // Default: send as document return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' }; } registerChannelAdapter('whatsapp', { factory: () => { const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']); const phoneNumber = env.WHATSAPP_PHONE_NUMBER; const authDir = AUTH_DIR; // Skip if no existing auth, no phone number for pairing, and not explicitly enabled (QR mode) const hasAuth = fs.existsSync(path.join(authDir, 'creds.json')); if (!hasAuth && !phoneNumber && !env.WHATSAPP_ENABLED) return null; fs.mkdirSync(authDir, { recursive: true }); // State let sock: WASocket; let connected = false; let setupConfig: ChannelSetup; let conversations: Map; // LID → phone JID mapping (WhatsApp's new ID system) const lidToPhoneMap: Record = {}; let botLidUser: string | undefined; // Outgoing queue for messages sent while disconnected const outgoingQueue: Array<{ jid: string; text: string }> = []; let flushing = false; // Sent message cache for retry/re-encrypt requests const sentMessageCache = new Map(); // Group metadata cache with TTL const groupMetadataCache = new Map(); // Pending questions: chatJid → { questionId, options } // User replies with /approve, /reject, etc. to answer const pendingQuestions = new Map< string, { questionId: string; options: NormalizedOption[]; } >(); // Group sync tracking let lastGroupSync = 0; let groupSyncTimerStarted = false; // First-connect promise let resolveFirstOpen: (() => void) | undefined; let rejectFirstOpen: ((err: Error) => void) | undefined; // Pairing code file for the setup skill to poll const pairingCodeFile = path.join(process.cwd(), 'store', 'pairing-code.txt'); // --- Helpers --- function buildConversationMap(configs: ConversationConfig[]): Map { const map = new Map(); for (const conv of configs) map.set(conv.platformId, conv); return map; } function setLidPhoneMapping(lidUser: string, phoneJid: string): void { if (lidToPhoneMap[lidUser] === phoneJid) return; lidToPhoneMap[lidUser] = phoneJid; // Cached group metadata depends on participant IDs — invalidate groupMetadataCache.clear(); } async function translateJid(jid: string): Promise { if (!jid.endsWith('@lid')) return jid; const lidUser = jid.split('@')[0].split(':')[0]; const cached = lidToPhoneMap[lidUser]; if (cached) return cached; // Query Baileys' signal repository try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid); if (pn) { const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; setLidPhoneMapping(lidUser, phoneJid); log.info('Translated LID to phone JID', { lidJid: jid, phoneJid }); return phoneJid; } } catch (err) { log.debug('Failed to resolve LID via signalRepository', { jid, err }); } return jid; } async function getNormalizedGroupMetadata(jid: string): Promise { if (!jid.endsWith('@g.us')) return undefined; const cached = groupMetadataCache.get(jid); if (cached && cached.expiresAt > Date.now()) return cached.metadata; const metadata = await sock.groupMetadata(jid); const participants = await Promise.all( metadata.participants.map(async (p) => ({ ...p, id: await translateJid(p.id), })), ); const normalized = { ...metadata, participants }; groupMetadataCache.set(jid, { metadata: normalized, expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS, }); return normalized; } async function syncGroupMetadata(force = false): Promise { if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) { return; } try { log.info('Syncing group metadata from WhatsApp...'); const groups = await sock.groupFetchAllParticipating(); let count = 0; for (const [jid, metadata] of Object.entries(groups)) { if (metadata.subject) { setupConfig.onMetadata(jid, metadata.subject, true); count++; } } lastGroupSync = Date.now(); log.info('Group metadata synced', { count }); } catch (err) { log.error('Failed to sync group metadata', { err }); } } async function flushOutgoingQueue(): Promise { if (flushing || outgoingQueue.length === 0) return; flushing = true; try { log.info('Flushing outgoing message queue', { count: outgoingQueue.length }); while (outgoingQueue.length > 0) { const item = outgoingQueue.shift()!; const sent = await sock.sendMessage(item.jid, { text: item.text }); if (sent?.key?.id && sent.message) { sentMessageCache.set(sent.key.id, sent.message); } } } finally { flushing = false; } } /** Download media from an inbound message, save to /workspace/attachments/. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function downloadInboundMedia( msg: WAMessage, normalized: any, ): Promise> { const mediaTypes: Array<{ key: string; type: string; ext: string }> = [ { key: 'imageMessage', type: 'image', ext: '.jpg' }, { key: 'videoMessage', type: 'video', ext: '.mp4' }, { key: 'audioMessage', type: 'audio', ext: '.ogg' }, { key: 'documentMessage', type: 'document', ext: '' }, ]; const results: Array<{ type: string; name: string; localPath: string }> = []; for (const { key, type, ext } of mediaTypes) { if (!normalized[key]) continue; try { const buffer = await downloadMediaMessage(msg, 'buffer', {}); const docFilename = normalized[key].fileName; const filename = docFilename || `${type}-${Date.now()}${ext}`; const attachDir = path.join(DATA_DIR, 'attachments'); fs.mkdirSync(attachDir, { recursive: true }); const filePath = path.join(attachDir, filename); fs.writeFileSync(filePath, buffer); results.push({ type, name: filename, localPath: `attachments/${filename}` }); log.info('Media downloaded', { type, filename }); } catch (err) { log.warn('Failed to download media', { type, err }); } } return results; } async function sendRawMessage(jid: string, text: string): Promise { if (!connected) { outgoingQueue.push({ jid, text }); log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length }); return; } try { const sent = await sock.sendMessage(jid, { text }); if (sent?.key?.id && sent.message) { sentMessageCache.set(sent.key.id, sent.message); if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) { const oldest = sentMessageCache.keys().next().value!; sentMessageCache.delete(oldest); } } return sent?.key?.id ?? undefined; } catch (err) { outgoingQueue.push({ jid, text }); log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length }); return undefined; } } // --- Socket creation --- async function connectSocket(): Promise { const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestWaWebVersion({}).catch((err) => { log.warn('Failed to fetch latest WA Web version, using default', { err }); return { version: undefined }; }); sock = makeWASocket({ version, auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, baileysLogger), }, printQRInTerminal: false, logger: baileysLogger, browser: Browsers.macOS('Chrome'), cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid), getMessage: async (key: WAMessageKey) => { // Check in-memory cache first (recently sent messages) const cached = sentMessageCache.get(key.id || ''); if (cached) return cached; // Return empty message to prevent indefinite "waiting for this message" return proto.Message.fromObject({}); }, }); // Request pairing code if phone number is set and not yet registered if (phoneNumber && !state.creds.registered) { setTimeout(async () => { try { const code = await sock.requestPairingCode(phoneNumber); log.info(`WhatsApp pairing code: ${code}`); log.info('Enter in WhatsApp > Linked Devices > Link with phone number'); fs.writeFileSync(pairingCodeFile, code, 'utf-8'); } catch (err) { log.error('Failed to request pairing code', { err }); } }, 3000); } sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; if (qr && !phoneNumber) { // QR code auth — print to terminal (async () => { try { const QRCode = await import('qrcode'); const qrText = await QRCode.toString(qr, { type: 'terminal' }); log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText); } catch { log.info('WhatsApp QR code (raw)', { qr }); } })(); } if (connection === 'close') { connected = false; const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode; const shouldReconnect = reason !== DisconnectReason.loggedOut; log.info('WhatsApp connection closed', { reason, shouldReconnect }); if (shouldReconnect) { log.info('Reconnecting...'); connectSocket().catch((err) => { log.error('Failed to reconnect, retrying in 5s', { err }); setTimeout(() => { connectSocket().catch((err2) => { log.error('Reconnection retry failed', { err: err2 }); }); }, RECONNECT_DELAY_MS); }); } else { log.info('WhatsApp logged out'); if (rejectFirstOpen) { rejectFirstOpen(new Error('WhatsApp logged out')); rejectFirstOpen = undefined; resolveFirstOpen = undefined; } } } else if (connection === 'open') { connected = true; log.info('Connected to WhatsApp'); // Clean up pairing code file after successful connection try { if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile); } catch { /* ignore */ } // Announce availability for presence updates sock.sendPresenceUpdate('available').catch((err) => { log.warn('Failed to send presence update', { err }); }); // Build LID → phone mapping from auth state if (sock.user) { const phoneUser = sock.user.id.split(':')[0]; const lidUser = sock.user.lid?.split(':')[0]; if (lidUser && phoneUser) { setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`); botLidUser = lidUser; } } // Flush queued messages flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err })); // Group sync syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err })); if (!groupSyncTimerStarted) { groupSyncTimerStarted = true; setInterval(() => { syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err })); }, GROUP_SYNC_INTERVAL_MS); } // Signal first open if (resolveFirstOpen) { resolveFirstOpen(); resolveFirstOpen = undefined; rejectFirstOpen = undefined; } } }); sock.ev.on('creds.update', saveCreds); // Phone number sharing events — update LID mapping sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => { const lidUser = lid?.split('@')[0].split(':')[0]; if (lidUser && jid) setLidPhoneMapping(lidUser, jid); }); // Inbound messages sock.ev.on('messages.upsert', async ({ messages }) => { for (const msg of messages) { try { if (!msg.message) continue; const normalized = normalizeMessageContent(msg.message); if (!normalized) continue; const rawJid = msg.key.remoteJid; if (!rawJid || rawJid === 'status@broadcast') continue; // Translate LID → phone JID let chatJid = await translateJid(rawJid); // eslint-disable-next-line @typescript-eslint/no-explicit-any if (chatJid.endsWith('@lid') && (msg.key as any).senderPn) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const pn = (msg.key as any).senderPn as string; const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`; setLidPhoneMapping(rawJid.split('@')[0].split(':')[0], phoneJid); chatJid = phoneJid; } const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString(); const isGroup = chatJid.endsWith('@g.us'); // Notify metadata for group discovery setupConfig.onMetadata(chatJid, undefined, isGroup); // Only forward messages for registered conversations if (!conversations.has(chatJid)) continue; let content = normalized.conversation || normalized.extendedTextMessage?.text || normalized.imageMessage?.caption || normalized.videoMessage?.caption || ''; // Normalize bot LID mention → assistant name for trigger matching if (botLidUser && content.includes(`@${botLidUser}`)) { content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`); } // Download media attachments (images, video, audio, documents) const attachments = await downloadInboundMedia(msg, normalized); // Skip empty protocol messages (no text and no attachments) if (!content && attachments.length === 0) continue; const sender = msg.key.participant || msg.key.remoteJid || ''; const senderName = msg.pushName || sender.split('@')[0]; const fromMe = msg.key.fromMe || false; // Filter bot's own messages to prevent echo loops. // fromMe is always true for messages sent from this linked device, // regardless of ASSISTANT_HAS_OWN_NUMBER mode. if (fromMe) continue; const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`); // Check if this reply answers a pending question via slash command const pending = pendingQuestions.get(chatJid); if (pending && content.startsWith('/')) { const cmd = content.trim().toLowerCase(); const matched = pending.options.find((o) => optionToCommand(o.label) === cmd); if (matched) { const voterName = msg.pushName || sender.split('@')[0]; setupConfig.onAction(pending.questionId, matched.value, sender); pendingQuestions.delete(chatJid); await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`); log.info('Question answered', { questionId: pending.questionId, value: matched.value, voterName, }); continue; // Don't forward this reply to the agent } } const inbound: InboundMessage = { id: msg.key.id || `wa-${Date.now()}`, kind: 'chat', content: { text: content, sender, senderName, ...(attachments.length > 0 && { attachments }), fromMe, isBotMessage, isGroup, chatJid, }, timestamp, }; // WhatsApp doesn't use threads — threadId is null setupConfig.onInbound(chatJid, null, inbound); } catch (err) { log.error('Error processing incoming WhatsApp message', { err, remoteJid: msg.key?.remoteJid, }); } } }); } // --- ChannelAdapter implementation --- const adapter: ChannelAdapter = { name: 'whatsapp', channelType: 'whatsapp', supportsThreads: false, async setup(hostConfig: ChannelSetup) { setupConfig = hostConfig; conversations = buildConversationMap(hostConfig.conversations); // Connect and wait for first open await new Promise((resolve, reject) => { resolveFirstOpen = resolve; rejectFirstOpen = reject; connectSocket().catch(reject); }); log.info('WhatsApp adapter initialized'); }, async deliver( platformId: string, _threadId: string | null, message: OutboundMessage, ): Promise { const content = message.content as Record; // Ask question → text with slash command replies if (content.type === 'ask_question' && content.questionId && content.options) { const questionId = content.questionId as string; const title = content.title as string; const question = content.question as string; if (!title) { log.error('ask_question missing required title — skipping delivery', { questionId }); return; } const options: NormalizedOption[] = normalizeOptions(content.options as never); const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n'); const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`; const msgId = await sendRawMessage(platformId, text); if (msgId) { pendingQuestions.set(platformId, { questionId, options }); if (pendingQuestions.size > PENDING_QUESTIONS_MAX) { const oldest = pendingQuestions.keys().next().value!; pendingQuestions.delete(oldest); } } return msgId; } // Reaction → emoji on a message if (content.operation === 'reaction' && content.messageId && content.emoji) { try { await sock.sendMessage(platformId, { react: { text: content.emoji as string, key: { remoteJid: platformId, id: content.messageId as string, fromMe: false }, }, }); } catch (err) { log.debug('Failed to send reaction', { platformId, err }); } return; } // Credential request → text fallback (WhatsApp doesn't support modals) if (content.type === 'credential_request' && content.credentialId) { const question = (content.question as string) || 'A credential has been requested.'; const text = `Credential request: ${question}\n\nPlease provide this credential through a secure channel (e.g. Discord or Slack).`; const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`; return sendRawMessage(platformId, prefixed); } // Normal message (with optional file attachments) const text = (content.markdown as string) || (content.text as string); const hasFiles = message.files && message.files.length > 0; if (!text && !hasFiles) return; // Send file attachments (first file gets the caption, rest are captionless) if (hasFiles) { let captionUsed = false; for (const file of message.files!) { try { const ext = path.extname(file.filename).toLowerCase(); const caption = !captionUsed ? text : undefined; const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption); const sent = await sock.sendMessage(platformId, mediaMsg); if (sent?.key?.id && sent.message) { sentMessageCache.set(sent.key.id, sent.message); } if (caption) captionUsed = true; } catch (err) { log.error('Failed to send file', { platformId, filename: file.filename, err }); } } if (captionUsed) return; // Text was sent as caption } if (text) { const formatted = formatWhatsApp(text); const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`; return sendRawMessage(platformId, prefixed); } }, async setTyping(platformId: string) { try { await sock.sendPresenceUpdate('composing', platformId); } catch (err) { log.debug('Failed to update typing status', { jid: platformId, err }); } }, async teardown() { connected = false; sock?.end(undefined); log.info('WhatsApp adapter shut down'); }, isConnected() { return connected; }, async syncConversations(): Promise { try { const groups = await sock.groupFetchAllParticipating(); return Object.entries(groups) .filter(([, m]) => m.subject) .map(([jid, m]) => ({ platformId: jid, name: m.subject, isGroup: true, })); } catch (err) { log.error('Failed to sync WhatsApp conversations', { err }); return []; } }, updateConversations(configs: ConversationConfig[]) { conversations = buildConversationMap(configs); }, }; return adapter; }, });