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:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user