mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(voice-call): reject local webhook fallback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<VoiceCallProvider> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user