- @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>
182 lines
5.1 KiB
TypeScript
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, "&")
|
|
.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 `<?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(),
|
|
});
|
|
}
|