diff --git a/mvp/Whatsapp-bot/src/webhook/webhook-listener.ts b/mvp/Whatsapp-bot/src/webhook/webhook-listener.ts index 5965ad7..512aef8 100644 --- a/mvp/Whatsapp-bot/src/webhook/webhook-listener.ts +++ b/mvp/Whatsapp-bot/src/webhook/webhook-listener.ts @@ -77,6 +77,11 @@ export class WebhookListener implements OnApplicationBootstrap { } else if (url === '/whatsapp-pdf') { await this.handleWhatsappPdf(payload); res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true })); + } else if (url === '/whatsapp-fotos') { + // Cross-canal: tras una llamada, la app pide que Luisa escriba al lead y le pida las fotos. + const { fotosEmitter } = await import('../whatsapp/whatsapp.service'); + fotosEmitter.emit('fotos', payload); + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true })); } else { res.writeHead(404).end('Not Found'); } diff --git a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts index e88bd11..31f901b 100644 --- a/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts +++ b/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts @@ -28,6 +28,7 @@ import { wrapSocket } from 'baileys-antiban'; export const pdfEmitter = new EventEmitter(); export const startEmitter = new EventEmitter(); +export const fotosEmitter = new EventEmitter(); interface LeadContext { leadId: string; @@ -51,6 +52,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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, @@ -66,6 +71,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { await this.conectar(); this.escucharPdf(); this.escucharStart(); + this.escucharFotos(); } async onModuleDestroy() { @@ -162,6 +168,95 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { } } + // 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, ''); } @@ -353,6 +448,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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) { @@ -451,13 +553,15 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`); } - // Al cerrar la cualificación, dispara el post-análisis de toda la conversación (una sola vez). - // Por estado (errático) O porque Luisa anuncia el presupuesto en su mensaje. - const cierre = ['presupuesto', 'fin_viable', 'fin_no_viable']; + // ¿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); - if ((cierre.includes(ctx.botStep) || anunciaPresupuesto) && !this.leadsAnalizados.has(ctx.leadId)) { + 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) @@ -469,6 +573,14 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy { 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); } diff --git a/mvp/b2c/src/app/api/retell/webhook/route.ts b/mvp/b2c/src/app/api/retell/webhook/route.ts index cff9469..abe60ab 100644 --- a/mvp/b2c/src/app/api/retell/webhook/route.ts +++ b/mvp/b2c/src/app/api/retell/webhook/route.ts @@ -1,8 +1,9 @@ import { desc, eq, like } from 'drizzle-orm'; import { db } from '@/db'; -import { leads, leadPipelineEventos } from '@/db/schema'; +import { leads, leadPipelineEventos, tenants } from '@/db/schema'; import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell'; import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion'; +import { pedirFotosWhatsapp } from '@/lib/webhooks'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -81,7 +82,34 @@ export async function POST(req: Request): Promise { // Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada. const analisis = detalle.transcript ? await analizarTranscripcion(leadId, detalle.transcript, 'llamada') - : { ok: false, error: 'sin transcripción' }; + : { ok: false as const, error: 'sin transcripción' }; + + // Cross-canal: tras la llamada, Luisa escribe al lead por WhatsApp, referencia lo hablado y le + // pide las fotos (el agente de voz le dijo que las enviara por ahí). + let fotosPedidas = false; + if (analisis.ok) { + const [info] = await db + .select({ nombre: leads.nombre, telefono: leads.telefono, tenantId: leads.tenantId }) + .from(leads) + .where(eq(leads.id, leadId)) + .limit(1); + if (info) { + const [t] = await db + .select({ nombre: tenants.nombreEmpresa }) + .from(tenants) + .where(eq(tenants.id, info.tenantId)) + .limit(1); + const tipo = (analisis.perfil?.tipoReforma as string | undefined) ?? null; + const contexto = tipo ? `la reforma de tu ${tipo}` : 'tu reforma'; + fotosPedidas = await pedirFotosWhatsapp({ + leadId, + telefono: info.telefono, + nombre: info.nombre, + empresa: t?.nombre ?? 'Reformix', + contexto, + }); + } + } return ok({ matched: true, @@ -89,5 +117,6 @@ export async function POST(req: Request): Promise { transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl), analizado: analisis.ok, + fotosPedidas, }); } diff --git a/mvp/b2c/src/lib/env.ts b/mvp/b2c/src/lib/env.ts index cd7244b..4b94f38 100644 --- a/mvp/b2c/src/lib/env.ts +++ b/mvp/b2c/src/lib/env.ts @@ -26,6 +26,8 @@ const schema = z.object({ PERFIL_WEBHOOK_URL: opcional, WHATSAPP_WEBHOOK_URL: opcional, WHATSAPP_START_WEBHOOK_URL: opcional, + // Cross-canal: tras una llamada, pedir al lead las fotos por WhatsApp. + WHATSAPP_FOTOS_WEBHOOK_URL: opcional, // Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email). APP_URL: opcional, // LLM (OpenRouter) para el post-análisis de la conversación de WhatsApp. @@ -47,6 +49,7 @@ export const env = schema.parse({ PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL, WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL, WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL, + WHATSAPP_FOTOS_WEBHOOK_URL: process.env.WHATSAPP_FOTOS_WEBHOOK_URL, APP_URL: process.env.APP_URL, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS, @@ -76,3 +79,7 @@ export function whatsappWebhookConfigurado(): boolean { export function whatsappStartConfigurado(): boolean { return Boolean(env.WHATSAPP_START_WEBHOOK_URL); } + +export function whatsappFotosConfigurado(): boolean { + return Boolean(env.WHATSAPP_FOTOS_WEBHOOK_URL); +} diff --git a/mvp/b2c/src/lib/webhooks.ts b/mvp/b2c/src/lib/webhooks.ts index c33027a..196bc47 100644 --- a/mvp/b2c/src/lib/webhooks.ts +++ b/mvp/b2c/src/lib/webhooks.ts @@ -3,6 +3,7 @@ import { perfilWebhookConfigurado, whatsappWebhookConfigurado, whatsappStartConfigurado, + whatsappFotosConfigurado, } from '@/lib/env'; // POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx. @@ -55,3 +56,16 @@ export async function iniciarConversacionWhatsapp(payload: { if (!whatsappStartConfigurado()) return false; return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload); } + +// Cross-canal: tras una llamada, que Luisa escriba al lead por WhatsApp, referencie lo hablado +// (contexto) y le pida las fotos para completar el render + presupuesto. +export async function pedirFotosWhatsapp(payload: { + leadId: string; + telefono: string; + nombre: string; + empresa: string; + contexto?: string; +}): Promise { + if (!whatsappFotosConfigurado()) return false; + return postWebhook(env.WHATSAPP_FOTOS_WEBHOOK_URL!, payload); +}