Files
whatsappNucleo/server/services/baileys/manager.ts
josedario87 80d0042c7e
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
Feature: Agregar botón para crear webhook de debug automáticamente
- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection
- Detectar si ya existe un webhook apuntando al receptor de debug
- Permitir eliminar el webhook de debug
- Incluir todos los eventos disponibles al crear el webhook
- También incluye mejoras previas de manejo de media y mensajes
2025-12-02 21:21:33 -06:00

734 lines
24 KiB
TypeScript

/**
* 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<string, { lastKnownPresence: string; lastSeen?: number }> }
}
const logger = pino({ level: 'warn' })
class BaileysManager extends EventEmitter {
private instances: Map<string, ManagedInstance> = 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<void> {
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<void> {
// 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
shouldSyncHistoryMessage: (msg) => {
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<void>,
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`)
})
}
/**
* Disconnect an instance
*/
async disconnect(instanceId: string): Promise<void> {
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): Promise<any> {
const managed = this.instances.get(instanceId)
if (!managed?.socket) {
throw new Error('Instance not connected')
}
const result = await managed.socket.sendMessage(jid, content)
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<void> {
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<void> {
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): Promise<void> {
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
}
}
})
console.log(`[BaileysManager] Sent reaction ${emoji} to message ${messageId}`)
}
/**
* Update instance status in database
*/
private async updateInstanceStatus(
instanceId: string,
status: string,
extra: Record<string, any> = {}
): Promise<void> {
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<void> {
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 content = msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
msg.message?.videoMessage?.caption ||
msg.message?.documentMessage?.caption ||
''
const messageType = this.getMessageType(msg.message)
// 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) return 'contact'
if (message.locationMessage) return 'location'
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()