mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 08:32:00 +00:00
refactor(voice-call): extract twilio twiml policy and status mapping
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
23
extensions/voice-call/src/providers/shared/call-status.ts
Normal file
23
extensions/voice-call/src/providers/shared/call-status.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
91
extensions/voice-call/src/providers/twilio/twiml-policy.ts
Normal file
91
extensions/voice-call/src/providers/twilio/twiml-policy.ts
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user