mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix: tighten meet voice-call setup checks
This commit is contained in:
@@ -13,6 +13,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: 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.
|
||||
|
||||
@@ -85,11 +85,12 @@ openclaw googlemeet setup --transport chrome-node --mode transcribe
|
||||
```
|
||||
|
||||
When Twilio delegation is configured, setup also reports whether the
|
||||
`voice-call` plugin and Twilio credentials are ready. Treat any `ok: false`
|
||||
check as a blocker for the checked transport and mode before asking an agent to
|
||||
join. Use `openclaw googlemeet setup --json` for scripts or machine-readable
|
||||
output. Use `--transport chrome`, `--transport chrome-node`, or `--transport twilio`
|
||||
to preflight a specific transport before an agent tries it.
|
||||
`voice-call` plugin, Twilio credentials, and public webhook exposure are ready.
|
||||
Treat any `ok: false` check as a blocker for the checked transport and mode
|
||||
before asking an agent to join. Use `openclaw googlemeet setup --json` for
|
||||
scripts or machine-readable output. Use `--transport chrome`,
|
||||
`--transport chrome-node`, or `--transport twilio` to preflight a specific
|
||||
transport before an agent tries it.
|
||||
|
||||
Join a meeting:
|
||||
|
||||
@@ -439,7 +440,8 @@ openclaw googlemeet setup
|
||||
```
|
||||
|
||||
When Twilio delegation is wired, `googlemeet setup` includes successful
|
||||
`twilio-voice-call-plugin` and `twilio-voice-call-credentials` checks.
|
||||
`twilio-voice-call-plugin`, `twilio-voice-call-credentials`, and
|
||||
`twilio-voice-call-webhook` checks.
|
||||
|
||||
```bash
|
||||
openclaw googlemeet join https://meet.google.com/abc-defg-hij \
|
||||
@@ -1115,8 +1117,8 @@ openclaw googlemeet join https://meet.google.com/abc-defg-hij \
|
||||
|
||||
Expected Twilio state:
|
||||
|
||||
- `googlemeet setup` includes green `twilio-voice-call-plugin` and
|
||||
`twilio-voice-call-credentials` checks.
|
||||
- `googlemeet setup` includes green `twilio-voice-call-plugin`,
|
||||
`twilio-voice-call-credentials`, and `twilio-voice-call-webhook` checks.
|
||||
- `voicecall` is available in the CLI after Gateway reload.
|
||||
- The returned session has `transport: "twilio"` and a `twilio.voiceCallId`.
|
||||
- `googlemeet leave <sessionId>` hangs up the delegated voice call.
|
||||
@@ -1303,6 +1305,11 @@ export TWILIO_AUTH_TOKEN=...
|
||||
export TWILIO_FROM_NUMBER=+15550001234
|
||||
```
|
||||
|
||||
`twilio-voice-call-webhook` fails when `voice-call` has no public webhook
|
||||
exposure, or when `publicUrl` points at loopback or private network space.
|
||||
Set `plugins.entries.voice-call.config.publicUrl` to the public provider URL or
|
||||
configure a `voice-call` tunnel/Tailscale exposure.
|
||||
|
||||
Then restart or reload the Gateway and run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1363,7 +1363,10 @@ describe("google-meet plugin", () => {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: { provider: "twilio" },
|
||||
config: {
|
||||
provider: "twilio",
|
||||
publicUrl: "https://voice.example.com/voice/webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1390,16 +1393,20 @@ describe("google-meet plugin", () => {
|
||||
id: "twilio-voice-call-credentials",
|
||||
ok: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports missing voice-call wiring for Twilio transport", async () => {
|
||||
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
|
||||
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
|
||||
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
|
||||
vi.stubEnv("TWILIO_FROM_NUMBER", "");
|
||||
const { tools } = setup(
|
||||
{ defaultTransport: "twilio" },
|
||||
{ defaultTransport: "chrome" },
|
||||
{
|
||||
fullConfig: {
|
||||
plugins: {
|
||||
@@ -1418,7 +1425,7 @@ describe("google-meet plugin", () => {
|
||||
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", { action: "setup_status" });
|
||||
const result = await tool.execute("id", { action: "setup_status", transport: "twilio" });
|
||||
|
||||
expect(result.details.ok).toBe(false);
|
||||
expect(result.details.checks).toEqual(
|
||||
@@ -1435,6 +1442,49 @@ describe("google-meet plugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports local voice-call publicUrl as unusable for Twilio transport", async () => {
|
||||
vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
|
||||
vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
|
||||
vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
|
||||
const { tools } = setup(
|
||||
{ defaultTransport: "twilio" },
|
||||
{
|
||||
fullConfig: {
|
||||
plugins: {
|
||||
allow: ["google-meet", "voice-call"],
|
||||
entries: {
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: {
|
||||
provider: "twilio",
|
||||
publicUrl: "http://127.0.0.1:3334/voice/webhook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const tool = tools[0] as {
|
||||
execute: (
|
||||
id: string,
|
||||
params: unknown,
|
||||
) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", { action: "setup_status" });
|
||||
|
||||
expect(result.details.ok).toBe(false);
|
||||
expect(result.details.checks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
|
||||
@@ -24,6 +24,78 @@ function resolveUserPath(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
|
||||
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
|
||||
const tunnel = asRecord(voiceCallConfig.tunnel);
|
||||
const tailscale = asRecord(voiceCallConfig.tailscale);
|
||||
const tunnelProvider = normalizeOptionalString(tunnel.provider);
|
||||
const tailscaleMode = normalizeOptionalString(tailscale.mode);
|
||||
|
||||
if (publicUrl) {
|
||||
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
|
||||
return {
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok,
|
||||
message: ok
|
||||
? `Voice-call public webhook URL configured: ${publicUrl}`
|
||||
: `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (tunnelProvider && tunnelProvider !== "none") {
|
||||
return {
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok: true,
|
||||
message: "Voice-call webhook exposure configured through tunnel",
|
||||
};
|
||||
}
|
||||
|
||||
if (tailscaleMode && tailscaleMode !== "off") {
|
||||
return {
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok: true,
|
||||
message: "Voice-call webhook exposure configured through Tailscale",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "twilio-voice-call-webhook",
|
||||
ok: false,
|
||||
message:
|
||||
"Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing",
|
||||
};
|
||||
}
|
||||
|
||||
export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): {
|
||||
ok: boolean;
|
||||
checks: SetupCheck[];
|
||||
@@ -143,7 +215,7 @@ export function getGoogleMeetSetupStatus(
|
||||
|
||||
const shouldCheckTwilioDelegation =
|
||||
config.voiceCall.enabled &&
|
||||
(config.defaultTransport === "twilio" ||
|
||||
(transport === "twilio" ||
|
||||
Boolean(config.twilio.defaultDialInNumber) ||
|
||||
Object.hasOwn(pluginEntries, "voice-call"));
|
||||
if (shouldCheckTwilioDelegation) {
|
||||
@@ -175,6 +247,7 @@ export function getGoogleMeetSetupStatus(
|
||||
? "Twilio voice-call credentials are configured"
|
||||
: "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials",
|
||||
});
|
||||
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +564,38 @@ describe("voice-call plugin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("CLI setup rejects local public webhook URLs for Twilio", async () => {
|
||||
const program = new Command();
|
||||
const stdout = captureStdout();
|
||||
await registerVoiceCallCli(program, {
|
||||
provider: "twilio",
|
||||
fromNumber: "+15550001234",
|
||||
publicUrl: "http://127.0.0.1:3334/voice/webhook",
|
||||
twilio: {
|
||||
accountSid: "AC123",
|
||||
authToken: "token",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await program.parseAsync(["voicecall", "setup", "--json"], { from: "user" });
|
||||
const parsed = JSON.parse(stdout.output()) as {
|
||||
ok?: boolean;
|
||||
checks?: Array<{ id: string; ok: boolean; message: string }>;
|
||||
};
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "webhook-exposure",
|
||||
ok: false,
|
||||
message: expect.stringContaining("local/private"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stdout.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("CLI status lists active calls without a call id", async () => {
|
||||
const program = new Command();
|
||||
const stdout = captureStdout();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sleep } from "../api.js";
|
||||
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
|
||||
import type { VoiceCallRuntime } from "./runtime.js";
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
|
||||
import {
|
||||
cleanupTailscaleExposureRoute,
|
||||
getTailscaleSelfInfo,
|
||||
@@ -166,16 +167,9 @@ function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
|
||||
return mode === "notify" || mode === "conversation" ? mode : undefined;
|
||||
}
|
||||
|
||||
function hasPublicExposure(config: VoiceCallConfig): boolean {
|
||||
return Boolean(
|
||||
config.publicUrl ||
|
||||
(config.tunnel?.provider && config.tunnel.provider !== "none") ||
|
||||
(config.tailscale?.mode && config.tailscale.mode !== "off"),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
|
||||
const validation = validateProviderConfig(config);
|
||||
const webhookExposure = resolveWebhookExposureStatus(config);
|
||||
const checks: SetupCheck[] = [
|
||||
{
|
||||
id: "plugin-enabled",
|
||||
@@ -200,15 +194,8 @@ function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
|
||||
},
|
||||
{
|
||||
id: "webhook-exposure",
|
||||
ok: config.provider === "mock" || hasPublicExposure(config),
|
||||
message:
|
||||
config.provider === "mock"
|
||||
? "Mock provider does not need a public webhook"
|
||||
: hasPublicExposure(config)
|
||||
? config.publicUrl
|
||||
? `Public webhook URL configured: ${config.publicUrl}`
|
||||
: "Webhook exposure configured through tunnel or Tailscale"
|
||||
: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
|
||||
ok: webhookExposure.ok,
|
||||
message: webhookExposure.message,
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
|
||||
@@ -211,6 +211,20 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed when Twilio publicUrl points at a local-only webhook", async () => {
|
||||
await expect(
|
||||
createVoiceCallRuntime({
|
||||
config: createExternalProviderConfig({
|
||||
provider: "twilio",
|
||||
publicUrl: "http://127.0.0.1:3334/voice/webhook",
|
||||
}),
|
||||
coreConfig: {} as CoreConfig,
|
||||
agentRuntime: {} as never,
|
||||
}),
|
||||
).rejects.toThrow("twilio 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({
|
||||
|
||||
@@ -18,6 +18,10 @@ import { resolveVoiceResponseModel } from "./response-model.js";
|
||||
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
||||
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
||||
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||
import {
|
||||
isProviderUnreachableWebhookUrl,
|
||||
providerRequiresPublicWebhook,
|
||||
} from "./webhook-exposure.js";
|
||||
import { VoiceCallWebhookServer } from "./webhook.js";
|
||||
import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
|
||||
|
||||
@@ -166,40 +170,6 @@ 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" &&
|
||||
|
||||
99
extensions/voice-call/src/webhook-exposure.ts
Normal file
99
extensions/voice-call/src/webhook-exposure.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type VoiceCallWebhookExposureConfig = {
|
||||
provider?: string;
|
||||
publicUrl?: string;
|
||||
tunnel?: {
|
||||
provider?: string;
|
||||
};
|
||||
tailscale?: {
|
||||
mode?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type VoiceCallWebhookExposureStatus = {
|
||||
ok: boolean;
|
||||
configured: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function providerRequiresPublicWebhook(providerName: string | undefined): boolean {
|
||||
return providerName === "twilio" || providerName === "telnyx" || providerName === "plivo";
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
|
||||
export function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return isLocalOnlyWebhookHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWebhookExposureStatus(
|
||||
config: VoiceCallWebhookExposureConfig,
|
||||
): VoiceCallWebhookExposureStatus {
|
||||
if (config.provider === "mock") {
|
||||
return {
|
||||
ok: true,
|
||||
configured: true,
|
||||
message: "Mock provider does not need a public webhook",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.publicUrl) {
|
||||
if (isProviderUnreachableWebhookUrl(config.publicUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
configured: true,
|
||||
message: `Public webhook URL is local/private and cannot be reached by ${config.provider ?? "the provider"}: ${config.publicUrl}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
configured: true,
|
||||
message: `Public webhook URL configured: ${config.publicUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.tunnel?.provider && config.tunnel.provider !== "none") {
|
||||
return {
|
||||
ok: true,
|
||||
configured: true,
|
||||
message: "Webhook exposure configured through tunnel",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.tailscale?.mode && config.tailscale.mode !== "off") {
|
||||
return {
|
||||
ok: true,
|
||||
configured: true,
|
||||
message: "Webhook exposure configured through Tailscale",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
configured: false,
|
||||
message: "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user