/** * BaileysManager - Manages multiple WhatsApp instances * Singleton pattern for managing all Baileys connections */ import type { WASocket, BaileysEventMap } from '@whiskeysockets/baileys' import * as baileys from '@whiskeysockets/baileys' // Get functions from baileys module (handle both ESM and CJS) const makeWASocket = (baileys as any).default || (baileys as any).makeWASocket const DisconnectReason = (baileys as any).DisconnectReason || (baileys as any).default?.DisconnectReason const fetchLatestBaileysVersion = (baileys as any).fetchLatestBaileysVersion || (baileys as any).default?.fetchLatestBaileysVersion const makeCacheableSignalKeyStore = (baileys as any).makeCacheableSignalKeyStore || (baileys as any).default?.makeCacheableSignalKeyStore const Browsers = (baileys as any).Browsers || (baileys as any).default?.Browsers // Debug: Log what we got from baileys console.log('[Baileys] Module keys:', Object.keys(baileys)) console.log('[Baileys] makeWASocket:', typeof makeWASocket) console.log('[Baileys] DisconnectReason:', typeof DisconnectReason) console.log('[Baileys] fetchLatestBaileysVersion:', typeof fetchLatestBaileysVersion) console.log('[Baileys] makeCacheableSignalKeyStore:', typeof makeCacheableSignalKeyStore) console.log('[Baileys] Browsers:', typeof Browsers) import { Boom } from '@hapi/boom' import { EventEmitter } from 'events' import QRCode from 'qrcode' import pino from 'pino' import { usePostgresAuthState, clearAuthState } from './auth-state' import { query } from '../../utils/database' // Types export interface ManagedInstance { id: string name: string socket: WASocket | null status: 'disconnected' | 'connecting' | 'connected' | 'qr_ready' | 'pairing' phoneNumber: string | null qrCode: string | null pairingCode: string | null reconnectAttempts: number lastError: string | null } export interface InstanceEvents { 'instance.status': { instanceId: string; status: string; phoneNumber?: string } 'instance.qr': { instanceId: string; qr: string; qrDataUrl: string } 'instance.pairing': { instanceId: string; code: string } 'message.received': { instanceId: string; message: any } 'message.sent': { instanceId: string; message: any } 'message.status': { instanceId: string; messageId: string; status: string } 'message.reaction': { instanceId: string; reaction: any } 'presence.update': { instanceId: string; jid: string; presences: Record } } const logger = pino({ level: 'warn' }) class BaileysManager extends EventEmitter { private instances: Map = new Map() private static instance: BaileysManager | null = null private initialized = false private constructor() { super() } static getInstance(): BaileysManager { if (!BaileysManager.instance) { BaileysManager.instance = new BaileysManager() } return BaileysManager.instance } /** * Initialize manager and reconnect previously connected instances */ async initialize(): Promise { if (this.initialized) return console.log('[BaileysManager] Initializing...') try { // Load instances that were previously connected const result = await query<{ id: string; name: string; phone_number: string }>( `SELECT id, name, phone_number FROM instances WHERE status = 'connected'` ) for (const row of result.rows) { console.log(`[BaileysManager] Reconnecting instance: ${row.name}`) await this.connect(row.id) } this.initialized = true console.log('[BaileysManager] Initialized successfully') } catch (error) { console.error('[BaileysManager] Initialization error:', error) } } /** * Connect an instance to WhatsApp */ async connect(instanceId: string, usePairingCode = false, phoneNumber?: string): Promise { // Check if already connected const existing = this.instances.get(instanceId) if (existing?.socket) { console.log(`[BaileysManager] Instance ${instanceId} already has active socket`) return } // Load instance from DB const instanceResult = await query<{ id: string; name: string; phone_number: string }>( 'SELECT id, name, phone_number FROM instances WHERE id = $1', [instanceId] ) if (instanceResult.rows.length === 0) { throw new Error(`Instance ${instanceId} not found`) } const instanceData = instanceResult.rows[0] // Update status await this.updateInstanceStatus(instanceId, 'connecting') // Create managed instance const managed: ManagedInstance = { id: instanceId, name: instanceData.name, socket: null, status: 'connecting', phoneNumber: instanceData.phone_number || null, qrCode: null, pairingCode: null, reconnectAttempts: 0, lastError: null } this.instances.set(instanceId, managed) try { console.log(`[BaileysManager] Loading auth state for ${instanceId}...`) // Load auth state from PostgreSQL const { state, saveCreds } = await usePostgresAuthState(instanceId) console.log(`[BaileysManager] Auth state loaded, creds registered:`, state.creds?.registered) console.log(`[BaileysManager] Fetching latest Baileys version...`) const { version } = await fetchLatestBaileysVersion() console.log(`[BaileysManager] Using Baileys version:`, version) console.log(`[BaileysManager] Creating WASocket...`) // Create socket const socket = makeWASocket({ version, auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, browser: Browsers.ubuntu('WhatsApp Nucleo'), logger: pino({ level: 'debug' }), generateHighQualityLinkPreview: true, syncFullHistory: true, markOnlineOnConnect: false, // Sync history messages from the last 30 days // IMPORTANTE: Si no hay messageTimestamp, retornar true para permitir el check inicial de Baileys shouldSyncHistoryMessage: (msg) => { if (!msg.messageTimestamp) return true const thirtyDaysAgo = Date.now() / 1000 - (30 * 24 * 60 * 60) return msg.messageTimestamp >= thirtyDaysAgo }, // getMessage for resending/decrypting messages getMessage: async (key) => { try { const result = await query<{ raw_message: any }>( `SELECT raw_message FROM messages WHERE instance_id = $1 AND message_id = $2`, [instanceId, key.id] ) if (result.rows.length > 0) { return result.rows[0].raw_message?.message } } catch (err) { console.error('[BaileysManager] getMessage error:', err) } return undefined } }) console.log(`[BaileysManager] WASocket created successfully`) managed.socket = socket // Setup event handlers console.log(`[BaileysManager] Setting up event handlers...`) this.setupEventHandlers(instanceId, socket, saveCreds, usePairingCode, phoneNumber) console.log(`[BaileysManager] Event handlers set up, waiting for connection events...`) } catch (error) { console.error(`[BaileysManager] Error connecting instance ${instanceId}:`, error) managed.lastError = (error as Error).message await this.updateInstanceStatus(instanceId, 'disconnected') throw error } } /** * Setup event handlers for a socket */ private setupEventHandlers( instanceId: string, socket: WASocket, saveCreds: () => Promise, usePairingCode: boolean, phoneNumber?: string ): void { const managed = this.instances.get(instanceId)! // Credentials update socket.ev.on('creds.update', saveCreds) // Connection update socket.ev.on('connection.update', async (update) => { console.log(`[BaileysManager] connection.update event:`, JSON.stringify(update, null, 2)) const { connection, lastDisconnect, qr } = update // QR Code received if (qr) { console.log(`[BaileysManager] QR code received! Length: ${qr.length}`) try { const qrDataUrl = await QRCode.toDataURL(qr) console.log(`[BaileysManager] QR DataURL generated, length: ${qrDataUrl.length}`) managed.qrCode = qrDataUrl managed.status = 'qr_ready' await this.updateInstanceStatus(instanceId, 'qr_ready', { qr_code: qrDataUrl }) console.log(`[BaileysManager] QR saved to database`) this.emit('instance.qr', { instanceId, qr, qrDataUrl }) } catch (err) { console.error(`[BaileysManager] Error generating QR:`, err) } } // Request pairing code if needed if (usePairingCode && phoneNumber && !managed.pairingCode && connection === 'connecting') { try { // Wait a bit for socket to be ready await new Promise(resolve => setTimeout(resolve, 3000)) if (!socket.authState.creds.registered) { const code = await socket.requestPairingCode(phoneNumber) managed.pairingCode = code managed.status = 'pairing' await this.updateInstanceStatus(instanceId, 'pairing', { pairing_code: code }) this.emit('instance.pairing', { instanceId, code }) } } catch (error) { console.error(`[BaileysManager] Error requesting pairing code:`, error) } } // Connection closed if (connection === 'close') { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode const shouldReconnect = statusCode !== DisconnectReason.loggedOut managed.socket = null managed.qrCode = null managed.pairingCode = null if (shouldReconnect && managed.reconnectAttempts < 5) { managed.reconnectAttempts++ console.log(`[BaileysManager] Reconnecting instance ${instanceId} (attempt ${managed.reconnectAttempts})`) // Wait before reconnecting await new Promise(resolve => setTimeout(resolve, 3000 * managed.reconnectAttempts)) await this.connect(instanceId) } else { managed.status = 'disconnected' await this.updateInstanceStatus(instanceId, 'disconnected') if (statusCode === DisconnectReason.loggedOut) { // Clear auth state if logged out await clearAuthState(instanceId) } } this.emit('instance.status', { instanceId, status: 'disconnected' }) } // Connection open if (connection === 'open') { const phoneNum = socket.user?.id.split(':')[0] || null managed.status = 'connected' managed.phoneNumber = phoneNum managed.qrCode = null managed.pairingCode = null managed.reconnectAttempts = 0 await this.updateInstanceStatus(instanceId, 'connected', { phone_number: phoneNum, qr_code: null, pairing_code: null, last_connected_at: new Date() }) this.emit('instance.status', { instanceId, status: 'connected', phoneNumber: phoneNum || undefined }) console.log(`[BaileysManager] Instance ${instanceId} connected as ${phoneNum}`) } }) // Messages received socket.ev.on('messages.upsert', async ({ messages, type }) => { for (const msg of messages) { // Emit event for webhooks this.emit('message.received', { instanceId, message: msg }) // Save message to database await this.saveMessage(instanceId, msg, type === 'notify') } }) // Message status update socket.ev.on('messages.update', async (updates) => { for (const update of updates) { if (update.update.status) { this.emit('message.status', { instanceId, messageId: update.key.id!, status: this.mapMessageStatus(update.update.status) }) } } }) // Chat upsert - new chats socket.ev.on('chats.upsert', async (chats) => { console.log(`[BaileysManager] chats.upsert: ${chats.length} chats`) for (const chat of chats) { try { await query( `INSERT INTO chats (instance_id, jid, name, is_group, unread_count) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (instance_id, jid) DO UPDATE SET name = COALESCE(EXCLUDED.name, chats.name), updated_at = NOW()`, [instanceId, chat.id, chat.name || chat.id?.split('@')[0], chat.id?.includes('@g.us'), chat.unreadCount || 0] ) } catch (err) { console.error(`[BaileysManager] Error upserting chat:`, err) } } }) // Chat update - changes to existing chats socket.ev.on('chats.update', async (updates) => { for (const update of updates) { try { const fields: string[] = [] const values: any[] = [instanceId, update.id] let paramIdx = 3 if (update.unreadCount !== undefined) { fields.push(`unread_count = $${paramIdx++}`) values.push(update.unreadCount) } if (update.name) { fields.push(`name = $${paramIdx++}`) values.push(update.name) } if (fields.length > 0) { await query( `UPDATE chats SET ${fields.join(', ')}, updated_at = NOW() WHERE instance_id = $1 AND jid = $2`, values ) } } catch (err) { console.error(`[BaileysManager] Error updating chat:`, err) } } }) // Presence update socket.ev.on('presence.update', async (update) => { console.log(`[BaileysManager] presence.update:`, JSON.stringify(update)) this.emit('presence.update', { instanceId, jid: update.id, presences: update.presences }) // Cache presence in database for (const [participantJid, presence] of Object.entries(update.presences)) { try { await query( `INSERT INTO presence_cache (instance_id, jid, presence, last_seen) VALUES ($1, $2, $3, $4) ON CONFLICT (instance_id, jid) DO UPDATE SET presence = EXCLUDED.presence, last_seen = COALESCE(EXCLUDED.last_seen, presence_cache.last_seen)`, [ instanceId, participantJid, presence.lastKnownPresence, presence.lastSeen ? new Date(presence.lastSeen * 1000) : null ] ) } catch (err) { console.error(`[BaileysManager] Error caching presence:`, err) } } }) // Message reactions socket.ev.on('messages.reaction', async (reactions) => { for (const reaction of reactions) { console.log(`[BaileysManager] message.reaction:`, JSON.stringify(reaction)) this.emit('message.reaction', { instanceId, reaction }) // Save reaction to database try { const { key, reaction: reactionData } = reaction if (reactionData.text) { // Add reaction await query( `INSERT INTO message_reactions (message_id, reactor_jid, emoji) SELECT m.id, $2, $3 FROM messages m WHERE m.instance_id = $1 AND m.message_id = $4 ON CONFLICT (message_id, reactor_jid) DO UPDATE SET emoji = EXCLUDED.emoji, timestamp = NOW()`, [instanceId, key.participant || key.fromMe ? 'me' : key.remoteJid, reactionData.text, reactionData.key.id] ) } else { // Remove reaction (empty text) await query( `DELETE FROM message_reactions WHERE message_id IN ( SELECT id FROM messages WHERE instance_id = $1 AND message_id = $2 ) AND reactor_jid = $3`, [instanceId, reactionData.key.id, key.participant || key.fromMe ? 'me' : key.remoteJid] ) } } catch (err) { console.error(`[BaileysManager] Error saving reaction:`, err) } } }) // History sync - save chats and messages from history socket.ev.on('messaging-history.set', async ({ chats, contacts, messages, syncType }) => { console.log(`[BaileysManager] History sync received: ${chats?.length || 0} chats, ${contacts?.length || 0} contacts, ${messages?.length || 0} messages, type: ${syncType}`) // Save chats if (chats?.length) { for (const chat of chats) { try { await query( `INSERT INTO chats (instance_id, jid, name, is_group, unread_count) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (instance_id, jid) DO UPDATE SET name = COALESCE(EXCLUDED.name, chats.name), unread_count = EXCLUDED.unread_count, updated_at = NOW()`, [instanceId, chat.id, chat.name || chat.id?.split('@')[0], chat.id?.includes('@g.us'), chat.unreadCount || 0] ) } catch (err) { console.error(`[BaileysManager] Error saving chat:`, err) } } } // Save messages if (messages?.length) { for (const msg of messages) { await this.saveMessage(instanceId, msg, false) } } console.log(`[BaileysManager] History sync processed`) // Emitir evento SSE al frontend para notificar que llegó el historial this.emit('history.synced', { instanceId, chatsCount: chats?.length || 0, contactsCount: contacts?.length || 0, messagesCount: messages?.length || 0, syncType }) }) } /** * Disconnect an instance */ async disconnect(instanceId: string): Promise { const managed = this.instances.get(instanceId) if (!managed?.socket) return managed.socket.end(new Error('Disconnected by user')) managed.socket = null managed.status = 'disconnected' managed.qrCode = null managed.pairingCode = null await this.updateInstanceStatus(instanceId, 'disconnected') this.emit('instance.status', { instanceId, status: 'disconnected' }) } /** * Send a message */ async sendMessage(instanceId: string, jid: string, content: any, options?: any): Promise { const managed = this.instances.get(instanceId) if (!managed?.socket) { throw new Error('Instance not connected') } const result = await managed.socket.sendMessage(jid, content, options) this.emit('message.sent', { instanceId, message: result }) return result } /** * Get QR code for an instance */ getQRCode(instanceId: string): string | null { return this.instances.get(instanceId)?.qrCode || null } /** * Get pairing code for an instance */ getPairingCode(instanceId: string): string | null { return this.instances.get(instanceId)?.pairingCode || null } /** * Get instance status */ getStatus(instanceId: string): ManagedInstance | null { return this.instances.get(instanceId) || null } /** * Get all instances */ getAllInstances(): ManagedInstance[] { return Array.from(this.instances.values()) } /** * Get the raw socket for an instance (for debug purposes) * Returns null if instance is not connected */ getSocket(instanceId: string): WASocket | null { const managed = this.instances.get(instanceId) if (!managed || managed.status !== 'connected') { return null } return managed.socket } /** * Subscribe to presence updates for a JID */ async subscribeToPresence(instanceId: string, jid: string): Promise { const managed = this.instances.get(instanceId) if (!managed?.socket) { throw new Error('Instance not connected') } await managed.socket.presenceSubscribe(jid) console.log(`[BaileysManager] Subscribed to presence: ${jid}`) } /** * Send presence update (composing, recording, available, unavailable, paused) */ async sendPresence(instanceId: string, jid: string, presence: 'composing' | 'recording' | 'available' | 'unavailable' | 'paused'): Promise { const managed = this.instances.get(instanceId) if (!managed?.socket) { throw new Error('Instance not connected') } await managed.socket.sendPresenceUpdate(presence, jid) console.log(`[BaileysManager] Sent presence ${presence} to ${jid}`) } /** * Send a reaction to a message */ async sendReaction(instanceId: string, jid: string, messageId: string, emoji: string, fromMe: boolean = false): Promise { const managed = this.instances.get(instanceId) if (!managed?.socket) { throw new Error('Instance not connected') } await managed.socket.sendMessage(jid, { react: { text: emoji, key: { remoteJid: jid, id: messageId, fromMe: fromMe } } }) console.log(`[BaileysManager] Sent reaction ${emoji} to message ${messageId} (fromMe: ${fromMe})`) } /** * Update instance status in database */ private async updateInstanceStatus( instanceId: string, status: string, extra: Record = {} ): Promise { const fields = ['status = $2'] const values: any[] = [instanceId, status] let paramIndex = 3 for (const [key, value] of Object.entries(extra)) { fields.push(`${key} = $${paramIndex}`) values.push(value) paramIndex++ } await query( `UPDATE instances SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $1`, values ) } /** * Save message to database */ private async saveMessage(instanceId: string, msg: any, isNew: boolean): Promise { try { const jid = msg.key.remoteJid if (!jid) return // Skip protocol messages (internal WhatsApp messages) if (msg.message?.protocolMessage) { console.log(`[BaileysManager] Skipping protocol message: ${msg.message.protocolMessage.type}`) return } // Skip if no actual message content if (!msg.message) { console.log(`[BaileysManager] Skipping message with no content`) return } // Get message content const messageType = this.getMessageType(msg.message) // Extract content based on message type let content = '' if (messageType === 'poll') { // For polls, store the poll data as JSON const pollMsg = msg.message?.pollCreationMessage || msg.message?.pollCreationMessageV3 if (pollMsg) { content = JSON.stringify({ name: pollMsg.name, options: pollMsg.options?.map((o: any) => o.optionName) || [], selectableCount: pollMsg.selectableOptionsCount || 1 }) } } else if (messageType === 'event') { // For events, store the event data as JSON const eventMsg = msg.message?.eventMessage if (eventMsg) { content = JSON.stringify({ name: eventMsg.name, startDate: eventMsg.startTime ? new Date(eventMsg.startTime * 1000).toISOString() : null, endDate: eventMsg.endTime ? new Date(eventMsg.endTime * 1000).toISOString() : null, description: eventMsg.description, location: eventMsg.location ? { name: eventMsg.location.name, address: eventMsg.location.address, latitude: eventMsg.location.degreesLatitude, longitude: eventMsg.location.degreesLongitude } : null }) } } else { // Default content extraction content = msg.message?.conversation || msg.message?.extendedTextMessage?.text || msg.message?.imageMessage?.caption || msg.message?.videoMessage?.caption || msg.message?.documentMessage?.caption || '' } // Skip unknown message types with no content (likely internal messages) if (messageType === 'unknown' && !content) { console.log(`[BaileysManager] Skipping unknown message type with no content`) return } // Ensure chat exists and update name if pushName is available const chatResult = await query<{ id: string }>( `INSERT INTO chats (instance_id, jid, name, is_group) VALUES ($1, $2, $3, $4) ON CONFLICT (instance_id, jid) DO UPDATE SET name = COALESCE(NULLIF(EXCLUDED.name, chats.jid), chats.name), updated_at = NOW() RETURNING id`, [instanceId, jid, msg.pushName || jid.split('@')[0], jid.includes('@g.us')] ) const chatId = chatResult.rows[0].id // Insert message await query( `INSERT INTO messages ( instance_id, chat_id, message_id, from_jid, from_me, message_type, content, timestamp, raw_message ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (instance_id, message_id) DO NOTHING`, [ instanceId, chatId, msg.key.id, msg.key.fromMe ? 'me' : jid, msg.key.fromMe || false, messageType, content, new Date(msg.messageTimestamp! * 1000), JSON.stringify(msg) ] ) // Update chat last message if (isNew) { await query( `UPDATE chats SET last_message_at = $1, unread_count = unread_count + 1 WHERE id = $2`, [new Date(msg.messageTimestamp! * 1000), chatId] ) } } catch (error) { console.error('[BaileysManager] Error saving message:', error) } } /** * Get message type from Baileys message object */ private getMessageType(message: any): string { if (!message) return 'unknown' if (message.conversation || message.extendedTextMessage) return 'text' if (message.imageMessage) return 'image' if (message.videoMessage) return 'video' if (message.audioMessage) return 'audio' if (message.documentMessage) return 'document' if (message.stickerMessage) return 'sticker' if (message.contactMessage || message.contactsArrayMessage) return 'contact' if (message.locationMessage) return 'location' if (message.pollCreationMessage || message.pollCreationMessageV3) return 'poll' if (message.eventMessage) return 'event' return 'unknown' } /** * Map Baileys message status to our status */ private mapMessageStatus(status: number): string { switch (status) { case 0: return 'pending' case 1: return 'sent' case 2: return 'delivered' case 3: return 'read' case 4: return 'read' default: return 'sent' } } } // Export singleton export const baileysManager = BaileysManager.getInstance()