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 { db } from '@/db';
|
||||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||||
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
||||||
@@ -6,6 +6,12 @@ import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
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)
|
// 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
|
// 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,
|
// 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 event = body.event;
|
||||||
const callId = body.call?.call_id;
|
const callId = body.call?.call_id;
|
||||||
// Solo nos interesa el final de la llamada; respondemos 200 a todo para que Retell no reintente.
|
// 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')) {
|
if (!callId || (event !== 'call_analyzed' && event !== 'call_ended')) return ok({ skipped: true });
|
||||||
return new Response('ok', { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const detalle = await obtenerLlamada(callId);
|
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 })
|
.select({ id: leads.id })
|
||||||
.from(leads)
|
.from(leads)
|
||||||
.where(eq(leads.id, detalle.leadId))
|
.where(like(leads.telefono, `%${dig}%`))
|
||||||
|
.orderBy(desc(leads.createdAt))
|
||||||
.limit(1);
|
.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;
|
const audioUrl = detalle.recordingUrl ? await descargarGrabacion(detalle.recordingUrl) : null;
|
||||||
|
|
||||||
@@ -44,10 +61,10 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
audioUrl: audioUrl ?? undefined,
|
audioUrl: audioUrl ?? undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(leads.id, detalle.leadId));
|
.where(eq(leads.id, leadId));
|
||||||
|
|
||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId: detalle.leadId,
|
leadId,
|
||||||
stage: 'llamada_completada',
|
stage: 'llamada_completada',
|
||||||
metadata: {
|
metadata: {
|
||||||
via: 'webhook',
|
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 = {
|
export type DetalleLlamada = {
|
||||||
callId: string;
|
callId: string;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
toNumber: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
recordingUrl: string | null;
|
recordingUrl: string | null;
|
||||||
callStatus: string | null;
|
callStatus: string | null;
|
||||||
@@ -109,6 +110,7 @@ export async function obtenerLlamada(callId: string): Promise<DetalleLlamada | n
|
|||||||
return {
|
return {
|
||||||
callId: String(c.call_id ?? callId),
|
callId: String(c.call_id ?? callId),
|
||||||
leadId: typeof meta.lead_id === 'string' ? meta.lead_id : null,
|
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,
|
transcript: typeof c.transcript === 'string' ? c.transcript : null,
|
||||||
recordingUrl: typeof c.recording_url === 'string' ? c.recording_url : null,
|
recordingUrl: typeof c.recording_url === 'string' ? c.recording_url : null,
|
||||||
callStatus: typeof c.call_status === 'string' ? c.call_status : null,
|
callStatus: typeof c.call_status === 'string' ? c.call_status : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user