refactor(voice-call): extract twilio twiml policy and status mapping

This commit is contained in:
Peter Steinberger
2026-03-03 00:29:00 +00:00
parent 68e982ec80
commit a96b3b406a
5 changed files with 280 additions and 84 deletions

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
isProviderStatusTerminal,
mapProviderStatusToEndReason,
normalizeProviderStatus,
} from "./call-status.js";
describe("provider call status mapping", () => {
it("normalizes missing statuses to unknown", () => {
expect(normalizeProviderStatus(undefined)).toBe("unknown");
expect(normalizeProviderStatus(" ")).toBe("unknown");
});
it("maps terminal provider statuses to end reasons", () => {
expect(mapProviderStatusToEndReason("completed")).toBe("completed");
expect(mapProviderStatusToEndReason("CANCELED")).toBe("hangup-bot");
expect(mapProviderStatusToEndReason("no-answer")).toBe("no-answer");
});
it("flags terminal provider statuses", () => {
expect(isProviderStatusTerminal("busy")).toBe(true);
expect(isProviderStatusTerminal("in-progress")).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
import type { EndReason } from "../../types.js";
const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
completed: "completed",
failed: "failed",
busy: "busy",
"no-answer": "no-answer",
canceled: "hangup-bot",
};
export function normalizeProviderStatus(status: string | null | undefined): string {
const normalized = status?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : "unknown";
}
export function mapProviderStatusToEndReason(status: string | null | undefined): EndReason | null {
const normalized = normalizeProviderStatus(status);
return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null;
}
export function isProviderStatusTerminal(status: string | null | undefined): boolean {
return mapProviderStatusToEndReason(status) !== null;
}

View File

@@ -21,8 +21,14 @@ import type {
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import type { VoiceCallProvider } from "./base.js";
import {
isProviderStatusTerminal,
mapProviderStatusToEndReason,
normalizeProviderStatus,
} from "./shared/call-status.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
import { twilioApiRequest } from "./twilio/api.js";
import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
@@ -327,34 +333,28 @@ export class TwilioProvider implements VoiceCallProvider {
}
// Handle call status changes
const callStatus = params.get("CallStatus");
switch (callStatus) {
case "initiated":
return { ...baseEvent, type: "call.initiated" };
case "ringing":
return { ...baseEvent, type: "call.ringing" };
case "in-progress":
return { ...baseEvent, type: "call.answered" };
case "completed":
case "busy":
case "no-answer":
case "failed":
this.streamAuthTokens.delete(callSid);
this.activeStreamCalls.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled":
this.streamAuthTokens.delete(callSid);
this.activeStreamCalls.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
default:
return null;
const callStatus = normalizeProviderStatus(params.get("CallStatus"));
if (callStatus === "initiated") {
return { ...baseEvent, type: "call.initiated" };
}
if (callStatus === "ringing") {
return { ...baseEvent, type: "call.ringing" };
}
if (callStatus === "in-progress") {
return { ...baseEvent, type: "call.answered" };
}
const endReason = mapProviderStatusToEndReason(callStatus);
if (endReason) {
this.streamAuthTokens.delete(callSid);
this.activeStreamCalls.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: endReason };
}
return null;
}
private static readonly EMPTY_TWIML =
@@ -380,65 +380,40 @@ export class TwilioProvider implements VoiceCallProvider {
return TwilioProvider.EMPTY_TWIML;
}
const params = new URLSearchParams(ctx.rawBody);
const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
const isStatusCallback = type === "status";
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const isOutbound = direction?.startsWith("outbound") ?? false;
const callSid = params.get("CallSid") || undefined;
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
const view = readTwimlRequestView(ctx);
const storedTwiml = view.callIdFromQuery
? this.twimlStorage.get(view.callIdFromQuery)
: undefined;
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: Boolean(storedTwiml),
isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
hasActiveStreams: this.activeStreamCalls.size > 0,
canStream: Boolean(view.callSid && this.getStreamUrl()),
});
// Avoid logging webhook params/TwiML (may contain PII).
if (decision.consumeStoredTwimlCallId) {
this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
}
if (decision.activateStreamCallSid) {
this.activeStreamCalls.add(decision.activateStreamCallSid);
}
// Handle initial TwiML request (when Twilio first initiates the call)
// Check if we have stored TwiML for this call (notify mode)
if (callIdFromQuery && !isStatusCallback) {
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
if (storedTwiml) {
// Clean up after serving (one-time use)
this.deleteStoredTwiml(callIdFromQuery);
return storedTwiml;
}
if (this.notifyCalls.has(callIdFromQuery)) {
return TwilioProvider.EMPTY_TWIML;
}
// Conversation mode: return streaming TwiML immediately for outbound calls.
if (isOutbound) {
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
switch (decision.kind) {
case "stored":
return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
case "queue":
return TwilioProvider.QUEUE_TWIML;
case "pause":
return TwilioProvider.PAUSE_TWIML;
case "stream": {
const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
case "empty":
default:
return TwilioProvider.EMPTY_TWIML;
}
// Status callbacks should not receive TwiML.
if (isStatusCallback) {
return TwilioProvider.EMPTY_TWIML;
}
// Handle subsequent webhook requests (status callbacks, etc.)
// For inbound calls, answer immediately with stream
if (direction === "inbound") {
if (this.activeStreamCalls.size > 0) {
return TwilioProvider.QUEUE_TWIML;
}
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
if (streamUrl && callSid) {
this.activeStreamCalls.add(callSid);
}
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
// For outbound calls, only connect to stream when call is in-progress
if (callStatus !== "in-progress") {
return TwilioProvider.EMPTY_TWIML;
}
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
/**
@@ -693,7 +668,6 @@ export class TwilioProvider implements VoiceCallProvider {
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set(["completed", "failed", "busy", "no-answer", "canceled"]);
try {
const data = await guardedJsonApiRequest<{ status?: string }>({
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
@@ -711,8 +685,8 @@ export class TwilioProvider implements VoiceCallProvider {
return { status: "not-found", isTerminal: true };
}
const status = data.status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
const status = normalizeProviderStatus(data.status);
return { status, isTerminal: isProviderStatusTerminal(status) };
} catch {
// Transient error — keep the call and rely on timer fallback
return { status: "error", isTerminal: false, isUnknown: true };

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../../types.js";
import { decideTwimlResponse, readTwimlRequestView } from "./twiml-policy.js";
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext {
return {
headers: {},
rawBody,
url: "https://example.ngrok.app/voice/twilio",
method: "POST",
query,
};
}
describe("twiml policy", () => {
it("returns stored twiml decision for initial notify callback", () => {
const view = readTwimlRequestView(
createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
callId: "call-1",
}),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: true,
isNotifyCall: true,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("stored");
});
it("returns queue for inbound when another stream is active", () => {
const view = readTwimlRequestView(
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456"),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: true,
canStream: true,
});
expect(decision.kind).toBe("queue");
});
it("returns stream + activation for inbound call when available", () => {
const view = readTwimlRequestView(
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA789"),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("stream");
expect(decision.activateStreamCallSid).toBe("CA789");
});
it("returns empty for status callbacks", () => {
const view = readTwimlRequestView(
createContext("CallStatus=completed&Direction=inbound&CallSid=CA123", {
type: "status",
}),
);
const decision = decideTwimlResponse({
...view,
hasStoredTwiml: false,
isNotifyCall: false,
hasActiveStreams: false,
canStream: true,
});
expect(decision.kind).toBe("empty");
});
});

View File

@@ -0,0 +1,91 @@
import type { WebhookContext } from "../../types.js";
export type TwimlResponseKind = "empty" | "pause" | "queue" | "stored" | "stream";
export type TwimlRequestView = {
callStatus: string | null;
direction: string | null;
isStatusCallback: boolean;
callSid?: string;
callIdFromQuery?: string;
};
export type TwimlPolicyInput = TwimlRequestView & {
hasStoredTwiml: boolean;
isNotifyCall: boolean;
hasActiveStreams: boolean;
canStream: boolean;
};
export type TwimlDecision =
| {
kind: "empty" | "pause" | "queue";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
}
| {
kind: "stored";
consumeStoredTwimlCallId: string;
activateStreamCallSid?: string;
}
| {
kind: "stream";
consumeStoredTwimlCallId?: string;
activateStreamCallSid?: string;
};
function isOutboundDirection(direction: string | null): boolean {
return direction?.startsWith("outbound") ?? false;
}
export function readTwimlRequestView(ctx: WebhookContext): TwimlRequestView {
const params = new URLSearchParams(ctx.rawBody);
const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
return {
callStatus: params.get("CallStatus"),
direction: params.get("Direction"),
isStatusCallback: type === "status",
callSid: params.get("CallSid") || undefined,
callIdFromQuery,
};
}
export function decideTwimlResponse(input: TwimlPolicyInput): TwimlDecision {
if (input.callIdFromQuery && !input.isStatusCallback) {
if (input.hasStoredTwiml) {
return { kind: "stored", consumeStoredTwimlCallId: input.callIdFromQuery };
}
if (input.isNotifyCall) {
return { kind: "empty" };
}
if (isOutboundDirection(input.direction)) {
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}
}
if (input.isStatusCallback) {
return { kind: "empty" };
}
if (input.direction === "inbound") {
if (input.hasActiveStreams) {
return { kind: "queue" };
}
if (input.canStream && input.callSid) {
return { kind: "stream", activateStreamCallSid: input.callSid };
}
return { kind: "pause" };
}
if (input.callStatus !== "in-progress") {
return { kind: "empty" };
}
return input.canStream ? { kind: "stream" } : { kind: "pause" };
}