diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2e261675a..c5cbd56f0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: add an OpenRouter TTS provider using the OpenAI-compatible `/audio/speech` endpoint and `OPENROUTER_API_KEY`. Fixes #71268. - macOS Talk Mode: retry failed local ElevenLabs stream playback through gateway `talk.speak` before falling back to the system voice, so configured ElevenLabs voices still play when streaming playback fails. Fixes #65662. - Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens. +- Plugins/Voice Call: fail fast when Twilio, Telnyx, or Plivo would fall back to a loopback/private webhook URL, so calls do not start with an unreachable callback endpoint. Thanks @artemgetmann. - Plugins/Voice Call: resolve queued-but-not-yet-playing Twilio TTS entries when barge-in or stream teardown clears the playback queue, so callers awaiting `queueTts()` do not hang. Thanks @kevinWangSheng. - Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari. - Plugins/Voice Call: start provider STT after Telnyx outbound conversation greetings and pass configured Telnyx voice IDs through to the speak action. Fixes #56091. Thanks @Roshan. diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index ad9aa64b208..b50864af5dd 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -78,6 +78,33 @@ function createBaseConfig(): VoiceCallConfig { return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" }); } +function createExternalProviderConfig(params: { + provider: "twilio" | "telnyx" | "plivo"; + publicUrl?: string; +}): VoiceCallConfig { + const config = createVoiceCallBaseConfig({ + provider: params.provider, + tunnelProvider: "none", + }); + config.twilio = { + accountSid: "AC123", + authToken: "secret", + }; + config.telnyx = { + apiKey: "key", + connectionId: "conn", + publicKey: "pub", + }; + config.plivo = { + authId: "MA123", + authToken: "secret", + }; + if (params.publicUrl) { + config.publicUrl = params.publicUrl; + } + return config; +} + describe("createVoiceCallRuntime lifecycle", () => { beforeEach(() => { vi.clearAllMocks(); @@ -170,6 +197,36 @@ describe("createVoiceCallRuntime lifecycle", () => { expect(mocks.webhookCtorArgs[0]?.[4]).toBe(fullConfig); }); + it.each(["twilio", "telnyx", "plivo"] as const)( + "fails closed when %s falls back to a local-only webhook", + async (provider) => { + await expect( + createVoiceCallRuntime({ + config: createExternalProviderConfig({ provider }), + coreConfig: {} as CoreConfig, + agentRuntime: {} as never, + }), + ).rejects.toThrow(`${provider} requires a publicly reachable webhook URL`); + expect(mocks.webhookStop).toHaveBeenCalledTimes(1); + }, + ); + + it("accepts an explicit public URL for external voice providers", async () => { + const runtime = await createVoiceCallRuntime({ + config: createExternalProviderConfig({ + provider: "twilio", + publicUrl: "https://voice.example.com/voice/webhook", + }), + coreConfig: {} as CoreConfig, + agentRuntime: {} as never, + }); + + expect(runtime.webhookUrl).toBe("https://voice.example.com/voice/webhook"); + expect(runtime.publicUrl).toBe("https://voice.example.com/voice/webhook"); + + await runtime.stop(); + }); + it("wires the shared realtime agent consult tool and handler", async () => { const config = createBaseConfig(); config.inboundPolicy = "allowlist"; diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 4b1fb181f85..eb509a40d20 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -158,6 +158,40 @@ function isLoopbackBind(bind: string | undefined): boolean { return bind === "127.0.0.1" || bind === "::1" || bind === "localhost"; } +function providerRequiresPublicWebhook(providerName: VoiceCallProvider["name"]): boolean { + return providerName === "twilio" || providerName === "telnyx" || providerName === "plivo"; +} + +function isLocalOnlyWebhookHost(hostname: string): boolean { + const host = hostname.trim().toLowerCase(); + if (!host) { + return false; + } + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "::" || + host === "::1" || + host.startsWith("127.") + ) { + return true; + } + if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) { + return true; + } + const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); + return private172 || host.startsWith("fc") || host.startsWith("fd"); +} + +function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean { + try { + const parsed = new URL(webhookUrl); + return isLocalOnlyWebhookHost(parsed.hostname); + } catch { + return false; + } +} + async function resolveProvider(config: VoiceCallConfig): Promise { const allowNgrokFreeTierLoopbackBypass = config.tunnel?.provider === "ngrok" && @@ -376,6 +410,17 @@ export async function createVoiceCallRuntime(params: { const webhookUrl = publicUrl ?? localUrl; + if ( + providerRequiresPublicWebhook(provider.name) && + isProviderUnreachableWebhookUrl(webhookUrl) + ) { + throw new Error( + `[voice-call] ${provider.name} requires a publicly reachable webhook URL. ` + + `Refusing to use local-only webhook ${webhookUrl}. ` + + "Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.", + ); + } + if (publicUrl && provider.name === "twilio") { (provider as TwilioProvider).setPublicUrl(publicUrl); }