From ac04972100c8f36a25c100a18378f6c9bad9c5e1 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Sun, 7 Jun 2026 18:56:16 +0200 Subject: [PATCH] =?UTF-8?q?Webhook=20Retell:=20fallback=20por=20tel=C3=A9f?= =?UTF-8?q?ono=20cuando=20la=20llamada=20no=20trae=20lead=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si la llamada no lleva metadata.lead_id (llamadas manuales/antiguas), el webhook busca el lead más reciente con ese teléfono (últimos 9 dígitos) y le asocia la transcripción + grabación. Responde JSON informativo (matched/leadId) para poder re-procesar llamadas a mano. Co-Authored-By: Claude Opus 4.8 --- mvp/b2c/src/app/api/retell/webhook/route.ts | 45 ++++++++++++++------- mvp/b2c/src/lib/voice/retell.ts | 2 + 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/mvp/b2c/src/app/api/retell/webhook/route.ts b/mvp/b2c/src/app/api/retell/webhook/route.ts index 98839be..1f3acaf 100644 --- a/mvp/b2c/src/app/api/retell/webhook/route.ts +++ b/mvp/b2c/src/app/api/retell/webhook/route.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { desc, eq, like } from 'drizzle-orm'; import { db } from '@/db'; import { leads, leadPipelineEventos } from '@/db/schema'; import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell'; @@ -6,6 +6,12 @@ import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const ok = (extra: Record = {}) => + new Response(JSON.stringify({ ok: true, ...extra }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + // Webhook de Retell (eventos de llamada). Al terminar la llamada (call_analyzed / call_ended) // releemos la llamada por su call_id desde la API de Retell (dato autoritativo y autenticado con // nuestra API key, así un webhook falso no puede inyectar datos), guardamos la transcripción real, @@ -21,19 +27,30 @@ export async function POST(req: Request): Promise { const event = body.event; const callId = body.call?.call_id; // Solo nos interesa el final de la llamada; respondemos 200 a todo para que Retell no reintente. - if (!callId || (event !== 'call_analyzed' && event !== 'call_ended')) { - return new Response('ok', { status: 200 }); - } + if (!callId || (event !== 'call_analyzed' && event !== 'call_ended')) return ok({ skipped: true }); const detalle = await obtenerLlamada(callId); - if (!detalle?.leadId) return new Response('ok', { status: 200 }); + if (!detalle) return ok({ matched: false, motivo: 'call no encontrada' }); - const [lead] = await db - .select({ id: leads.id }) - .from(leads) - .where(eq(leads.id, detalle.leadId)) - .limit(1); - if (!lead) return new Response('ok', { status: 200 }); + // Mapeo al lead: por metadata.lead_id; si falta (llamadas sin metadata), fallback al lead más + // reciente con ese teléfono (últimos 9 dígitos, tolerante a +34/espacios). + let leadId = detalle.leadId; + if (!leadId && detalle.toNumber) { + const dig = detalle.toNumber.replace(/\D/g, '').slice(-9); + if (dig.length === 9) { + const [m] = await db + .select({ id: leads.id }) + .from(leads) + .where(like(leads.telefono, `%${dig}%`)) + .orderBy(desc(leads.createdAt)) + .limit(1); + leadId = m?.id ?? null; + } + } + if (!leadId) return ok({ matched: false, motivo: 'sin lead' }); + + const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1); + if (!lead) return ok({ matched: false, motivo: 'lead no existe' }); const audioUrl = detalle.recordingUrl ? await descargarGrabacion(detalle.recordingUrl) : null; @@ -44,10 +61,10 @@ export async function POST(req: Request): Promise { audioUrl: audioUrl ?? undefined, updatedAt: new Date(), }) - .where(eq(leads.id, detalle.leadId)); + .where(eq(leads.id, leadId)); await db.insert(leadPipelineEventos).values({ - leadId: detalle.leadId, + leadId, stage: 'llamada_completada', metadata: { via: 'webhook', @@ -60,5 +77,5 @@ export async function POST(req: Request): Promise { }, }); - return new Response('ok', { status: 200 }); + return ok({ matched: true, leadId, transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl) }); } diff --git a/mvp/b2c/src/lib/voice/retell.ts b/mvp/b2c/src/lib/voice/retell.ts index 51fe555..b622e5c 100644 --- a/mvp/b2c/src/lib/voice/retell.ts +++ b/mvp/b2c/src/lib/voice/retell.ts @@ -83,6 +83,7 @@ export async function iniciarLlamadaSaliente(opts: { export type DetalleLlamada = { callId: string; leadId: string | null; + toNumber: string | null; transcript: string | null; recordingUrl: string | null; callStatus: string | null; @@ -109,6 +110,7 @@ export async function obtenerLlamada(callId: string): Promise