Paso de fotos + flujo cross-canal llamada→WhatsApp para el render

Bot: al cerrar la cualificación, Luisa pide una foto y entra en modo recogida;
al llegar la foto la sube como "antes" con perfilCompleto:true → dispara render +
presupuesto + entrega del PDF. Nuevo webhook /whatsapp-fotos para que, tras una
llamada, Luisa escriba al lead, referencie lo hablado y le pida las fotos
(reutiliza el mismo modo).

App: el webhook de Retell, tras el análisis de la llamada, llama a pedirFotosWhatsapp
(WHATSAPP_FOTOS_WEBHOOK_URL) con el contexto de la reforma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-10 12:50:33 +02:00
parent c5d4a9296a
commit b0871b733c
5 changed files with 173 additions and 6 deletions

View File

@@ -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');
}

View File

@@ -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<string, LeadContext>();
// leads cuya conversación ya se mandó a post-análisis (para no repetir).
private readonly leadsAnalizados = new Set<string>();
// leads a los que se les ha pedido foto y estamos esperándola.
private readonly esperandoFotos = new Set<string>();
// leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir.
private readonly pipelineDisparado = new Set<string>();
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<void> {
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<void> {
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<string> {
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);
}