refactor(uok): rename scheduler-v2 and plan-v2 to drop v2 suffix
v1 no longer exists — the suffix is just noise. Update all import sites and rename the test file to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
9450b4a11d
commit
5dbd318a76
15 changed files with 1063 additions and 990 deletions
1585
package-lock.json
generated
1585
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -29,7 +29,7 @@
|
|||
"test": "vitest run packages/daemon/src --root ../.. --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.93.0",
|
||||
"@anthropic-ai/sdk": "^0.95.1",
|
||||
"@singularity-forge/rpc-client": "^2.75.3",
|
||||
"discord.js": "^14.25.1",
|
||||
"yaml": "^2.8.0",
|
||||
|
|
|
|||
|
|
@ -23,21 +23,21 @@
|
|||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.93.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
||||
"@google/gemini-cli-core": "0.40.1",
|
||||
"@google/genai": "^1.40.0",
|
||||
"@anthropic-ai/sdk": "^0.95.1",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1045.0",
|
||||
"@google/gemini-cli-core": "^0.41.2",
|
||||
"@google/genai": "^2.0.1",
|
||||
"@mistralai/mistralai": "^2.2.1",
|
||||
"@singularity-forge/google-gemini-cli-provider": "^2.75.3",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"ajv": "^8.17.1",
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"chalk": "^5.6.2",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"openai": "^6.26.0",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"undici": "^7.24.2",
|
||||
"openai": "^6.37.0",
|
||||
"proxy-agent": "^8.0.1",
|
||||
"undici": "^8.2.0",
|
||||
"yaml": "^2.8.3",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,18 +23,18 @@
|
|||
"@mariozechner/jiti": "^2.6.2",
|
||||
"@silvia-odwyer/photon-node": "^0.3.4",
|
||||
"chalk": "^5.5.0",
|
||||
"diff": "^8.0.2",
|
||||
"diff": "^9.0.0",
|
||||
"express": "^5.2.1",
|
||||
"extract-zip": "^2.0.1",
|
||||
"file-type": "^21.1.1",
|
||||
"hosted-git-info": "^9.0.2",
|
||||
"file-type": "^21.3.4",
|
||||
"hosted-git-info": "^9.0.3",
|
||||
"ignore": "^7.0.5",
|
||||
"marked": "^15.0.12",
|
||||
"minimatch": "^10.2.3",
|
||||
"marked": "^18.0.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.24.2",
|
||||
"yaml": "^2.8.2"
|
||||
"strip-ansi": "^7.2.0",
|
||||
"undici": "^8.2.0",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^7.0.2",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked": "^18.0.3",
|
||||
"mime-types": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ import {
|
|||
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
import { hasFinalizedMilestoneContext } from "./uok/plan-v2.js";
|
||||
import { hasFinalizedMilestoneContext } from "./uok/plan.js";
|
||||
import {
|
||||
decideUnitRuntimeDispatch,
|
||||
readUnitRuntimeRecord,
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ import {
|
|||
ensurePlanV2Graph as ensurePlanningFlowGraph,
|
||||
isEmptyPlanV2GraphResult,
|
||||
isMissingFinalizedContextResult,
|
||||
} from "../uok/plan-v2.js";
|
||||
} from "../uok/plan.js";
|
||||
import { buildUokProgressEvent } from "../uok/progress-event.js";
|
||||
import {
|
||||
clearUnitRuntimeRecord,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ import { getMilestoneSlices, isDbAvailable } from "./sf-db.js";
|
|||
import { deriveState } from "./state.js";
|
||||
import { resolveUokFlags } from "./uok/flags.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan-v2.js";
|
||||
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan.js";
|
||||
import {
|
||||
clearUnitRuntimeRecord,
|
||||
listUnitRuntimeRecords,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
ExecutionGraphSchedulerV2,
|
||||
ProgressStream,
|
||||
WorkerPool,
|
||||
} from "../uok/scheduler-v2.js";
|
||||
} from "../uok/scheduler.js";
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
|
|
@ -152,7 +152,7 @@ export {
|
|||
isEmptyPlanV2GraphResult,
|
||||
isExecutionEntryPhase,
|
||||
isMissingFinalizedContextResult,
|
||||
} from "./plan-v2.js";
|
||||
} from "./plan.js";
|
||||
export {
|
||||
buildUokProgressEvent,
|
||||
UOK_PROGRESS_EVENT_TYPES,
|
||||
|
|
@ -164,7 +164,7 @@ export {
|
|||
ExecutionGraphSchedulerV2,
|
||||
ProgressStream,
|
||||
WorkerPool,
|
||||
} from "./scheduler-v2.js";
|
||||
} from "./scheduler.js";
|
||||
export { SecurityGate } from "./security-gate.js";
|
||||
// ─── Task State Machine ────────────────────────────────────────────────────
|
||||
export {
|
||||
|
|
|
|||
143
src/tests/integration/web-voice-ivr-contract.test.ts
Normal file
143
src/tests/integration/web-voice-ivr-contract.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, test, vi } from "vitest";
|
||||
|
||||
const voiceRoute = await import("../../../web/app/api/voice/route.ts");
|
||||
const voicePromptRoute = await import(
|
||||
"../../../web/app/api/voice/prompt/route.ts"
|
||||
);
|
||||
|
||||
const ENV_KEYS = [
|
||||
"ELEVENLABS_API_KEY",
|
||||
"ELEVENLABS_VOICE_ID",
|
||||
"ELEVENLABS_MODEL_ID",
|
||||
"TWILIO_IVR_AI_NUMBER",
|
||||
"TWILIO_IVR_AI_NUMBERS",
|
||||
"TWILIO_IVR_ONCALL_NUMBER",
|
||||
"TWILIO_IVR_ONCALL_NUMBERS",
|
||||
"TWILIO_ONCALL_NUMBER",
|
||||
"ONCALL_NUMBER",
|
||||
] as const;
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
) as Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
||||
|
||||
function setEnv(
|
||||
values: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>,
|
||||
): void {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setEnv(originalEnv);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("voice IVR", () => {
|
||||
test("voice_GET_when_oncall_number_configured_renders_menu", async () => {
|
||||
setEnv({
|
||||
TWILIO_IVR_ONCALL_NUMBER: "+16175551212",
|
||||
});
|
||||
|
||||
const response = await voiceRoute.GET(
|
||||
new Request("http://localhost/api/voice"),
|
||||
);
|
||||
const xml = await response.text();
|
||||
|
||||
assert.equal(
|
||||
response.headers.get("content-type"),
|
||||
"text/xml; charset=utf-8",
|
||||
);
|
||||
assert.match(xml, /<Gather input="dtmf" numDigits="1" timeout="6"/);
|
||||
assert.match(xml, /Welcome to Singularity Forge\./);
|
||||
assert.match(xml, /Press 1 to reach the on-call line\./);
|
||||
assert.match(xml, /Press 9 to repeat this menu\./);
|
||||
assert.doesNotMatch(xml, /Press 2 for the AI assistant\./);
|
||||
});
|
||||
|
||||
test("voice_POST_when_digit_one_dials_oncall_number", async () => {
|
||||
setEnv({
|
||||
TWILIO_IVR_ONCALL_NUMBERS: "+16175551212,+16175551213",
|
||||
});
|
||||
|
||||
const response = await voiceRoute.POST(
|
||||
new Request("http://localhost/api/voice", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "Digits=1",
|
||||
}),
|
||||
);
|
||||
const xml = await response.text();
|
||||
|
||||
assert.match(xml, /<Dial answerOnBridge="true" timeout="20">/);
|
||||
assert.match(xml, /<Number>\+16175551212<\/Number>/);
|
||||
assert.match(xml, /<Number>\+16175551213<\/Number>/);
|
||||
});
|
||||
|
||||
test("voice_GET_when_elevenlabs_configured_uses_prompt_audio", async () => {
|
||||
setEnv({
|
||||
ELEVENLABS_API_KEY: "test-api-key",
|
||||
ELEVENLABS_VOICE_ID: "voice-123",
|
||||
ELEVENLABS_MODEL_ID: "eleven_flash_v2_5",
|
||||
TWILIO_IVR_ONCALL_NUMBER: "+16175551212",
|
||||
});
|
||||
|
||||
const response = await voiceRoute.GET(
|
||||
new Request("https://example.com/api/voice"),
|
||||
);
|
||||
const xml = await response.text();
|
||||
|
||||
assert.match(xml, /<Play>https:\/\/example\.com\/api\/voice\/prompt\?/);
|
||||
assert.match(xml, /text=Welcome\+to\+Singularity\+Forge\./);
|
||||
});
|
||||
|
||||
test("voice_prompt_GET_when_configured_returns_audio", async () => {
|
||||
setEnv({
|
||||
ELEVENLABS_API_KEY: "test-api-key",
|
||||
ELEVENLABS_VOICE_ID: "voice-123",
|
||||
ELEVENLABS_MODEL_ID: "eleven_flash_v2_5",
|
||||
});
|
||||
|
||||
const fetchCalls: Array<[string, RequestInit | undefined]> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push([String(input), init]);
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: {
|
||||
"content-type": "audio/mpeg",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await voicePromptRoute.GET(
|
||||
new Request(
|
||||
"https://example.com/api/voice/prompt?text=Welcome%20to%20Singularity%20Forge.",
|
||||
),
|
||||
);
|
||||
const body = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "audio/mpeg");
|
||||
assert.deepEqual(Array.from(body), [1, 2, 3]);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(
|
||||
fetchCalls[0]![0],
|
||||
/https:\/\/api\.elevenlabs\.io\/v1\/text-to-speech\/voice-123\?output_format=mp3_44100_128/,
|
||||
);
|
||||
assert.equal(
|
||||
(fetchCalls[0]![1]?.headers as Record<string, string>)["xi-api-key"],
|
||||
"test-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
92
web/app/api/voice/prompt/route.ts
Normal file
92
web/app/api/voice/prompt/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* ElevenLabs prompt synthesizer for the SF Twilio IVR.
|
||||
*
|
||||
* Purpose: keep spoken IVR prompts outside Twilio's built-in TTS so the voice
|
||||
* can be swapped to ElevenLabs without changing the call-control webhook.
|
||||
*
|
||||
* Consumer: the Twilio IVR route requests this endpoint when ElevenLabs prompt
|
||||
* synthesis is configured.
|
||||
*/
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function noStoreHeaders(): HeadersInit {
|
||||
return {
|
||||
"Cache-Control": "no-store",
|
||||
};
|
||||
}
|
||||
|
||||
function getElevenLabsConfig(): {
|
||||
apiKey: string;
|
||||
voiceId: string;
|
||||
modelId: string;
|
||||
} | null {
|
||||
const env = process.env;
|
||||
const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? "";
|
||||
const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? "";
|
||||
if (!apiKey || !voiceId) return null;
|
||||
return {
|
||||
apiKey,
|
||||
modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5",
|
||||
voiceId,
|
||||
};
|
||||
}
|
||||
|
||||
function getPromptText(request: Request): string | null {
|
||||
const url = new URL(request.url);
|
||||
const text = url.searchParams.get("text")?.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a spoken prompt with ElevenLabs and return audio bytes.
|
||||
*
|
||||
* Purpose: feed Twilio `<Play>` with a consistent prompt voice when the IVR is
|
||||
* configured to use ElevenLabs.
|
||||
*
|
||||
* Consumer: Twilio IVR TwiML route as an audio asset source.
|
||||
*/
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const config = getElevenLabsConfig();
|
||||
const text = getPromptText(request);
|
||||
|
||||
if (!config || !text) {
|
||||
return new Response("ElevenLabs prompt synthesis is not configured.", {
|
||||
status: 503,
|
||||
headers: noStoreHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(config.voiceId)}?output_format=mp3_44100_128`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"xi-api-key": config.apiKey,
|
||||
Accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: config.modelId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "");
|
||||
return new Response(message || "ElevenLabs prompt synthesis failed.", {
|
||||
status: response.status,
|
||||
headers: noStoreHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": response.headers.get("content-type") || "audio/mpeg",
|
||||
},
|
||||
});
|
||||
}
|
||||
181
web/app/api/voice/route.ts
Normal file
181
web/app/api/voice/route.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* 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,
|
||||
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(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, getRoutingConfig());
|
||||
return new Response(responseXml, {
|
||||
headers: noStoreHeaders(),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue