Captura transcripción + grabación reales de la llamada (webhook Retell)

- retell.ts: la llamada saliente manda metadata.lead_id; helpers obtenerLlamada
  (GET /v2/get-call, dato autoritativo) y descargarGrabacion (guarda el audio
  en nuestro sistema como data URI).
- /api/retell/webhook: en call_analyzed/call_ended relee la llamada por call_id,
  guarda la transcripción real en lead.transcripcion, descarga la grabación a
  lead.audio_url y deja el análisis + duración en un evento de pipeline. Seguro
  por re-fetch (no se fía del body del webhook).
- orchestrator/pedirLlamada: pasan leadId; procesarLead ya no guarda transcript
  simulado cuando la llamada es real (lo rellena el webhook).
- La ficha del panel ya mostraba transcripción + audio: ahora se pueblan solos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-07 18:49:12 +02:00
parent 6ef69b403d
commit aed0ffae50
4 changed files with 129 additions and 1 deletions

View File

@@ -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<Response> {
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 });
}

View File

@@ -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,

View File

@@ -83,8 +83,11 @@ export async function procesarLead(leadId: string): Promise<void> {
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,

View File

@@ -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<ResultadoLlamada | null> {
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<DetalleLlamada | null> {
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<string, unknown>;
const meta = (c.metadata ?? {}) as Record<string, unknown>;
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<string | null> {
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;
}
}