Añade llamada saliente con Retell al funnel B2C
Integra el agente de voz de Retell (Arquitectura A para la demo): tras la pre-llamada, el orquestador lanza una llamada saliente real con override de agente y dynamic variables (empresa + cliente), mientras el render y el presupuesto se siguen generando con los datos del formulario. - src/lib/env.ts: esquema zod de RETELL_* (claves opcionales -> sin ellas el funnel sigue en modo simulado y el build no se rompe) - src/lib/voice/retell.ts: cliente fino con fetch a create-phone-call (sin nueva dependencia), normalización E.164 y builder de variables - orchestrator: dispara la llamada best-effort y guarda el callId - tests del normalizador de teléfono y del builder de variables Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
28
mvp/b2c/src/lib/env.ts
Normal file
28
mvp/b2c/src/lib/env.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Variables de entorno de integraciones externas, validadas con zod (lo exige CLAUDE.md).
|
||||
// Son OPCIONALES a propósito: sin ellas el funnel sigue en modo simulado, y ni el build ni
|
||||
// la demo dependen de tener claves cargadas.
|
||||
const opcional = z.preprocess(
|
||||
(v) => (v === '' || v === undefined ? undefined : v),
|
||||
z.string().min(1).optional(),
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
RETELL_API_KEY: opcional,
|
||||
RETELL_AGENT_ID: opcional,
|
||||
RETELL_FROM_NUMBER: opcional,
|
||||
});
|
||||
|
||||
export const env = schema.parse({
|
||||
RETELL_API_KEY: process.env.RETELL_API_KEY,
|
||||
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
|
||||
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
|
||||
});
|
||||
|
||||
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
|
||||
// ir ligado al número en el panel; si además se define RETELL_AGENT_ID, se manda como
|
||||
// override_agent_id en cada llamada.
|
||||
export function retellConfigurado(): boolean {
|
||||
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
|
||||
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { Lead } from '@/db/schema';
|
||||
import { deterministicExtractor } from '@/lib/voice/extractor';
|
||||
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
|
||||
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
||||
import type { RawCallData } from '@/lib/voice/preferences';
|
||||
|
||||
// Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless):
|
||||
@@ -60,6 +61,13 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) throw new Error('Lead no encontrado.');
|
||||
|
||||
const [tenant] = await db
|
||||
.select({ nombreEmpresa: tenants.nombreEmpresa })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, lead.tenantId))
|
||||
.limit(1);
|
||||
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
||||
|
||||
const tipo = lead.tipoReforma ?? 'otro';
|
||||
|
||||
// Paso 4: pre-llamada (SMS + WhatsApp)
|
||||
@@ -69,13 +77,24 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
metadata: { via: ['sms', 'whatsapp'], simulado: true },
|
||||
});
|
||||
|
||||
// Paso 5: llamada del agente IA completada
|
||||
// Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente
|
||||
// REAL (el móvil del lead suena y el agente habla con sus variables); el render y el
|
||||
// presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo).
|
||||
const llamada = await iniciarLlamadaSaliente({
|
||||
telefono: lead.telefono,
|
||||
variables: construirVariablesLlamada({ nombreEmpresa }, lead),
|
||||
});
|
||||
const transcripcion = construirTranscripcion(lead);
|
||||
const entidades = construirEntidades(lead);
|
||||
await db.insert(leadPipelineEventos).values({
|
||||
leadId,
|
||||
stage: 'llamada_completada',
|
||||
metadata: { simulado: true, duracionSeg: 95 },
|
||||
metadata: {
|
||||
simulado: !llamada,
|
||||
real: Boolean(llamada),
|
||||
retellCallId: llamada?.callId,
|
||||
duracionSeg: 95,
|
||||
},
|
||||
});
|
||||
|
||||
// Paso 6a: render IA generado
|
||||
|
||||
77
mvp/b2c/src/lib/voice/retell.ts
Normal file
77
mvp/b2c/src/lib/voice/retell.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { env, retellConfigurado } from '@/lib/env';
|
||||
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';
|
||||
|
||||
// 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.
|
||||
export function normalizarTelefonoEs(raw: string): string | null {
|
||||
const limpio = raw.replace(/[\s\-().]/g, '');
|
||||
if (/^\+[1-9]\d{7,14}$/.test(limpio)) return limpio;
|
||||
if (/^0034\d{9}$/.test(limpio)) return `+${limpio.slice(2)}`;
|
||||
if (/^34\d{9}$/.test(limpio)) return `+${limpio}`;
|
||||
if (/^[6789]\d{8}$/.test(limpio)) return `+34${limpio}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type VariablesLlamada = Record<string, string>;
|
||||
|
||||
// Dynamic variables que el agente de Retell inserta en su prompt ({{empresa_nombre}}, etc.).
|
||||
// Solo los datos que el script "habla": empresa y nombre de pila del cliente; tipo de reforma
|
||||
// y provincia van como contexto opcional para que el agente confirme en vez de preguntar a frío.
|
||||
export function construirVariablesLlamada(
|
||||
empresa: { nombreEmpresa: string },
|
||||
lead: Pick<Lead, 'nombre' | 'tipoReforma' | 'provincia'>,
|
||||
): VariablesLlamada {
|
||||
const vars: VariablesLlamada = {
|
||||
empresa_nombre: empresa.nombreEmpresa,
|
||||
cliente_nombre: lead.nombre.split(' ')[0],
|
||||
};
|
||||
if (lead.tipoReforma) vars.tipo_reforma = TIPO_LABEL[lead.tipoReforma].toLowerCase();
|
||||
if (lead.provincia) vars.provincia = lead.provincia;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export type ResultadoLlamada = { callId: string };
|
||||
|
||||
// Inicia una llamada saliente con el agente de Retell. Best-effort para la demo (Arquitectura A):
|
||||
// el POST vuelve en cuanto Retell acepta la llamada (el móvil del lead empieza a sonar) y el
|
||||
// pipeline continúa generando el entregable. Si Retell no está configurado, el teléfono no es
|
||||
// válido o la API falla, devuelve null y NO lanza — el funnel sigue en modo simulado.
|
||||
export async function iniciarLlamadaSaliente(opts: {
|
||||
telefono: string;
|
||||
variables: VariablesLlamada;
|
||||
}): Promise<ResultadoLlamada | null> {
|
||||
if (!retellConfigurado()) return null;
|
||||
|
||||
const toNumber = normalizarTelefonoEs(opts.telefono);
|
||||
if (!toNumber) return null;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
from_number: env.RETELL_FROM_NUMBER,
|
||||
to_number: toNumber,
|
||||
retell_llm_dynamic_variables: opts.variables,
|
||||
};
|
||||
if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID;
|
||||
|
||||
try {
|
||||
const res = await fetch(CREATE_PHONE_CALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RETELL_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Retell create-phone-call ${res.status}: ${await res.text()}`);
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as { call_id?: string };
|
||||
return data.call_id ? { callId: data.call_id } : null;
|
||||
} catch (err) {
|
||||
console.error('Retell create-phone-call error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user