mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 04:50:35 +00:00
refactor(voice-call): extract twilio twiml policy and status mapping
This commit is contained in:
@@ -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