/**
* 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(),
});
}