diff --git a/mvp/b2c/src/app/api/retell/webhook/route.ts b/mvp/b2c/src/app/api/retell/webhook/route.ts new file mode 100644 index 0000000..98839be --- /dev/null +++ b/mvp/b2c/src/app/api/retell/webhook/route.ts @@ -0,0 +1,64 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { leads, leadPipelineEventos } from '@/db/schema'; +import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// 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, +// descargamos la grabación a nuestro sistema (lead.audio_url) y dejamos el análisis en el pipeline. +export async function POST(req: Request): Promise { + let body: { event?: string; call?: { call_id?: string } } = {}; + try { + body = await req.json(); + } catch { + return new Response('bad json', { status: 400 }); + } + + 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 }); + } + + const detalle = await obtenerLlamada(callId); + if (!detalle?.leadId) return new Response('ok', { status: 200 }); + + 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 }); + + const audioUrl = detalle.recordingUrl ? await descargarGrabacion(detalle.recordingUrl) : null; + + await db + .update(leads) + .set({ + transcripcion: detalle.transcript ?? undefined, + audioUrl: audioUrl ?? undefined, + updatedAt: new Date(), + }) + .where(eq(leads.id, detalle.leadId)); + + await db.insert(leadPipelineEventos).values({ + leadId: detalle.leadId, + stage: 'llamada_completada', + metadata: { + via: 'webhook', + real: true, + retellCallId: detalle.callId, + callStatus: detalle.callStatus, + duracionSeg: detalle.duracionSeg, + grabacionGuardada: Boolean(audioUrl), + analysis: detalle.analysis ?? null, + }, + }); + + return new Response('ok', { status: 200 }); +} diff --git a/mvp/b2c/src/app/solicitud/actions.ts b/mvp/b2c/src/app/solicitud/actions.ts index 8a1e4f3..4d9320b 100644 --- a/mvp/b2c/src/app/solicitud/actions.ts +++ b/mvp/b2c/src/app/solicitud/actions.ts @@ -237,6 +237,7 @@ export async function pedirLlamada( const llamada = await iniciarLlamadaSaliente({ telefono: lead.telefono, variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead), + leadId, }); await db.insert(leadPipelineEventos).values({ leadId, diff --git a/mvp/b2c/src/lib/funnel/orchestrator.ts b/mvp/b2c/src/lib/funnel/orchestrator.ts index 4bc8d81..6b40865 100644 --- a/mvp/b2c/src/lib/funnel/orchestrator.ts +++ b/mvp/b2c/src/lib/funnel/orchestrator.ts @@ -83,8 +83,11 @@ export async function procesarLead(leadId: string): Promise { const llamada = await iniciarLlamadaSaliente({ telefono: lead.telefono, variables: construirVariablesLlamada({ nombreEmpresa }, lead), + leadId, }); - const transcripcion = construirTranscripcion(lead); + // Si la llamada es REAL, la transcripción real la rellena el webhook de Retell al colgar; no + // guardamos el placeholder. Solo usamos el transcript simulado cuando no hay llamada real. + const transcripcion = llamada ? null : construirTranscripcion(lead); const entidades = construirEntidades(lead); await db.insert(leadPipelineEventos).values({ leadId, diff --git a/mvp/b2c/src/lib/voice/retell.ts b/mvp/b2c/src/lib/voice/retell.ts index d5ec782..51fe555 100644 --- a/mvp/b2c/src/lib/voice/retell.ts +++ b/mvp/b2c/src/lib/voice/retell.ts @@ -3,6 +3,7 @@ import { TIPO_LABEL } from '@/lib/funnel'; import type { Lead } from '@/db/schema'; const CREATE_PHONE_CALL_URL = 'https://api.retellai.com/v2/create-phone-call'; +const GET_CALL_URL = 'https://api.retellai.com/v2/get-call'; // Normaliza un teléfono español a E.164 (+34XXXXXXXXX), que es lo que exige Retell. // Acepta espacios, guiones, paréntesis y los prefijos +34 / 0034 / 34 ya puestos. @@ -42,6 +43,7 @@ export type ResultadoLlamada = { callId: string }; export async function iniciarLlamadaSaliente(opts: { telefono: string; variables: VariablesLlamada; + leadId?: string; // se manda como metadata.lead_id para mapear la llamada al lead en el webhook }): Promise { if (!retellConfigurado()) return null; @@ -53,6 +55,7 @@ export async function iniciarLlamadaSaliente(opts: { to_number: toNumber, retell_llm_dynamic_variables: opts.variables, }; + if (opts.leadId) body.metadata = { lead_id: opts.leadId }; if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID; try { @@ -75,3 +78,60 @@ export async function iniciarLlamadaSaliente(opts: { return null; } } + +// Datos post-llamada de Retell (lo que usamos para el post-análisis). +export type DetalleLlamada = { + callId: string; + leadId: string | null; + transcript: string | null; + recordingUrl: string | null; + callStatus: string | null; + duracionSeg: number | null; + analysis: unknown; +}; + +// Consulta autoritativa de una llamada por su id (el webhook solo nos da el call_id; releemos de +// Retell con nuestra API key para tener el dato real y autenticado). +export async function obtenerLlamada(callId: string): Promise { + if (!env.RETELL_API_KEY) return null; + try { + const res = await fetch(`${GET_CALL_URL}/${encodeURIComponent(callId)}`, { + headers: { Authorization: `Bearer ${env.RETELL_API_KEY}` }, + }); + if (!res.ok) { + console.error(`Retell get-call ${res.status}: ${await res.text()}`); + return null; + } + const c = (await res.json()) as Record; + const meta = (c.metadata ?? {}) as Record; + const start = typeof c.start_timestamp === 'number' ? c.start_timestamp : null; + const end = typeof c.end_timestamp === 'number' ? c.end_timestamp : null; + return { + callId: String(c.call_id ?? callId), + leadId: typeof meta.lead_id === 'string' ? meta.lead_id : 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, + duracionSeg: start && end ? Math.round((end - start) / 1000) : null, + analysis: c.call_analysis ?? null, + }; + } catch (err) { + console.error('Retell get-call error:', err); + return null; + } +} + +// Descarga la grabación de Retell y la devuelve como data URI para almacenarla en nuestro sistema +// (control de retención propio, sin depender de la URL de Retell que puede caducar). +export async function descargarGrabacion(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + const tipo = res.headers.get('content-type') || 'audio/wav'; + const buf = Buffer.from(await res.arrayBuffer()); + return `data:${tipo};base64,${buf.toString('base64')}`; + } catch (err) { + console.error('descargarGrabacion error:', err); + return null; + } +}