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') { } else if (url === '/whatsapp-pdf') {
await this.handleWhatsappPdf(payload); await this.handleWhatsappPdf(payload);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true })); 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 { } else {
res.writeHead(404).end('Not Found'); res.writeHead(404).end('Not Found');
} }

View File

@@ -28,6 +28,7 @@ import { wrapSocket } from 'baileys-antiban';
export const pdfEmitter = new EventEmitter(); export const pdfEmitter = new EventEmitter();
export const startEmitter = new EventEmitter(); export const startEmitter = new EventEmitter();
export const fotosEmitter = new EventEmitter();
interface LeadContext { interface LeadContext {
leadId: string; leadId: string;
@@ -51,6 +52,10 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly leadCache = new Map<string, LeadContext>(); private readonly leadCache = new Map<string, LeadContext>();
// leads cuya conversación ya se mandó a post-análisis (para no repetir). // leads cuya conversación ya se mandó a post-análisis (para no repetir).
private readonly leadsAnalizados = new Set<string>(); 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( constructor(
private readonly leadsService: LeadsService, private readonly leadsService: LeadsService,
@@ -66,6 +71,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
await this.conectar(); await this.conectar();
this.escucharPdf(); this.escucharPdf();
this.escucharStart(); this.escucharStart();
this.escucharFotos();
} }
async onModuleDestroy() { 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 { private normalizarTelefono(jid: string): string {
return jid.split('@')[0].replace(/\D/g, ''); return jid.split('@')[0].replace(/\D/g, '');
} }
@@ -353,6 +448,13 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
const msgContent = normalizeMessageContent(msg.message); const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return; 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) { if (msgContent.conversation || msgContent.extendedTextMessage) {
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || ''; textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
} else if (msgContent.audioMessage) { } 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}`); 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). // ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto.
// Por estado (errático) O porque Luisa anuncia el presupuesto en su mensaje. const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
const cierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
const anunciaPresupuesto = const anunciaPresupuesto =
/presupuesto/i.test(respuesta) && /presupuesto/i.test(respuesta) &&
/prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/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.leadsAnalizados.add(ctx.leadId);
this.api this.api
.analizarConversacion(ctx.leadId) .analizarConversacion(ctx.leadId)
@@ -469,6 +573,14 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
botStep: ctx.botStep, botStep: ctx.botStep,
}); });
await this.enviarMensaje(jid, respuesta); 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) { } catch (error: any) {
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack); this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
} }

View File

@@ -1,8 +1,9 @@
import { desc, eq, like } from 'drizzle-orm'; import { desc, eq, like } from 'drizzle-orm';
import { db } from '@/db'; 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 { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion'; import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion';
import { pedirFotosWhatsapp } from '@/lib/webhooks';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -81,7 +82,34 @@ export async function POST(req: Request): Promise<Response> {
// Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada. // Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada.
const analisis = detalle.transcript const analisis = detalle.transcript
? await analizarTranscripcion(leadId, detalle.transcript, 'llamada') ? 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({ return ok({
matched: true, matched: true,
@@ -89,5 +117,6 @@ export async function POST(req: Request): Promise<Response> {
transcript: Boolean(detalle.transcript), transcript: Boolean(detalle.transcript),
grabacion: Boolean(audioUrl), grabacion: Boolean(audioUrl),
analizado: analisis.ok, analizado: analisis.ok,
fotosPedidas,
}); });
} }

View File

@@ -26,6 +26,8 @@ const schema = z.object({
PERFIL_WEBHOOK_URL: opcional, PERFIL_WEBHOOK_URL: opcional,
WHATSAPP_WEBHOOK_URL: opcional, WHATSAPP_WEBHOOK_URL: opcional,
WHATSAPP_START_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). // Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email).
APP_URL: opcional, APP_URL: opcional,
// LLM (OpenRouter) para el post-análisis de la conversación de WhatsApp. // 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, PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL,
WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL, WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL,
WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_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, APP_URL: process.env.APP_URL,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS, OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS,
@@ -76,3 +79,7 @@ export function whatsappWebhookConfigurado(): boolean {
export function whatsappStartConfigurado(): boolean { export function whatsappStartConfigurado(): boolean {
return Boolean(env.WHATSAPP_START_WEBHOOK_URL); return Boolean(env.WHATSAPP_START_WEBHOOK_URL);
} }
export function whatsappFotosConfigurado(): boolean {
return Boolean(env.WHATSAPP_FOTOS_WEBHOOK_URL);
}

View File

@@ -3,6 +3,7 @@ import {
perfilWebhookConfigurado, perfilWebhookConfigurado,
whatsappWebhookConfigurado, whatsappWebhookConfigurado,
whatsappStartConfigurado, whatsappStartConfigurado,
whatsappFotosConfigurado,
} from '@/lib/env'; } from '@/lib/env';
// POST JSON best-effort: nunca lanza. Devuelve true solo si el destino respondió 2xx. // 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; if (!whatsappStartConfigurado()) return false;
return postWebhook(env.WHATSAPP_START_WEBHOOK_URL!, payload); 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<boolean> {
if (!whatsappFotosConfigurado()) return false;
return postWebhook(env.WHATSAPP_FOTOS_WEBHOOK_URL!, payload);
}