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({
|
const llamada = await iniciarLlamadaSaliente({
|
||||||
telefono: lead.telefono,
|
telefono: lead.telefono,
|
||||||
variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead),
|
variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead),
|
||||||
|
leadId,
|
||||||
});
|
});
|
||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId,
|
leadId,
|
||||||
|
|||||||
@@ -83,8 +83,11 @@ export async function procesarLead(leadId: string): Promise<void> {
|
|||||||
const llamada = await iniciarLlamadaSaliente({
|
const llamada = await iniciarLlamadaSaliente({
|
||||||
telefono: lead.telefono,
|
telefono: lead.telefono,
|
||||||
variables: construirVariablesLlamada({ nombreEmpresa }, lead),
|
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);
|
const entidades = construirEntidades(lead);
|
||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId,
|
leadId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TIPO_LABEL } from '@/lib/funnel';
|
|||||||
import type { Lead } from '@/db/schema';
|
import type { Lead } from '@/db/schema';
|
||||||
|
|
||||||
const CREATE_PHONE_CALL_URL = 'https://api.retellai.com/v2/create-phone-call';
|
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.
|
// 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.
|
// 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: {
|
export async function iniciarLlamadaSaliente(opts: {
|
||||||
telefono: string;
|
telefono: string;
|
||||||
variables: VariablesLlamada;
|
variables: VariablesLlamada;
|
||||||
|
leadId?: string; // se manda como metadata.lead_id para mapear la llamada al lead en el webhook
|
||||||
}): Promise<ResultadoLlamada | null> {
|
}): Promise<ResultadoLlamada | null> {
|
||||||
if (!retellConfigurado()) return null;
|
if (!retellConfigurado()) return null;
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export async function iniciarLlamadaSaliente(opts: {
|
|||||||
to_number: toNumber,
|
to_number: toNumber,
|
||||||
retell_llm_dynamic_variables: opts.variables,
|
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;
|
if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,3 +78,60 @@ export async function iniciarLlamadaSaliente(opts: {
|
|||||||
return null;
|
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