singularity-forge/web/app/api/voice/route.ts
Mikael Hugo e50d96e1f8 chore(web): upgrade all dependencies to latest stable
- @hookform/resolvers 3.10.0 → 5.2.2
- @tailwindcss/postcss 4.2.1 → 4.3.0
- @types/node 24.12.2 → 25.6.2
- @uiw/codemirror-* 4.25.8 → 4.25.9
- autoprefixer 10.4.27 → 10.5.0
- esbuild 0.27.4 → 0.28.0
- eslint 9.39.4 → 9.x (pinned; eslint 10 incompatible with eslint-config-next)
- eslint-config-next 16.2.3 → 16.2.6
- lucide-react 0.564.0 → 1.14.0
- motion 12.36.0 → 12.38.0
- next 16.2.3 → 16.2.6
- postcss 8.5.8 → 8.5.14
- react/react-dom 19.2.4 → 19.2.6
- react-day-picker 9.13.2 → 10.0.0
- react-hook-form 7.71.2 → 7.75.0
- react-resizable-panels 2.1.9 → 4.11.0
- recharts 2.15.0 → 3.8.1
- sonner 1.7.4 → 2.0.7
- tailwindcss 4.2.1 → 4.3.0
- tw-animate-css 1.3.3 → 1.4.0
- typescript 5.7.3 → 6.0.3
- zod 3.25.76 → 4.4.3

Breaking changes fixed:
- react-resizable-panels v4: PanelGroup→Group, PanelResizeHandle→Separator
- react-day-picker v10: ClassNames.table renamed to month_grid
- recharts v3: TooltipContentProps/DefaultLegendContentProps type changes,
  DataKey type for key prop
- shiki: cast createHighlighter promise to local ShikiHighlighter type
- voice/route.ts: pass requestUrl through buildDigitsResponse
- pty-chat-parser.ts: declare _lastInputAt private field
- sf-workspace-store.tsx: fix stale pi-coding-agent import path,
  add import for locally-used workspace types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 11:52:54 +02:00

182 lines
5.1 KiB
TypeScript

/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 `<?xml version="1.0" encoding="UTF-8"?>\n<Response>\n${body}\n</Response>\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 `<Say voice="alice" language="en-US">${escapeXml(promptText)}</Say>`;
}
const promptUrl = new URL("/api/voice/prompt", requestUrl);
promptUrl.searchParams.set("text", promptText);
return `<Play>${escapeXml(promptUrl.toString())}</Play>`;
}
function buildDialTargets(numbers: string[]): string {
if (numbers.length === 0) {
return `<Say voice="alice" language="en-US">The requested destination is not configured.</Say>\n<Hangup/>`;
}
return [
`<Dial answerOnBridge="true" timeout="20">`,
...numbers.map((number) => ` <Number>${escapeXml(number)}</Number>`),
`</Dial>`,
].join("\n");
}
function buildMenuResponse(
requestUrl: string,
config: VoiceRoutingConfig,
): string {
return buildTwiml(
[
`<Gather input="dtmf" numDigits="1" timeout="6" action="/api/voice" method="POST">`,
` ${buildPromptVerb(buildMenuPrompt(config), requestUrl)}`,
`</Gather>`,
`<Say voice="alice" language="en-US">Sorry, we did not get a selection.</Say>`,
`<Redirect method="POST">/api/voice</Redirect>`,
].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<string | null> {
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<Response> {
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<Response> {
const digits = await readDigits(request);
const responseXml =
digits === null
? buildMenuResponse(request.url, getRoutingConfig())
: buildDigitsResponse(digits, request.url, getRoutingConfig());
return new Response(responseXml, {
headers: noStoreHeaders(),
});
}