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