feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Reemplazo completo de Evolution API por implementación directa con Baileys. Características: - Dashboard completo con Nuxt UI v4 - Soporte para múltiples instancias de WhatsApp - Conexión via QR code o pairing code - Persistencia de mensajes en PostgreSQL - API REST para integraciones externas - Webhooks con firma HMAC - SSE para actualizaciones en tiempo real - Autenticación con Authentik
This commit is contained in:
456
server/services/baileys/manager.ts
Normal file
456
server/services/baileys/manager.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* BaileysManager - Manages multiple WhatsApp instances
|
||||
* Singleton pattern for managing all Baileys connections
|
||||
*/
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
useMultiFileAuthState,
|
||||
type WASocket,
|
||||
type BaileysEventMap,
|
||||
Browsers
|
||||
} from '@whiskeysockets/baileys'
|
||||
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 }
|
||||
}
|
||||
|
||||
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 {
|
||||
// Load auth state from PostgreSQL
|
||||
const { state, saveCreds } = await usePostgresAuthState(instanceId)
|
||||
const { version } = await fetchLatestBaileysVersion()
|
||||
|
||||
// Create socket
|
||||
const socket = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger)
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
browser: Browsers.ubuntu('WhatsApp Nucleo'),
|
||||
logger,
|
||||
generateHighQualityLinkPreview: true,
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false
|
||||
})
|
||||
|
||||
managed.socket = socket
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers(instanceId, socket, saveCreds, usePairingCode, phoneNumber)
|
||||
|
||||
} 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) => {
|
||||
const { connection, lastDisconnect, qr } = update
|
||||
|
||||
// QR Code received
|
||||
if (qr && !usePairingCode) {
|
||||
const qrDataUrl = await QRCode.toDataURL(qr)
|
||||
managed.qrCode = qrDataUrl
|
||||
managed.status = 'qr_ready'
|
||||
|
||||
await this.updateInstanceStatus(instanceId, 'qr_ready', { qr_code: qrDataUrl })
|
||||
this.emit('instance.qr', { instanceId, qr, qrDataUrl })
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Ensure chat exists
|
||||
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 updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[instanceId, jid, msg.pushName || jid.split('@')[0], jid.includes('@g.us')]
|
||||
)
|
||||
const chatId = chatResult.rows[0].id
|
||||
|
||||
// Get message content
|
||||
const content = msg.message?.conversation ||
|
||||
msg.message?.extendedTextMessage?.text ||
|
||||
msg.message?.imageMessage?.caption ||
|
||||
''
|
||||
|
||||
const messageType = this.getMessageType(msg.message)
|
||||
|
||||
// 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()
|
||||
Reference in New Issue
Block a user