feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
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:
2025-12-02 17:54:31 -06:00
parent 327118440b
commit faedec47d7
62 changed files with 4489 additions and 92 deletions

View File

@@ -0,0 +1,131 @@
/**
* PostgreSQL-based auth state for Baileys
* Stores credentials and keys in the database instead of files
*/
import type { AuthenticationCreds, SignalDataTypeMap } from '@whiskeysockets/baileys'
import { initAuthCreds, BufferJSON, proto } from '@whiskeysockets/baileys'
import { query } from '../../utils/database'
export interface PostgresAuthState {
state: {
creds: AuthenticationCreds
keys: {
get: <T extends keyof SignalDataTypeMap>(type: T, ids: string[]) => Promise<{ [id: string]: SignalDataTypeMap[T] }>
set: (data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }) => Promise<void>
}
}
saveCreds: () => Promise<void>
}
export async function usePostgresAuthState(instanceId: string): Promise<PostgresAuthState> {
// Load or create credentials
const loadCreds = async (): Promise<AuthenticationCreds> => {
const result = await query<{ key_data: any }>(
'SELECT key_data FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
[instanceId, 'creds', 'default']
)
if (result.rows.length > 0 && result.rows[0].key_data) {
return JSON.parse(JSON.stringify(result.rows[0].key_data), BufferJSON.reviver)
}
return initAuthCreds()
}
// Save credentials
const saveCreds = async (creds: AuthenticationCreds): Promise<void> => {
const data = JSON.parse(JSON.stringify(creds, BufferJSON.replacer))
await query(
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (instance_id, key_type, key_id)
DO UPDATE SET key_data = $4, updated_at = NOW()`,
[instanceId, 'creds', 'default', data]
)
}
// Load keys by type and ids
const loadKeys = async <T extends keyof SignalDataTypeMap>(
type: T,
ids: string[]
): Promise<{ [id: string]: SignalDataTypeMap[T] }> => {
const result: { [id: string]: SignalDataTypeMap[T] } = {}
if (ids.length === 0) return result
const placeholders = ids.map((_, i) => `$${i + 3}`).join(', ')
const queryResult = await query<{ key_id: string; key_data: any }>(
`SELECT key_id, key_data FROM auth_keys
WHERE instance_id = $1 AND key_type = $2 AND key_id IN (${placeholders})`,
[instanceId, type, ...ids]
)
for (const row of queryResult.rows) {
let value = JSON.parse(JSON.stringify(row.key_data), BufferJSON.reviver)
// Handle special types
if (type === 'app-state-sync-key' && value) {
value = proto.Message.AppStateSyncKeyData.fromObject(value)
}
result[row.key_id] = value
}
return result
}
// Save keys
const saveKeys = async (
data: { [type: string]: { [id: string]: SignalDataTypeMap[keyof SignalDataTypeMap] | null } }
): Promise<void> => {
for (const type in data) {
for (const id in data[type]) {
const value = data[type][id]
if (value === null) {
// Delete key
await query(
'DELETE FROM auth_keys WHERE instance_id = $1 AND key_type = $2 AND key_id = $3',
[instanceId, type, id]
)
} else {
// Upsert key
const serialized = JSON.parse(JSON.stringify(value, BufferJSON.replacer))
await query(
`INSERT INTO auth_keys (instance_id, key_type, key_id, key_data)
VALUES ($1, $2, $3, $4)
ON CONFLICT (instance_id, key_type, key_id)
DO UPDATE SET key_data = $4, updated_at = NOW()`,
[instanceId, type, id, serialized]
)
}
}
}
}
// Load initial credentials
const creds = await loadCreds()
return {
state: {
creds,
keys: {
get: loadKeys,
set: saveKeys
}
},
saveCreds: async () => {
await saveCreds(creds)
}
}
}
/**
* Clear all auth data for an instance
*/
export async function clearAuthState(instanceId: string): Promise<void> {
await query(
'DELETE FROM auth_keys WHERE instance_id = $1',
[instanceId]
)
}

View 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()