From a96b3b406a9c831384451a7f6b54d32bed0fabff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 00:29:00 +0000 Subject: [PATCH] refactor(voice-call): extract twilio twiml policy and status mapping --- .../src/providers/shared/call-status.test.ts | 24 +++ .../src/providers/shared/call-status.ts | 23 +++ extensions/voice-call/src/providers/twilio.ts | 142 +++++++----------- .../src/providers/twilio/twiml-policy.test.ts | 84 +++++++++++ .../src/providers/twilio/twiml-policy.ts | 91 +++++++++++ 5 files changed, 280 insertions(+), 84 deletions(-) create mode 100644 extensions/voice-call/src/providers/shared/call-status.test.ts create mode 100644 extensions/voice-call/src/providers/shared/call-status.ts create mode 100644 extensions/voice-call/src/providers/twilio/twiml-policy.test.ts create mode 100644 extensions/voice-call/src/providers/twilio/twiml-policy.ts diff --git a/extensions/voice-call/src/providers/shared/call-status.test.ts b/extensions/voice-call/src/providers/shared/call-status.test.ts new file mode 100644 index 00000000000..8bce2b2b360 --- /dev/null +++ b/extensions/voice-call/src/providers/shared/call-status.test.ts @@ -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); + }); +}); diff --git a/extensions/voice-call/src/providers/shared/call-status.ts b/extensions/voice-call/src/providers/shared/call-status.ts new file mode 100644 index 00000000000..c6376993491 --- /dev/null +++ b/extensions/voice-call/src/providers/shared/call-status.ts @@ -0,0 +1,23 @@ +import type { EndReason } from "../../types.js"; + +const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record = { + 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; +} diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 58ddc073273..e09367eb3fa 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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 { - 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 }; diff --git a/extensions/voice-call/src/providers/twilio/twiml-policy.test.ts b/extensions/voice-call/src/providers/twilio/twiml-policy.test.ts new file mode 100644 index 00000000000..eb8d69b4cb1 --- /dev/null +++ b/extensions/voice-call/src/providers/twilio/twiml-policy.test.ts @@ -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"); + }); +}); diff --git a/extensions/voice-call/src/providers/twilio/twiml-policy.ts b/extensions/voice-call/src/providers/twilio/twiml-policy.ts new file mode 100644 index 00000000000..21755166ffc --- /dev/null +++ b/extensions/voice-call/src/providers/twilio/twiml-policy.ts @@ -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" }; +}