import { EventEmitter } from 'events'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy, } from '@nestjs/common'; import makeWASocket, { DisconnectReason, useMultiFileAuthState, fetchLatestBaileysVersion, WASocket, downloadMediaMessage, normalizeMessageContent, } from '@whiskeysockets/baileys'; import { Boom } from '@hapi/boom'; import * as path from 'path'; const pino = require('pino'); const QRCode = require('qrcode-terminal'); import { LeadsService } from '../leads/leads.service'; import { ConversacionService } from '../conversacion/conversacion.service'; import { ClaudeService } from '../claude/claude.service'; import { MediaService } from '../media/media.service'; import { WhatsappDebounceService } from './whatsapp-debounce.service'; import { WebhookListener } from '../webhook/webhook-listener'; import { ApiClient } from '../api/api-client.service'; import { wrapSocket } from 'baileys-antiban'; export const pdfEmitter = new EventEmitter(); export const startEmitter = new EventEmitter(); export const fotosEmitter = new EventEmitter(); interface LeadContext { leadId: string; telefono: string; nombre: string; botStep: string; viable: boolean | null; } @Injectable() export class WhatsappService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WhatsappService.name); private sock: WASocket | null = null; private authDir = process.env.BAILEYS_AUTH_DIR || path.join(process.cwd(), 'auth_info_baileys'); private readonly ultimoMsgPorJid = new Map(); private baileysLogger = pino({ level: 'info' }); // leadId por JID private readonly jidToLeadId = new Map(); // contexto de lead por leadId private readonly leadCache = new Map(); // leads cuya conversación ya se mandó a post-análisis (para no repetir). private readonly leadsAnalizados = new Set(); // leads a los que se les ha pedido foto y estamos esperándola. private readonly esperandoFotos = new Set(); // leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir. private readonly pipelineDisparado = new Set(); constructor( private readonly leadsService: LeadsService, private readonly conversacionService: ConversacionService, private readonly claudeService: ClaudeService, private readonly mediaService: MediaService, private readonly debounceService: WhatsappDebounceService, private readonly webhookListener: WebhookListener, private readonly api: ApiClient, ) {} async onModuleInit() { await this.conectar(); this.escucharPdf(); this.escucharStart(); this.escucharFotos(); } async onModuleDestroy() { if (this.sock) this.sock.end(undefined); } private escucharPdf() { pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => { this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`); // Buscar JID por teléfono let jid: string | null = null; for (const [j, lid] of this.jidToLeadId) { if (lid === payload.leadId) { jid = j; break; } } if (!jid) { jid = `${payload.telefono}@s.whatsapp.net`; } if (!this.sock) return; try { const safeSock = wrapSocket(this.sock); await safeSock.sendMessage(jid, { document: Buffer.from(payload.pdfBase64, 'base64'), mimetype: 'application/pdf', fileName: payload.filename, caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.', }); this.logger.log(`PDF enviado a ${jid}`); } catch (err: any) { this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`); } }); } // Apertura proactiva: cuando el funnel dispara /whatsapp-start, Luisa escribe ella el primer // mensaje (el bot ya no es solo reactivo). private escucharStart() { startEmitter.on( 'start', async (p: { leadId: string; telefono: string; nombre: string; empresa: string }) => { try { await this.enviarApertura(p); } catch (err: any) { this.logger.error(`[APERTURA] Error: ${err.message}`); } }, ); } private async enviarApertura(p: { leadId: string; telefono: string; nombre: string; empresa: string }) { if (!this.sock) { this.logger.warn(`[APERTURA] WhatsApp no conectado; no se envía a ${p.telefono}`); return; } const tel = (p.telefono || '').replace(/\D/g, ''); let jid = `${tel}@s.whatsapp.net`; try { const res = await this.sock.onWhatsApp(tel); if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid; else if (!res || !res[0]?.exists) this.logger.warn(`[APERTURA] ${tel} no parece estar en WhatsApp`); } catch { /* seguimos con el jid por defecto */ } const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola'; const empresa = p.empresa || 'Reformix'; const apertura = `¡Hola ${primerNombre}! Soy Luisa, del equipo de ${empresa}. 😊\n\n` + `Acabas de pedir presupuesto para tu reforma y te ayudo a prepararlo (con un render de cómo ` + `quedaría incluido). Para empezar, cuéntame: ¿qué espacio quieres reformar? (cocina, baño, salón…)`; // Contexto para los siguientes mensajes del cliente. this.jidToLeadId.set(jid, p.leadId); this.webhookListener.registerJid(tel, jid); this.leadCache.set(p.leadId, { leadId: p.leadId, telefono: tel, nombre: p.nombre || '', botStep: 'apertura', viable: null, }); await this.enviarMensaje(jid, apertura); this.logger.log(`[APERTURA] Enviada a ${jid} (lead ${p.leadId})`); try { await this.api.actualizarPerfil(p.leadId, { estadoWa: 'enviado', botStep: 'apertura', canalOrigen: 'whatsapp' }); await this.conversacionService.guardarMensaje(p.leadId, 'assistant', apertura, { botStep: 'apertura' }); await this.api.registrarIntento(p.leadId, 'whatsapp', 1, 'exitoso', true); } catch (err: any) { this.logger.warn(`[APERTURA] No se pudo persistir en la app: ${err.message}`); } } // Recibe una foto en modo "esperando fotos": la sube como "antes" y marca perfilCompleto, lo que // dispara en la app la generación de render + presupuesto + entrega del PDF. private async recibirFotoYFinalizar(ctx: LeadContext, jid: string, msg: any, msgContent: any): Promise { if (!this.sock || this.pipelineDisparado.has(ctx.leadId)) return; try { const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, { logger: this.baileysLogger, reuploadRequest: this.sock.updateMediaMessage, }); const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64'); const mimeType = msgContent.imageMessage?.mimetype || 'image/jpeg'; this.esperandoFotos.delete(ctx.leadId); this.pipelineDisparado.add(ctx.leadId); await this.api.enviarIngesta( ctx.leadId, [{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes' }], { perfilCompleto: true }, ); await this.conversacionService.guardarMensaje(ctx.leadId, 'user', '[foto del espacio]', { botStep: 'fotos_recibidas' }); const conf = '¡Perfecto! Con esto preparo tu presupuesto con el render. En un momento te llega aquí mismo 🛠️'; await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', conf, { botStep: 'fotos_recibidas' }); await this.enviarMensaje(jid, conf); this.logger.log(`[FOTOS] lead ${ctx.leadId}: foto recibida → perfilCompleto disparado`); } catch (err: any) { this.pipelineDisparado.delete(ctx.leadId); this.logger.error(`[FOTOS] error procesando foto de ${ctx.leadId}: ${err.message}`); } } // Cross-canal: tras una llamada, la app pide por webhook que Luisa escriba al lead, referencie lo // hablado y le pida las fotos. Reutiliza el mismo modo de recogida. private escucharFotos() { fotosEmitter.on( 'fotos', async (p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string }) => { try { await this.iniciarRecogidaFotos(p); } catch (err: any) { this.logger.error(`[FOTOS] iniciarRecogida error: ${err.message}`); } }, ); } private async iniciarRecogidaFotos(p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string; }): Promise { if (!this.sock) { this.logger.warn(`[FOTOS] WhatsApp no conectado; no se pide foto a ${p.telefono}`); return; } const jid = await this.resolverJidYRegistrar(p.leadId, p.telefono, p.nombre, 'pide_fotos'); this.esperandoFotos.add(p.leadId); const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola'; const empresa = p.empresa || 'Reformix'; const ctx = p.contexto ? ` sobre ${p.contexto}` : ''; const mensaje = `¡Hola ${primerNombre}! Soy Luisa, de ${empresa}. 😊 Gracias por tu llamada${ctx}. ` + `Para terminar tu presupuesto con el render, mándame una foto del espacio 📸`; await this.conversacionService.guardarMensaje(p.leadId, 'assistant', mensaje, { botStep: 'pide_fotos' }); await this.enviarMensaje(jid, mensaje); this.logger.log(`[FOTOS] recogida iniciada para lead ${p.leadId} (cross-canal)`); } // Resuelve el jid real del teléfono (vía onWhatsApp) y registra el contexto del lead. private async resolverJidYRegistrar(leadId: string, telefono: string, nombre: string, botStep: string): Promise { const tel = (telefono || '').replace(/\D/g, ''); let jid = `${tel}@s.whatsapp.net`; try { const res = await this.sock?.onWhatsApp(tel); if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid; } catch { /* jid por defecto */ } this.jidToLeadId.set(jid, leadId); this.webhookListener.registerJid(tel, jid); if (!this.leadCache.has(leadId)) { this.leadCache.set(leadId, { leadId, telefono: tel, nombre: nombre || '', botStep, viable: null }); } return jid; } private normalizarTelefono(jid: string): string { return jid.split('@')[0].replace(/\D/g, ''); } // WhatsApp puede entregar mensajes desde una dirección @lid (id de privacidad) en vez del número. // Resolvemos el número real vía remoteJidAlt o el mapa LID→PN de Baileys; si no, caemos al jid. private resolverTelefono(msg: any): string { const jid: string = msg.key?.remoteJid || ''; if (jid.endsWith('@lid')) { const alt = msg.key?.remoteJidAlt; if (typeof alt === 'string' && alt.includes('@s.whatsapp.net')) return this.normalizarTelefono(alt); try { const pn = (this.sock as any)?.signalRepository?.lidMapping?.getPNForLID?.(jid); if (typeof pn === 'string' && pn) return this.normalizarTelefono(pn); } catch { /* sin mapping disponible */ } } return this.normalizarTelefono(jid); } private calcularDelayEscritura(longitudTexto: number): number { const min = 1500; const max = 4000; const factor = Math.min(longitudTexto / 120, 1); return Math.round(min + (max - min) * factor); } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } private async conectar() { const { state, saveCreds } = await useMultiFileAuthState(this.authDir); const { version } = await fetchLatestBaileysVersion(); this.baileysLogger = pino({ level: 'info' }) as any; this.sock = makeWASocket({ version, auth: state, printQRInTerminal: false, logger: this.baileysLogger, // true: marca el dispositivo "online" al conectar para que WhatsApp le ENTREGUE los mensajes // entrantes tras reconectar (con false, al reanudar la sesión quedaba "no disponible" y no // recibía nada aunque el socket dijera "open"). markOnlineOnConnect: true, generateHighQualityLinkPreview: false, syncFullHistory: false, }); this.sock.ev.on('creds.update', saveCreds); this.sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; this.webhookListener.setConnState({ connection: connection ?? null, hasQr: !!qr, lastDisconnect: (lastDisconnect?.error as Boom)?.output?.statusCode ?? null, at: new Date().toISOString(), }); if (qr) { QRCode.generate(qr, { small: true }); console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n'); this.webhookListener.setQr(qr); } if (connection === 'close') { const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`); this.webhookListener.setConectado(false); if (shouldReconnect) setTimeout(() => this.conectar(), 5000); else this.logger.error('Sesion cerrada (logged out).'); } else if (connection === 'open') { this.logger.log('✅ WhatsApp conectado. Luisa esta lista.'); this.webhookListener.setConectado(true); } }); this.sock.ev.on('messages.upsert', async ({ messages, type }) => { for (const msg of messages) { this.webhookListener.pushInbound({ type, remoteJid: msg.key.remoteJid ?? null, remoteJidAlt: (msg.key as any).remoteJidAlt ?? null, fromMe: !!msg.key.fromMe, msgType: msg.message ? Object.keys(msg.message)[0] : null, at: new Date().toISOString(), }); } if (type !== 'notify') return; for (const msg of messages) { if (msg.key.fromMe) continue; if (!msg.key.remoteJid) continue; if (msg.key.remoteJid.includes('@g.us')) continue; const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid); const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, ''); if (allowedNumber && telefonoNormalizado !== allowedNumber) continue; await this.encolarMensaje(msg); } }); } private extraerTextoPlano(msg: any): string | null { const msgContent = msg.message; if (!msgContent) return null; if (msgContent.conversation || msgContent.extendedTextMessage) { const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || ''; return texto.trim() ? texto : null; } return null; } private crearMsgConTexto(msg: any, texto: string): any { return { ...msg, message: { conversation: texto } }; } private async encolarMensaje(msg: any): Promise { const jid = msg.key.remoteJid!; const textoPlano = this.extraerTextoPlano(msg); if (textoPlano === null) { await this.procesarMensaje(msg); return; } this.ultimoMsgPorJid.set(jid, msg); await this.debounceService.add(jid, textoPlano, async (combinedMessage) => { const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg; this.ultimoMsgPorJid.delete(jid); await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage)); }); } private async getOrCreateContext(telefono: string, jid: string): Promise { let leadId = this.webhookListener.getLeadIdByTelefono(telefono); // Fallback: si no está en memoria (reinicio del bot), recuperarlo de la BD por teléfono. if (!leadId) { leadId = await this.api.buscarLeadPorTelefono(telefono); if (leadId) { this.webhookListener.ensureSession(telefono, leadId); this.logger.log(`Lead ${leadId} recuperado por teléfono ${telefono} (sin sesión en memoria).`); } } this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() }); if (!leadId) { this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`); return null; } this.webhookListener.registerJid(telefono, jid); this.jidToLeadId.set(jid, leadId); let ctx = this.leadCache.get(leadId); if (!ctx) { const lead = await this.api.getLead(leadId); ctx = { leadId, telefono, nombre: lead?.nombre || '', botStep: lead?.botStep || 'nuevo', viable: lead?.viable ?? null, }; this.leadCache.set(leadId, ctx); } return ctx; } private async procesarMensaje(msg: any): Promise { const jid = msg.key.remoteJid!; if (jid.includes('@g.us')) return; const telefono = this.resolverTelefono(msg); try { const ctx = await this.getOrCreateContext(telefono, jid); if (!ctx) return; const primerMensajeDeUsuario = !this.jidToLeadId.has(jid); let textoNormalizado = ''; const msgContent = normalizeMessageContent(msg.message); if (!msgContent) return; // Modo recogida de fotos (tras cerrar la cualificación o tras una llamada): la foto cierra el // flujo → sube la foto + dispara render/presupuesto, sin re-cualificar. if (msgContent.imageMessage && this.esperandoFotos.has(ctx.leadId)) { await this.recibirFotoYFinalizar(ctx, jid, msg, msgContent); return; } if (msgContent.conversation || msgContent.extendedTextMessage) { textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || ''; } else if (msgContent.audioMessage) { const audioMeta = msgContent.audioMessage; const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus'; this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`); if (!this.sock) return; const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, { logger: this.baileysLogger, reuploadRequest: this.sock.updateMediaMessage, }); const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType); } else if (msgContent.imageMessage) { this.logger.log(`Imagen recibida de lead ${ctx.leadId}`); if (!this.sock) return; const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, { logger: this.baileysLogger, reuploadRequest: this.sock.updateMediaMessage, }); const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg'; textoNormalizado = await this.mediaService.inferirImagen( Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), mimeType, 'en_proceso', ); if (msgContent.imageMessage.caption) { textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`; } } else { this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`); return; } if (!textoNormalizado.trim()) return; this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`); if (primerMensajeDeUsuario) { await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true); } if (msgContent.imageMessage) { const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, { logger: this.baileysLogger, reuploadRequest: this.sock.updateMediaMessage, }); const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64'); const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg'; await this.api.enviarIngesta(ctx.leadId, [{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes', }]); } await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, { botStep: ctx.botStep, }); const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId); const leadParaClaude = { id: ctx.leadId, telefono: ctx.telefono, nombre: ctx.nombre, estado_actual: ctx.botStep || 'nuevo', espacio: null as string | null, rango_m2: null as string | null, estilo: null as string | null, urgencia: null as string | null, presupuesto_declarado: null as string | null, viable: ctx.viable as boolean | null, email: null as string | null, }; const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude( leadParaClaude as any, historial.slice(0, -1), textoNormalizado, ); this.logger.log(`LUISA [${telefono}]: ${respuesta}`); if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) { const entidadMap: Record = {}; if (entidad) { for (const [k, v] of Object.entries(entidad)) { const mapped = this.mapearCampoALegacy(k); entidadMap[mapped] = v; } } await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable }); if (nuevoEstado) ctx.botStep = nuevoEstado; if (viable !== undefined) ctx.viable = viable; this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`); } // ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto. const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable']; const anunciaPresupuesto = /presupuesto/i.test(respuesta) && /prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/i.test(respuesta); const esCierre = estadosCierre.includes(ctx.botStep) || anunciaPresupuesto; // Al cerrar, dispara el post-análisis de toda la conversación (una sola vez). if (esCierre && !this.leadsAnalizados.has(ctx.leadId)) { this.leadsAnalizados.add(ctx.leadId); this.api .analizarConversacion(ctx.leadId) .then((ok) => this.logger.log(`[ANALISIS] lead ${ctx.leadId}: ${ok ? 'ok' : 'fallo'}`)) .catch((e: any) => this.logger.error(`[ANALISIS] ${e.message}`)); } await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, { botStep: ctx.botStep, }); await this.enviarMensaje(jid, respuesta); // Tras cerrar, pide una foto para el render (si no la hemos pedido/recibido ya). if (esCierre && !this.esperandoFotos.has(ctx.leadId) && !this.pipelineDisparado.has(ctx.leadId)) { this.esperandoFotos.add(ctx.leadId); const pedir = 'Una última cosa para incluir el render en tu presupuesto: mándame una foto del espacio 📸'; await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', pedir, { botStep: 'pide_fotos' }); await this.enviarMensaje(jid, pedir); } } catch (error: any) { this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack); } } private mapearCampoALegacy(campo: string): string { const map: Record = { espacio: 'espacio', rango_m2: 'rangoM2', estilo: 'estilo', urgencia: 'urgencia', presupuesto_declarado: 'presupuestoDeclarado', nombre: 'nombre', }; return map[campo] || campo; } async enviarMensaje(jid: string, texto: string): Promise { if (!this.sock) return; try { const jidPresencia = jid.includes('@lid') ? `${jid.split('@')[0]}@s.whatsapp.net` : jid; await this.sock.sendPresenceUpdate('composing', jidPresencia); await this.delay(this.calcularDelayEscritura(texto.length)); await this.sock.sendPresenceUpdate('paused', jidPresencia); const safeSock = wrapSocket(this.sock); await safeSock.sendMessage(jid, { text: texto }); this.logger.log(`Mensaje enviado a ${jid}`); } catch (error: any) { this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`); } } isConectado(): boolean { return this.sock !== null; } }