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:
64
mvp/b2c/src/app/api/retell/webhook/route.ts
Normal file
64
mvp/b2c/src/app/api/retell/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user