import type { ConfigFileCache } from "../../config-file-cache.js";
import type { GatewayConfig } from "../../config.js";
import { credentialKey } from "../../credential-key.js";
import { getLogger } from "../../logger.js";
import { resolveAdmissionPolicy } from "../../risk/admission-policy-cache.js";
import {
CircuitBreakerOpenError,
forwardTwilioVoiceWebhook,
resolvePublicBaseWssUrl,
} from "../../runtime/client.js";
import {
resolveAssistant,
resolveAssistantByPhoneNumber,
isRejection,
} from "../../routing/resolve-assistant.js";
import {
validateTwilioWebhookRequest,
type TwilioValidationCaches,
} from "../../twilio/validate-webhook.js";
import {
findPendingPhoneSession,
gatherVerificationTwiml,
} from "../../voice/verification.js";
import { ContactStore } from "../../db/contact-store.js";
const log = getLogger("twilio-voice-webhook");
/** TwiML that rejects the call — Twilio plays a busy signal and hangs up. */
const REJECT_TWIML =
'';
const TWIML_HEADERS = { "Content-Type": "text/xml" };
/** Escapes XML special characters so contact display names are safe to embed in TwiML. */
function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export function createTwilioVoiceWebhookHandler(
config: GatewayConfig,
caches?: TwilioValidationCaches & { configFile?: ConfigFileCache },
) {
return async (req: Request): Promise => {
const validation = await validateTwilioWebhookRequest(req, config, caches);
if (validation instanceof Response) return validation;
const { params } = validation;
log.info({ callSid: params.CallSid }, "Twilio voice webhook received");
// For inbound calls (no callSessionId in the URL), resolve the assistant
// by the "To" phone number, then fall through to the standard routing
// chain (defaultAssistantId / unmapped policy).
const url = new URL(req.url);
const hasCallSessionId = !!url.searchParams.get("callSessionId");
let assistantId: string | undefined;
if (!hasCallSessionId) {
// `no_one` kill switch — mirrors handle-inbound.ts. Only inbound is
// gated; outbound assistant-initiated calls are never kill-switched.
const phonePolicy = resolveAdmissionPolicy("phone");
if (phonePolicy === "no_one") {
log.info(
{ callSid: params.CallSid, from: params.From, to: params.To },
"Inbound voice call hard-denied by admission policy 'no_one'",
);
return new Response(REJECT_TWIML, {
status: 200,
headers: TWIML_HEADERS,
});
}
const phoneRouting = params.To
? resolveAssistantByPhoneNumber(config, params.To, caches?.configFile)
: undefined;
if (phoneRouting && "assistantId" in phoneRouting) {
assistantId = phoneRouting.assistantId;
log.info(
{ assistantId, toNumber: params.To },
"Resolved assistant by phone number for inbound call",
);
} else {
// Phone-number lookup missed — fall through to standard routing so
// defaultAssistantId / unmapped policy is respected, instead of
// silently forwarding with no assistant ID.
const fallbackRouting = resolveAssistant(
config,
params.From,
params.From,
);
if (isRejection(fallbackRouting)) {
// A caller-ID-withheld (anonymous) inbound call has no `From` to
// route or trust-classify on, so resolveAssistant fail-closes on the
// missing identity. Voice lines are intentionally open, though, so
// keep answering an anonymous caller on an unmapped line via the
// default assistant when the unmapped policy allows it.
if (
!params.From &&
config.unmappedPolicy === "default" &&
config.defaultAssistantId
) {
assistantId = config.defaultAssistantId;
log.info(
{ to: params.To },
"Anonymous inbound call routed to the default assistant",
);
} else {
log.warn(
{
from: params.From,
to: params.To,
reason: fallbackRouting.reason,
},
"Inbound voice call rejected by routing — no phone number match and unmapped policy rejects",
);
return new Response(REJECT_TWIML, {
status: 200,
headers: TWIML_HEADERS,
});
}
} else {
assistantId = fallbackRouting.assistantId;
log.info(
{
assistantId,
routeSource: fallbackRouting.routeSource,
from: params.From,
},
"Resolved assistant via fallback routing for inbound call",
);
}
}
// ── Gateway-owned voice verification ────────────────────────────
// For inbound calls, check if there's a pending phone verification
// session. If so, intercept the call with a TwiML flow
// instead of forwarding to the assistant. The assistant never
// touches verification — it only receives verified calls.
try {
const pendingSession = await findPendingPhoneSession();
if (pendingSession) {
log.info(
{
callSid: params.CallSid,
fromNumber: params.From,
sessionId: pendingSession.id,
},
"Pending phone verification session found — intercepting with gateway verification",
);
const verifyCallbackPath = `/webhooks/twilio/voice-verify?attempt=0`;
const codeDigits = pendingSession.codeDigits ?? 6;
return new Response(
gatherVerificationTwiml(verifyCallbackPath, 0, codeDigits),
{ status: 200, headers: TWIML_HEADERS },
);
}
} catch (err) {
log.warn(
{ err, callSid: params.CallSid },
"Failed to check pending verification session — falling through to assistant",
);
}
// ── Known-but-unverified caller guidance ─────────────────────────────
// If the caller's number is registered under a contact's phone channel
// but has not yet passed DTMF verification, intercept with a helpful
// message rather than letting the runtime treat them as an unknown caller.
if (params.From) {
try {
const callerRecord = new ContactStore().getContactByPhoneNumber(
params.From,
);
// Only intercept genuinely unverified channels — not blocked ones.
// A blocked caller should fall through to the runtime's deny path
// rather than hearing a helpful verification script (which would
// both leak the contact name and weaken block semantics).
// The display name is intentionally included: the caller registered
// this number themselves, so disclosing their own name is expected.
const unverifiedStatuses = new Set(["unverified", "pending"]);
if (
callerRecord &&
unverifiedStatuses.has(callerRecord.channel.status)
) {
const isGuardian = callerRecord.contact.role === "guardian";
log.info(
{
callSid: params.CallSid,
contactId: callerRecord.contact.id,
channelStatus: callerRecord.channel.status,
isGuardian,
},
"Known-but-unverified caller — returning verification guidance TwiML",
);
const name = escapeXml(callerRecord.contact.displayName);
// Conditional guidance: only the guardian has direct access to the
// assistant's contacts page; other contacts must ask the guardian
// to (re)start a verification session for them.
const action = isGuardian
? `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
`and follow the prompts. Then call back once the verification session is active.`
: `Please reach out to the account guardian to start a new verification session, ` +
`then call back once the verification session is active.`;
const twiml =
`` +
`This number is registered as ${name}'s phone but has not been verified yet. ` +
`${action}` +
``;
return new Response(twiml, { status: 200, headers: TWIML_HEADERS });
}
} catch (err) {
log.warn(
{ err, callSid: params.CallSid },
"Failed to check unverified caller — falling through to assistant",
);
}
}
}
try {
const platformAssistantId = (
await caches?.credentials?.get(
credentialKey("vellum", "platform_assistant_id"),
)
)?.trim();
const runtimeResponse = await forwardTwilioVoiceWebhook(
config,
params,
req.url,
resolvePublicBaseWssUrl(
config,
caches?.configFile,
platformAssistantId,
validation.validatedCandidateUrl,
),
);
return new Response(runtimeResponse.body, {
status: runtimeResponse.status,
headers: runtimeResponse.headers,
});
} catch (err) {
if (err instanceof CircuitBreakerOpenError) {
return Response.json(
{ error: "Service temporarily unavailable" },
{
status: 503,
headers: { "Retry-After": String(err.retryAfterSecs) },
},
);
}
log.error({ err }, "Failed to forward Twilio voice webhook to runtime");
return Response.json({ error: "Internal server error" }, { status: 502 });
}
};
}