fix: return TwiML for outbound conversation calls
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
|
|
||||||
## 2026.1.23-1
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
|||||||
64
extensions/voice-call/src/providers/twilio.test.ts
Normal file
64
extensions/voice-call/src/providers/twilio.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { WebhookContext } from "../types.js";
|
||||||
|
import { TwilioProvider } from "./twilio.js";
|
||||||
|
|
||||||
|
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
|
||||||
|
|
||||||
|
function createProvider(): TwilioProvider {
|
||||||
|
return new TwilioProvider(
|
||||||
|
{ accountSid: "AC123", authToken: "secret" },
|
||||||
|
{ publicUrl: "https://example.ngrok.app", streamPath: "/voice/stream" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(
|
||||||
|
rawBody: string,
|
||||||
|
query?: WebhookContext["query"],
|
||||||
|
): WebhookContext {
|
||||||
|
return {
|
||||||
|
headers: {},
|
||||||
|
rawBody,
|
||||||
|
url: "https://example.ngrok.app/voice/twilio",
|
||||||
|
method: "POST",
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TwilioProvider", () => {
|
||||||
|
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
|
||||||
|
const provider = createProvider();
|
||||||
|
const ctx = createContext("CallStatus=initiated&Direction=outbound-api", {
|
||||||
|
callId: "call-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = provider.parseWebhookEvent(ctx);
|
||||||
|
|
||||||
|
expect(result.providerResponseBody).toContain(STREAM_URL);
|
||||||
|
expect(result.providerResponseBody).toContain("<Connect>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty TwiML for status callbacks", () => {
|
||||||
|
const provider = createProvider();
|
||||||
|
const ctx = createContext("CallStatus=ringing&Direction=outbound-api", {
|
||||||
|
callId: "call-1",
|
||||||
|
type: "status",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = provider.parseWebhookEvent(ctx);
|
||||||
|
|
||||||
|
expect(result.providerResponseBody).toBe(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns streaming TwiML for inbound calls", () => {
|
||||||
|
const provider = createProvider();
|
||||||
|
const ctx = createContext("CallStatus=ringing&Direction=inbound");
|
||||||
|
|
||||||
|
const result = provider.parseWebhookEvent(ctx);
|
||||||
|
|
||||||
|
expect(result.providerResponseBody).toContain(STREAM_URL);
|
||||||
|
expect(result.providerResponseBody).toContain("<Connect>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -294,6 +294,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const isStatusCallback = type === "status";
|
const isStatusCallback = type === "status";
|
||||||
const callStatus = params.get("CallStatus");
|
const callStatus = params.get("CallStatus");
|
||||||
const direction = params.get("Direction");
|
const direction = params.get("Direction");
|
||||||
|
const isOutbound = direction?.startsWith("outbound") ?? false;
|
||||||
const callIdFromQuery =
|
const callIdFromQuery =
|
||||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||||
? ctx.query.callId.trim()
|
? ctx.query.callId.trim()
|
||||||
@@ -313,6 +314,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
if (this.notifyCalls.has(callIdFromQuery)) {
|
if (this.notifyCalls.has(callIdFromQuery)) {
|
||||||
return TwilioProvider.EMPTY_TWIML;
|
return TwilioProvider.EMPTY_TWIML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conversation mode: return streaming TwiML immediately for outbound calls.
|
||||||
|
if (isOutbound) {
|
||||||
|
const streamUrl = this.getStreamUrl();
|
||||||
|
return streamUrl
|
||||||
|
? this.getStreamConnectXml(streamUrl)
|
||||||
|
: TwilioProvider.PAUSE_TWIML;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status callbacks should not receive TwiML.
|
// Status callbacks should not receive TwiML.
|
||||||
|
|||||||
Reference in New Issue
Block a user