/** * Twilio IVR webhook for customer calls into the SF voice bridge. * * Purpose: give the public Twilio number a deterministic menu that can route * callers to the on-call line or to an optional AI assistant without adding a * separate voice service. * * Consumer: Twilio Programmable Voice webhook hits this route on inbound calls. */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type VoiceRoutingConfig = { onCallNumbers: string[]; aiNumbers: string[]; }; type VoicePromptConfig = { useElevenLabs: boolean; voiceId: string | null; modelId: string; apiKey: string | null; }; function noStoreHeaders(): HeadersInit { return { "Cache-Control": "no-store", "Content-Type": "text/xml; charset=utf-8", }; } function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function splitPhoneList(value: string | undefined): string[] { if (!value) return []; return value .split(/[\s,]+/) .map((entry) => entry.trim()) .filter(Boolean); } function getRoutingConfig(): VoiceRoutingConfig { const env = process.env; const onCallNumbers = splitPhoneList( env.TWILIO_IVR_ONCALL_NUMBERS ?? env.TWILIO_IVR_ONCALL_NUMBER ?? env.TWILIO_ONCALL_NUMBER ?? env.ONCALL_NUMBER, ); const aiNumbers = splitPhoneList( env.TWILIO_IVR_AI_NUMBERS ?? env.TWILIO_IVR_AI_NUMBER, ); return { aiNumbers, onCallNumbers }; } function getPromptConfig(): VoicePromptConfig { const env = process.env; const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? null; const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? null; return { apiKey, modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5", useElevenLabs: Boolean(apiKey && voiceId), voiceId, }; } function buildTwiml(body: string): string { return `\n\n${body}\n\n`; } function buildMenuPrompt(config: VoiceRoutingConfig): string { const lines = [ `Welcome to Singularity Forge.`, `Press 1 to reach the on-call line.`, ]; if (config.aiNumbers.length > 0) { lines.push(`Press 2 for the AI assistant.`); } lines.push(`Press 9 to repeat this menu.`); return lines.join(" "); } function buildPromptVerb(promptText: string, requestUrl: string): string { const promptConfig = getPromptConfig(); if (!promptConfig.useElevenLabs) { return `${escapeXml(promptText)}`; } const promptUrl = new URL("/api/voice/prompt", requestUrl); promptUrl.searchParams.set("text", promptText); return `${escapeXml(promptUrl.toString())}`; } function buildDialTargets(numbers: string[]): string { if (numbers.length === 0) { return `The requested destination is not configured.\n`; } return [ ``, ...numbers.map((number) => ` ${escapeXml(number)}`), ``, ].join("\n"); } function buildMenuResponse( requestUrl: string, config: VoiceRoutingConfig, ): string { return buildTwiml( [ ``, ` ${buildPromptVerb(buildMenuPrompt(config), requestUrl)}`, ``, `Sorry, we did not get a selection.`, `/api/voice`, ].join("\n"), ); } function buildDigitsResponse( digits: string | null, requestUrl: string, config: VoiceRoutingConfig, ): string { if (digits === "1") { return buildTwiml(buildDialTargets(config.onCallNumbers)); } if (digits === "2" && config.aiNumbers.length > 0) { return buildTwiml(buildDialTargets(config.aiNumbers)); } return buildMenuResponse(requestUrl, config); } async function readDigits(request: Request): Promise { if (request.method === "GET") return null; const body = await request.text(); if (!body) return null; const params = new URLSearchParams(body); return params.get("Digits") ?? params.get("digits"); } /** * Return the initial IVR menu for inbound Twilio calls. * * Purpose: present a stable entrypoint for customers so the Twilio number can * route them either to on-call or to an optional AI assistant. * * Consumer: Twilio inbound call webhook and local integration tests. */ export async function GET(request: Request): Promise { return new Response(buildMenuResponse(request.url, getRoutingConfig()), { headers: noStoreHeaders(), }); } /** * Route the Twilio call according to the selected IVR digit. * * Purpose: let the same public number handle both menu playback and the actual * customer transfer without adding a second webhook surface. * * Consumer: Twilio posts DTMF selections back to this route after the caller * chooses an IVR option. */ export async function POST(request: Request): Promise { const digits = await readDigits(request); const responseXml = digits === null ? buildMenuResponse(request.url, getRoutingConfig()) : buildDigitsResponse(digits, request.url, getRoutingConfig()); return new Response(responseXml, { headers: noStoreHeaders(), }); }