From 4f6a4317deda8881dec94c120fe8750d411913eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 10:21:02 +0100 Subject: [PATCH] fix: clarify google meet twilio dial plan --- CHANGELOG.md | 1 + docs/plugins/google-meet.md | 3 +- extensions/google-meet/index.test.ts | 107 ++++++++++++++++++++++++++ extensions/google-meet/index.ts | 14 +++- extensions/google-meet/src/runtime.ts | 15 +++- extensions/google-meet/src/setup.ts | 17 ++++ 6 files changed, 151 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adc5521715..f46c94999f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. - Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc. +- Google Meet/Twilio: report missing dial-in details during setup and explain that Twilio cannot join Meet URLs without a phone dial plan. Thanks @vincentkoc. - Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427. - Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah. - CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 82044bd6218..55edf7410e2 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -22,7 +22,8 @@ Google Meet participant support for OpenClaw — the plugin is explicit by desig - There is no automatic consent announcement. - The default Chrome audio backend is `BlackHole 2ch`. - Chrome can run locally or on a paired node host. -- Twilio accepts a dial-in number plus optional PIN or DTMF sequence. +- Twilio accepts a dial-in number plus optional PIN or DTMF sequence; it + cannot dial a Meet URL directly. - The CLI command is `googlemeet`; `meet` is reserved for broader agent teleconference workflows. diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 7fdcc59f1b9..1b434ccd57e 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1068,6 +1068,21 @@ describe("google-meet plugin", () => { }); }); + it("explains that Twilio joins need dial-in details", async () => { + const { tools } = setup({ defaultTransport: "twilio" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>; + }; + + const result = await tool.execute("id", { + action: "join", + url: "https://meet.google.com/abc-defg-hij", + }); + + expect(result.details.error).toContain("Twilio transport requires a Meet dial-in phone number"); + expect(result.details.error).toContain("Google Meet URLs do not include dial-in details"); + }); + it("hangs up delegated Twilio calls on leave", async () => { const { tools } = setup({ defaultTransport: "twilio" }); const tool = tools[0] as { @@ -1619,6 +1634,98 @@ describe("google-meet plugin", () => { ); }); + it("reports missing Twilio dial plan for explicit Twilio setup", async () => { + vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); + vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); + vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); + const { tools } = setup( + { defaultTransport: "chrome" }, + { + fullConfig: { + plugins: { + allow: ["google-meet", "voice-call"], + entries: { + "voice-call": { + enabled: true, + config: { + provider: "twilio", + publicUrl: "https://voice.example.com/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", transport: "twilio" }); + + expect(result.details.ok).toBe(false); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "twilio-dial-plan", + ok: false, + message: expect.stringContaining("dial-in phone number"), + }), + ]), + ); + }); + + it("accepts request-provided Twilio dial-in details during setup", async () => { + vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); + vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); + vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); + const { tools } = setup( + { defaultTransport: "chrome" }, + { + fullConfig: { + plugins: { + allow: ["google-meet", "voice-call"], + entries: { + "voice-call": { + enabled: true, + config: { + provider: "twilio", + publicUrl: "https://voice.example.com/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", + transport: "twilio", + dialInNumber: "+15551234567", + }); + + expect(result.details.ok).toBe(true); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "twilio-dial-plan", + ok: true, + message: expect.stringContaining("request includes"), + }), + ]), + ); + }); + it.each([ "http://127.0.0.1:3334/voice/webhook", "http://[::1]:3334/voice/webhook", diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 932c5b13466..69ba21eed06 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -239,8 +239,15 @@ const GoogleMeetToolSchema = Type.Object({ "Join mode. realtime starts live listen/talk-back through the realtime voice model; transcribe joins without the realtime talk-back bridge.", }), ), - dialInNumber: Type.Optional(Type.String({ description: "Meet dial-in number for Twilio" })), - pin: Type.Optional(Type.String({ description: "Meet phone PIN for Twilio" })), + dialInNumber: Type.Optional( + Type.String({ + description: + "Meet dial-in phone number for Twilio. Required for Twilio unless twilio.defaultDialInNumber is configured; Meet URLs cannot be dialed directly.", + }), + ), + pin: Type.Optional( + Type.String({ description: "Meet phone PIN for Twilio; # is appended if omitted" }), + ), dtmfSequence: Type.Optional(Type.String({ description: "Explicit DTMF sequence for Twilio" })), sessionId: Type.Optional(Type.String({ description: "Meet session ID" })), message: Type.Optional(Type.String({ description: "Realtime instructions to speak now" })), @@ -776,6 +783,7 @@ export default definePluginEntry({ await rt.setupStatus({ transport: normalizeTransport(params?.transport), mode: normalizeMode(params?.mode), + dialInNumber: normalizeOptionalString(params?.dialInNumber), }), ); } catch (err) { @@ -986,7 +994,7 @@ export default definePluginEntry({ name: "google_meet", label: "Google Meet", description: - "Join and track Google Meet sessions through Chrome or Twilio. Call setup_status before join/create/test_listen/test_speech; if it reports a Chrome node offline or local audio missing, surface that blocker instead of retrying or switching transports. Offline nodes are diagnostics only, not usable candidates. If local Chrome realtime audio is unsupported on this OS, use mode=transcribe, transport=twilio, or a macOS chrome-node for realtime Chrome. If a Meet tab is already open after a timeout, call recover_current_tab before retrying join to report login, permission, or admission blockers without opening another tab.", + "Join and track Google Meet sessions through Chrome or Twilio. Call setup_status before join/create/test_listen/test_speech; if it reports a Chrome node offline, local audio missing, or missing Twilio dial plan, surface that blocker instead of retrying or switching transports. Twilio cannot dial a Meet URL directly: provide dialInNumber plus optional pin/dtmfSequence, or configure twilio.defaultDialInNumber. Offline nodes are diagnostics only, not usable candidates. If local Chrome realtime audio is unsupported on this OS, use mode=transcribe, transport=twilio, or a macOS chrome-node for realtime Chrome. If a Meet tab is already open after a timeout, call recover_current_tab before retrying join to report login, permission, or admission blockers without opening another tab.", parameters: GoogleMeetToolSchema, async execute(_toolCallId, params) { const raw = asParamRecord(params); diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 5367cef4c4a..a796a0051f2 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -226,9 +226,17 @@ export class GoogleMeetRuntime { return session ? { found: true, session } : { found: false }; } - async setupStatus(options: { transport?: GoogleMeetTransport; mode?: GoogleMeetMode } = {}) { + async setupStatus( + options: { + transport?: GoogleMeetTransport; + mode?: GoogleMeetMode; + dialInNumber?: string; + } = {}, + ) { const transport = resolveTransport(options.transport, this.params.config); const mode = resolveMode(options.mode, this.params.config); + const twilioDialInNumber = + transport === "twilio" ? normalizeDialInNumber(options.dialInNumber) : undefined; const shouldCheckChromeNode = transport === "chrome-node" || (!options.transport && Boolean(this.params.config.chromeNode.node)); @@ -236,6 +244,7 @@ export class GoogleMeetRuntime { fullConfig: this.params.fullConfig, mode, transport, + twilioDialInNumber, }); if (shouldCheckChromeNode) { try { @@ -440,7 +449,9 @@ export class GoogleMeetRuntime { request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber, ); if (!dialInNumber) { - throw new Error("dialInNumber required for twilio transport"); + throw new Error( + "Twilio transport requires a Meet dial-in phone number. Google Meet URLs do not include dial-in details; pass dialInNumber with optional pin/dtmfSequence, configure twilio.defaultDialInNumber, or use chrome/chrome-node transport.", + ); } const dtmfSequence = buildMeetDtmfSequence({ pin: request.pin ?? this.params.config.twilio.defaultPin, diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts index 947b4d72fb4..8aefcdb3fee 100644 --- a/extensions/google-meet/src/setup.ts +++ b/extensions/google-meet/src/setup.ts @@ -87,6 +87,7 @@ export function getGoogleMeetSetupStatus( fullConfig?: unknown; mode?: GoogleMeetMode; transport?: GoogleMeetTransport; + twilioDialInNumber?: string; }, ): { ok: boolean; @@ -99,6 +100,7 @@ export function getGoogleMeetSetupStatus( fullConfig?: unknown; mode?: GoogleMeetMode; transport?: GoogleMeetTransport; + twilioDialInNumber?: string; }, ) { const checks: SetupCheck[] = []; @@ -193,6 +195,21 @@ export function getGoogleMeetSetupStatus( }); } + if (transport === "twilio") { + const hasRequestDialPlan = Boolean(options?.twilioDialInNumber); + const hasDefaultDialPlan = Boolean(config.twilio.defaultDialInNumber); + const hasDialPlan = hasRequestDialPlan || hasDefaultDialPlan; + checks.push({ + id: "twilio-dial-plan", + ok: hasDialPlan, + message: hasRequestDialPlan + ? "Twilio request includes a Meet dial-in number" + : hasDefaultDialPlan + ? "Twilio default Meet dial-in number is configured" + : "Twilio joins require a Meet dial-in phone number; pass dialInNumber with optional pin/dtmfSequence or configure twilio.defaultDialInNumber", + }); + } + const shouldCheckTwilioDelegation = config.voiceCall.enabled && (transport === "twilio" ||