From 54f44ec3215d3550f7b33d73025947661e3481fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 05:41:29 +0100 Subject: [PATCH] fix: restore Twilio Meet voice intro --- CHANGELOG.md | 1 + docs/cli/voicecall.md | 4 ++ docs/plugins/google-meet.md | 2 +- extensions/google-meet/src/cli.test.ts | 33 ++++++++++++ extensions/google-meet/src/cli.ts | 1 + .../src/voice-call-gateway.test.ts | 51 +++++++++++++++++++ .../google-meet/src/voice-call-gateway.ts | 2 +- extensions/voice-call/README.md | 1 + extensions/voice-call/index.test.ts | 37 ++++++++++++++ extensions/voice-call/index.ts | 3 ++ extensions/voice-call/src/cli.ts | 16 ++++-- 11 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 extensions/google-meet/src/voice-call-gateway.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7fe0ab235..b715e0bf27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Docker: restore `python3` in the gateway runtime image after the slim-runtime switch. Fixes #75041. - CLI/Voice Call: scope `voicecall` command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc. - Doctor/plugins: warn when restrictive `plugins.allow` is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty. +- Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf. - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. - Telegram/agents: keep typing indicators and optional generation tools off the reply critical path, so fresh Telegram replies no longer stall while provider catalogs and media models load. (#75360) Thanks @obviyus. - Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337. diff --git a/docs/cli/voicecall.md b/docs/cli/voicecall.md index dc42b95879a..681e0f88c35 100644 --- a/docs/cli/voicecall.md +++ b/docs/cli/voicecall.md @@ -19,6 +19,7 @@ Primary doc: ```bash openclaw voicecall setup openclaw voicecall smoke +openclaw voicecall status --json openclaw voicecall status --call-id openclaw voicecall call --to "+15555550123" --message "Hello" --mode notify openclaw voicecall continue --call-id --message "Any questions?" @@ -33,6 +34,9 @@ scripts: openclaw voicecall setup --json ``` +`status` prints active calls as JSON by default. Pass `--call-id ` to inspect +one call. + For external providers (`twilio`, `telnyx`, `plivo`), setup must resolve a public webhook URL from `publicUrl`, a tunnel, or Tailscale exposure. A loopback/private serve fallback is rejected because carriers cannot reach it. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 81abf6b2fd8..147698d077b 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -1267,7 +1267,7 @@ Also verify: `googlemeet doctor [session-id]` prints the session, node, in-call state, manual action reason, realtime provider connection, `realtimeReady`, audio input/output activity, last audio timestamps, byte counters, and browser URL. -Use `googlemeet status [session-id]` when you need the raw JSON. Use +Use `googlemeet status [session-id] --json` when you need the raw JSON. Use `googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh without exposing tokens; add `--meeting` or `--create-space` when you need a Google Meet API proof as well. diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index cb6eb97eb0e..815f4e03e52 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -594,6 +594,39 @@ describe("google-meet CLI", () => { } }); + it("accepts --json on session status", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + status: () => ({ + found: true, + sessions: [ + { + id: "meet_1", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "twilio", + mode: "realtime", + participantIdentity: "Twilio PSTN participant", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + ], + }), + }, + }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); + expect(JSON.parse(stdout.output())).toMatchObject({ + found: true, + sessions: [{ id: "meet_1", transport: "twilio" }], + }); + } finally { + stdout.restore(); + } + }); + it("prints a dry-run export manifest without writing files", async () => { stubMeetArtifactsApi(); const stdout = captureStdout(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 28ff9a73184..925fbe18a99 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -1935,6 +1935,7 @@ export function registerGoogleMeetCli(params: { root .command("status") .argument("[session-id]", "Meet session ID") + .option("--json", "Print JSON output", false) .action(async (sessionId?: string) => { const rt = await params.ensureRuntime(); writeStdoutJson(rt.status(sessionId)); diff --git a/extensions/google-meet/src/voice-call-gateway.test.ts b/extensions/google-meet/src/voice-call-gateway.test.ts new file mode 100644 index 00000000000..3c8baa22a35 --- /dev/null +++ b/extensions/google-meet/src/voice-call-gateway.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { resolveGoogleMeetConfig } from "./config.js"; +import { joinMeetViaVoiceCallGateway } from "./voice-call-gateway.js"; + +const gatewayMocks = vi.hoisted(() => ({ + request: vi.fn(), + stopAndWait: vi.fn(async () => {}), + startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })), +})); + +vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({ + GatewayClient: vi.fn(function MockGatewayClient(params: { onHelloOk?: () => void }) { + queueMicrotask(() => params.onHelloOk?.()); + return { + request: gatewayMocks.request, + stopAndWait: gatewayMocks.stopAndWait, + }; + }), + startGatewayClientWhenEventLoopReady: gatewayMocks.startGatewayClientWhenEventLoopReady, +})); + +describe("Google Meet voice-call gateway", () => { + beforeEach(() => { + gatewayMocks.request.mockReset(); + gatewayMocks.request.mockResolvedValue({ callId: "call-1" }); + gatewayMocks.stopAndWait.mockClear(); + gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear(); + }); + + it("starts Twilio Meet calls in conversation mode with the realtime intro by default", async () => { + const config = resolveGoogleMeetConfig({ + voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" }, + realtime: { introMessage: "Say exactly: I'm here and listening." }, + }); + + await joinMeetViaVoiceCallGateway({ + config, + dialInNumber: "+15551234567", + }); + + expect(gatewayMocks.request).toHaveBeenCalledWith( + "voicecall.start", + { + to: "+15551234567", + message: "Say exactly: I'm here and listening.", + mode: "conversation", + }, + { timeoutMs: 30_000 }, + ); + }); +}); diff --git a/extensions/google-meet/src/voice-call-gateway.ts b/extensions/google-meet/src/voice-call-gateway.ts index efe1d54c3cd..694f284f1da 100644 --- a/extensions/google-meet/src/voice-call-gateway.ts +++ b/extensions/google-meet/src/voice-call-gateway.ts @@ -76,7 +76,7 @@ export async function joinMeetViaVoiceCallGateway(params: { "voicecall.start", { to: params.dialInNumber, - message: params.config.voiceCall.introMessage, + message: params.config.voiceCall.introMessage ?? params.config.realtime.introMessage, mode: "conversation", }, { timeoutMs: params.config.voiceCall.requestTimeoutMs }, diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index 0f25e83ea30..5cd455db126 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -123,6 +123,7 @@ openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw" openclaw voicecall continue --call-id --message "Any questions?" openclaw voicecall speak --call-id --message "One moment" openclaw voicecall end --call-id +openclaw voicecall status --json openclaw voicecall status --call-id openclaw voicecall tail openclaw voicecall expose --mode funnel diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 79f12ed15d3..0cf6ea0b5b8 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -63,6 +63,7 @@ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime { endCall: vi.fn(async () => ({ success: true })), getCall: vi.fn((id: string) => (id === callId ? { callId } : undefined)), getCallByProviderCallId: vi.fn(() => undefined), + getActiveCalls: vi.fn(() => [{ callId }]), } as unknown as VoiceCallRuntime["manager"], webhookServer: {} as VoiceCallRuntime["webhookServer"], webhookUrl: "http://127.0.0.1:3334/voice/webhook", @@ -284,6 +285,26 @@ describe("voice-call plugin", () => { expect(payload.callId).toBe("call-1"); }); + it("preserves mode on legacy voicecall.start", async () => { + const { methods } = setup({ provider: "mock" }); + const handler = methods.get("voicecall.start") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + await handler?.({ + params: { message: "Hi", mode: "conversation", to: "+15550001234" }, + respond, + }); + expect(runtimeStub.manager.initiateCall).toHaveBeenCalledWith("+15550001234", undefined, { + message: "Hi", + mode: "conversation", + }); + expect(respond.mock.calls[0]?.[0]).toBe(true); + }); + it("returns call status", async () => { const { methods } = setup({ provider: "mock" }); const handler = methods.get("voicecall.status") as @@ -490,6 +511,22 @@ describe("voice-call plugin", () => { } }); + it("CLI status lists active calls without a call id", async () => { + const program = new Command(); + const stdout = captureStdout(); + await registerVoiceCallCli(program); + + try { + await program.parseAsync(["voicecall", "status", "--json"], { from: "user" }); + const parsed = JSON.parse(stdout.output()) as { + calls?: Array<{ callId?: string }>; + }; + expect(parsed.calls).toEqual([expect.objectContaining({ callId: "call-1" })]); + } finally { + stdout.restore(); + } + }); + it("CLI smoke dry-runs a live call unless --yes is passed", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 025fc0083e3..66df71e9865 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -456,11 +456,14 @@ export default definePluginEntry({ return; } const rt = await ensureRuntime(); + const mode = + params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined; await initiateCallAndRespond({ rt, respond, to, message: message || undefined, + mode, }); } catch (err) { sendError(respond, err); diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index 4d67af54f67..253c79ea8f6 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -387,11 +387,19 @@ export function registerVoiceCallCli(params: { root .command("status") .description("Show call status") - .requiredOption("--call-id ", "Call ID") - .action(async (options: { callId: string }) => { + .option("--call-id ", "Call ID") + .option("--json", "Print machine-readable JSON") + .action(async (options: { callId?: string; json?: boolean }) => { const rt = await ensureRuntime(); - const call = rt.manager.getCall(options.callId); - writeStdoutJson(call ?? { found: false }); + if (options.callId) { + const call = rt.manager.getCall(options.callId); + writeStdoutJson(call ?? { found: false }); + return; + } + writeStdoutJson({ + found: true, + calls: rt.manager.getActiveCalls(), + }); }); root