Webhook Retell: fallback por teléfono cuando la llamada no trae lead_id

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-07 18:56:16 +02:00
parent aed0ffae50
commit ac04972100
2 changed files with 33 additions and 14 deletions

View File

@@ -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<string, unknown> = {}) =>
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<Response> {
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
// 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(eq(leads.id, detalle.leadId))
.where(like(leads.telefono, `%${dig}%`))
.orderBy(desc(leads.createdAt))
.limit(1);
if (!lead) return new Response('ok', { status: 200 });
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<Response> {
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<Response> {
},
});
return new Response('ok', { status: 200 });
return ok({ matched: true, leadId, transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl) });
}

View File

@@ -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<DetalleLlamada | n
return {
callId: String(c.call_id ?? callId),
leadId: typeof meta.lead_id === 'string' ? meta.lead_id : null,
toNumber: typeof c.to_number === 'string' ? c.to_number : null,
transcript: typeof c.transcript === 'string' ? c.transcript : null,
recordingUrl: typeof c.recording_url === 'string' ? c.recording_url : null,
callStatus: typeof c.call_status === 'string' ? c.call_status : null,