diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d397042a5..a790c92cab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs before joins. Thanks @donkeykong91 and @PfanP. +- Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22. - Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP. - Voice Call CLI: delegate operational `voicecall` commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and `setup --json` hangs. Fixes #72345. Thanks @serrurco and @DougButdorf. - Agents/pi-embedded-runner: extract the `abortable` provider-call wrapper from `runEmbeddedAttempt` to module scope so its promise handlers no longer close over the run lexical context, releasing transcripts, tool buffers, and subscription callbacks when a provider call hangs past abort. (#74182) Thanks @cjboy007. diff --git a/extensions/voice-call/src/manager.notify.test.ts b/extensions/voice-call/src/manager.notify.test.ts index 7bc1bee7469..2c2c5cf7c03 100644 --- a/extensions/voice-call/src/manager.notify.test.ts +++ b/extensions/voice-call/src/manager.notify.test.ts @@ -37,6 +37,15 @@ class DelayedPlayTtsProvider extends FakeProvider { } } +class FailStartListeningProvider extends FakeProvider { + override async startListening( + input: Parameters[0], + ): Promise { + this.startListeningCalls.push(input); + throw new Error("synthetic start listening failure"); + } +} + function requireCall( manager: Awaited>["manager"], callId: string, @@ -266,6 +275,37 @@ describe("CallManager notify and mapping", () => { expect(requireCall(manager, callId).state).toBe("listening"); }); + it("logs fire-and-forget initial-message failures instead of leaking unhandled rejections", async () => { + const provider = new FailStartListeningProvider("twilio"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { manager } = await createManagerHarness({ streaming: { enabled: false } }, provider); + + const callId = await initiateCallWithMessage( + manager, + "+15550000013", + "Twilio hello", + "conversation", + ); + await answerCall(manager, callId, "evt-initial-message-start-listening-fails"); + + expectFirstPlayTtsText(provider, "Twilio hello"); + expect(provider.startListeningCalls).toEqual([ + expect.objectContaining({ + callId, + providerCallId: "call-uuid", + }), + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + `[voice-call] Failed to speak initial message for call ${callId}: synthetic start listening failure`, + ), + ); + } finally { + warn.mockRestore(); + } + }); + it("preserves initialMessage after a failed first playback and retries on next trigger", async () => { const provider = new FailFirstPlayTtsProvider("plivo"); const { manager } = await createManagerHarness({}, provider); diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index 93ab5dae7e5..fe4f511696c 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CallManagerContext } from "./manager/context.js"; @@ -350,7 +351,11 @@ export class CallManager { return; } - void this.speakInitialMessage(call.providerCallId); + void this.speakInitialMessage(call.providerCallId).catch((err) => { + console.warn( + `[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`, + ); + }); } /** diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index ec1f7793015..535f0921eaa 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { WebhookContext } from "../types.js"; import { TwilioProvider } from "./twilio.js"; +import { TwilioApiError } from "./twilio/api.js"; const STREAM_URL = "wss://example.ngrok.app/voice/stream"; @@ -57,6 +58,16 @@ function createApiRequestMock() { return vi.fn(async () => ({})); } +function createTwilioCallStateRaceError(): TwilioApiError { + return new TwilioApiError( + 400, + JSON.stringify({ + code: 21220, + message: "Call is not in-progress. Cannot redirect.", + }), + ); +} + function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) { const provider = createProvider(); const apiRequest = createApiRequestMock(); @@ -280,6 +291,38 @@ describe("TwilioProvider", () => { expect(params.Twiml).toContain(" { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { provider, apiRequest } = configureTelephonyTwiMlFallback({ + providerCallId: "CA-race-play", + }); + apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({}); + + const playback = provider.playTts({ + callId: "call-race-play", + providerCallId: "CA-race-play", + text: "Hello after race", + }); + await Promise.resolve(); + expect(apiRequest).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(250); + await expect(playback).resolves.toBeUndefined(); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-play.json"); + expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-play.json"); + expect(warn).toHaveBeenCalledWith( + "[voice-call] Twilio playTts update hit call state race (21220); retrying in 250ms", + ); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); + it("sends DTMF by updating the call and redirecting back to the webhook", async () => { const { provider, apiRequest } = configureTelephonyTwiMlFallback({ providerCallId: "CA-dtmf", @@ -303,6 +346,37 @@ describe("TwilioProvider", () => { expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio"); }); + it("retries startListening when Twilio briefly rejects a live-call update as not in progress", async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { provider, apiRequest } = configureTelephonyTwiMlFallback({ + providerCallId: "CA-race-listen", + }); + apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({}); + + const listening = provider.startListening({ + callId: "call-race-listen", + providerCallId: "CA-race-listen", + }); + await Promise.resolve(); + expect(apiRequest).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(250); + await expect(listening).resolves.toBeUndefined(); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-listen.json"); + expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-listen.json"); + expect(warn).toHaveBeenCalledWith( + "[voice-call] Twilio startListening update hit call state race (21220); retrying in 250ms", + ); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); + it("ignores stale stream unregister requests that do not match current stream SID", () => { const provider = createProvider(); provider.registerCallStream("CA-reconnect", "MZ-new"); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 6fe360eabb4..cde782fd802 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { setTimeout as sleep } from "node:timers/promises"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { TwilioConfig } from "../config.js"; @@ -31,11 +32,18 @@ import { } from "./shared/call-status.js"; import { guardedJsonApiRequest } from "./shared/guarded-json-api.js"; import type { TwilioProviderOptions } from "./twilio.types.js"; -import { twilioApiRequest } from "./twilio/api.js"; +import { TwilioApiError, twilioApiRequest } from "./twilio/api.js"; import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; export type { TwilioProviderOptions } from "./twilio.types.js"; +const TWILIO_CALL_NOT_IN_PROGRESS_CODE = 21220; +const TWILIO_CALL_UPDATE_RETRY_DELAYS_MS = [250, 750] as const; + +function isTwilioCallNotInProgressError(err: unknown): boolean { + return err instanceof TwilioApiError && err.twilioCode === TWILIO_CALL_NOT_IN_PROGRESS_CODE; +} + function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string { if (verifiedRequestKey) { return verifiedRequestKey; @@ -220,6 +228,30 @@ export class TwilioProvider implements VoiceCallProvider { }); } + private async updateLiveCallTwiml( + providerCallId: string, + twiml: string, + operation: string, + ): Promise { + let retryIndex = 0; + while (true) { + try { + await this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml }); + return; + } catch (err) { + const retryDelayMs = TWILIO_CALL_UPDATE_RETRY_DELAYS_MS[retryIndex]; + if (retryDelayMs === undefined || !isTwilioCallNotInProgressError(err)) { + throw err; + } + retryIndex += 1; + console.warn( + `[voice-call] Twilio ${operation} update hit call state race (21220); retrying in ${retryDelayMs}ms`, + ); + await sleep(retryDelayMs); + } + } + } + /** * Verify Twilio webhook signature using HMAC-SHA1. * @@ -589,9 +621,7 @@ export class TwilioProvider implements VoiceCallProvider { `; - await this.apiRequest(`/Calls/${input.providerCallId}.json`, { - Twiml: twiml, - }); + await this.updateLiveCallTwiml(input.providerCallId, twiml, "playTts"); } async sendDtmf(input: SendDtmfInput): Promise { @@ -606,9 +636,7 @@ export class TwilioProvider implements VoiceCallProvider { ${escapeXml(webhookUrl)} `; - await this.apiRequest(`/Calls/${input.providerCallId}.json`, { - Twiml: twiml, - }); + await this.updateLiveCallTwiml(input.providerCallId, twiml, "sendDtmf"); } /** @@ -754,9 +782,7 @@ export class TwilioProvider implements VoiceCallProvider { `; - await this.apiRequest(`/Calls/${input.providerCallId}.json`, { - Twiml: twiml, - }); + await this.updateLiveCallTwiml(input.providerCallId, twiml, "startListening"); } /** diff --git a/extensions/voice-call/src/providers/twilio/api.test.ts b/extensions/voice-call/src/providers/twilio/api.test.ts index 77b159910d8..33efc4a5cfb 100644 --- a/extensions/voice-call/src/providers/twilio/api.test.ts +++ b/extensions/voice-call/src/providers/twilio/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { twilioApiRequest } from "./api.js"; +import { TwilioApiError, twilioApiRequest } from "./api.js"; const originalFetch = globalThis.fetch; @@ -90,4 +90,32 @@ describe("twilioApiRequest", () => { }), ).rejects.toThrow("Twilio API error: 400 bad request"); }); + + it("exposes structured Twilio error codes from json error bodies", async () => { + globalThis.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + code: 21220, + message: "Call is not in-progress. Cannot redirect.", + }), + { status: 400 }, + ), + ) as unknown as typeof fetch; + + await expect( + twilioApiRequest({ + baseUrl: "https://api.twilio.com", + accountSid: "AC123", + authToken: "secret", + endpoint: "/Calls/CA123.json", + body: {}, + }), + ).rejects.toMatchObject({ + name: "TwilioApiError", + httpStatus: 400, + twilioCode: 21220, + message: "Twilio API error: 400 Call is not in-progress. Cannot redirect.", + } satisfies Partial); + }); }); diff --git a/extensions/voice-call/src/providers/twilio/api.ts b/extensions/voice-call/src/providers/twilio/api.ts index 15614433648..24b17aac622 100644 --- a/extensions/voice-call/src/providers/twilio/api.ts +++ b/extensions/voice-call/src/providers/twilio/api.ts @@ -1,3 +1,40 @@ +type ParsedTwilioApiError = { + code?: number; + message?: string; +}; + +function parseTwilioApiError(text: string): ParsedTwilioApiError { + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== "object") { + return {}; + } + const record = parsed as Record; + return { + code: typeof record.code === "number" ? record.code : undefined, + message: typeof record.message === "string" ? record.message : undefined, + }; + } catch { + return {}; + } +} + +export class TwilioApiError extends Error { + readonly httpStatus: number; + readonly responseText: string; + readonly twilioCode?: number; + + constructor(httpStatus: number, responseText: string) { + const parsed = parseTwilioApiError(responseText); + const detail = parsed.message ?? responseText; + super(`Twilio API error: ${httpStatus} ${detail}`); + this.name = "TwilioApiError"; + this.httpStatus = httpStatus; + this.responseText = responseText; + this.twilioCode = parsed.code; + } +} + export async function twilioApiRequest(params: { baseUrl: string; accountSid: string; @@ -34,7 +71,7 @@ export async function twilioApiRequest(params: { return undefined as T; } const errorText = await response.text(); - throw new Error(`Twilio API error: ${response.status} ${errorText}`); + throw new TwilioApiError(response.status, errorText); } const text = await response.text();