diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f71c204a7..c859bd15fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps. - Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582) - Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709. - Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim. diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 4ac92b0fd8a..075aa00bd6e 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -668,6 +668,12 @@ space, because the carrier cannot call back into those addresses. Do not use `localhost`, `127.0.0.1`, `0.0.0.0`, `10.x`, `172.16.x`-`172.31.x`, `192.168.x`, `169.254.x`, `fc00::/7`, or `fd00::/8` as `publicUrl`. +Twilio notify-mode outbound calls send their initial `` TwiML directly in +the create-call request, so the first spoken message does not depend on Twilio +fetching webhook TwiML. A public webhook is still required for status callbacks, +conversation calls, pre-connect DTMF, realtime streams, and post-connect call +control. + Use one public exposure path: ```json5 diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index a024ba532ef..14c8df5b7ac 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -54,8 +54,8 @@ type TwilioApiRequest = ( options?: { allowNotFound?: boolean }, ) => Promise; -function createApiRequestMock() { - return vi.fn(async () => ({})); +function createApiRequestMock(impl?: TwilioApiRequest) { + return vi.fn(impl ?? (async () => ({}))); } function createTwilioCallStateRaceError(): TwilioApiError { @@ -88,6 +88,63 @@ function configureTelephonyTwiMlFallback(params: { providerCallId: string; strea } describe("TwilioProvider", () => { + it("sends direct initial TwiML for notify-mode outbound calls", async () => { + const provider = createProvider(); + const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" })); + ( + provider as unknown as { + apiRequest: TwilioApiRequest; + } + ).apiRequest = apiRequest; + + const result = await provider.initiateCall({ + callId: "call-1", + from: "+14155550100", + to: "+14155550123", + webhookUrl: "https://example.ngrok.app/voice/webhook", + inlineTwiml: "Hello", + }); + + expect(result).toEqual({ providerCallId: "CA123", status: "queued" }); + expect(apiRequest).toHaveBeenCalledWith( + "/Calls.json", + expect.objectContaining({ + To: "+14155550123", + From: "+14155550100", + Twiml: "Hello", + StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status", + StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"], + }), + ); + expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Url"); + }); + + it("uses the webhook URL for conversation outbound calls", async () => { + const provider = createProvider(); + const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" })); + ( + provider as unknown as { + apiRequest: TwilioApiRequest; + } + ).apiRequest = apiRequest; + + await provider.initiateCall({ + callId: "call-1", + from: "+14155550100", + to: "+14155550123", + webhookUrl: "https://example.ngrok.app/voice/webhook", + }); + + expect(apiRequest).toHaveBeenCalledWith( + "/Calls.json", + expect.objectContaining({ + Url: "https://example.ngrok.app/voice/webhook?callId=call-1", + StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status", + }), + ); + expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Twiml"); + }); + it("returns streaming TwiML for outbound conversation calls before in-progress", () => { const provider = createProvider(); const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", { diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 305425b4752..67fbaec24aa 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -537,8 +537,8 @@ export class TwilioProvider implements VoiceCallProvider { /** * Initiate an outbound call via Twilio API. - * If inlineTwiml or preConnectTwiml is provided, the first webhook request - * receives that TwiML before normal dynamic TwiML resumes. + * If preConnectTwiml is provided, the first webhook request receives that + * TwiML before normal dynamic TwiML resumes. */ async initiateCall(input: InitiateCallInput): Promise { const url = new URL(input.webhookUrl); @@ -549,32 +549,30 @@ export class TwilioProvider implements VoiceCallProvider { statusUrl.searchParams.set("callId", input.callId); statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests - // Store TwiML content if provided (for notify mode) - // We now serve it from the webhook endpoint instead of sending inline - if (input.inlineTwiml) { - this.twimlStorage.set(input.callId, input.inlineTwiml); - this.notifyCalls.add(input.callId); - console.log( - `[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=notify)`, - ); - } else if (input.preConnectTwiml) { + if (!input.inlineTwiml && input.preConnectTwiml) { this.twimlStorage.set(input.callId, input.preConnectTwiml); console.log( `[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=pre-connect)`, ); } - // Build request params - always use URL-based TwiML. - // Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter. const params: Record = { To: input.to, From: input.from, - Url: url.toString(), // TwiML serving endpoint - StatusCallback: statusUrl.toString(), // Separate status callback endpoint + StatusCallback: statusUrl.toString(), StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"], Timeout: "30", }; + if (input.inlineTwiml) { + params.Twiml = input.inlineTwiml; + console.log( + `[voice-call] Sending direct Twilio initial TwiML for call ${input.callId} (kind=notify)`, + ); + } else { + params.Url = url.toString(); + } + const result = await this.apiRequest("/Calls.json", params); this.callWebhookUrls.set(result.sid, url.toString()); diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index ea3b76dd33f..c5a333d49cc 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -211,7 +211,7 @@ export type InitiateCallInput = { to: string; webhookUrl: string; clientState?: Record; - /** Inline TwiML to execute (skips webhook, used for notify mode) */ + /** Inline TwiML to execute without fetching webhook TwiML. */ inlineTwiml?: string; /** TwiML to serve once before normal webhook-driven call handling resumes. */ preConnectTwiml?: string;