Skip to content

Commit 54f44ec

Browse files
committed
fix: restore Twilio Meet voice intro
1 parent 5d1ba08 commit 54f44ec

11 files changed

Lines changed: 145 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Docker: restore `python3` in the gateway runtime image after the slim-runtime switch. Fixes #75041.
1616
- 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.
1717
- 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.
18+
- 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.
1819
- 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.
1920
- 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.
2021
- 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.

docs/cli/voicecall.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Primary doc:
1919
```bash
2020
openclaw voicecall setup
2121
openclaw voicecall smoke
22+
openclaw voicecall status --json
2223
openclaw voicecall status --call-id <id>
2324
openclaw voicecall call --to "+15555550123" --message "Hello" --mode notify
2425
openclaw voicecall continue --call-id <id> --message "Any questions?"
@@ -33,6 +34,9 @@ scripts:
3334
openclaw voicecall setup --json
3435
```
3536

37+
`status` prints active calls as JSON by default. Pass `--call-id <id>` to inspect
38+
one call.
39+
3640
For external providers (`twilio`, `telnyx`, `plivo`), setup must resolve a public
3741
webhook URL from `publicUrl`, a tunnel, or Tailscale exposure. A loopback/private
3842
serve fallback is rejected because carriers cannot reach it.

docs/plugins/google-meet.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1267,7 +1267,7 @@ Also verify:
12671267
`googlemeet doctor [session-id]` prints the session, node, in-call state,
12681268
manual action reason, realtime provider connection, `realtimeReady`, audio
12691269
input/output activity, last audio timestamps, byte counters, and browser URL.
1270-
Use `googlemeet status [session-id]` when you need the raw JSON. Use
1270+
Use `googlemeet status [session-id] --json` when you need the raw JSON. Use
12711271
`googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh
12721272
without exposing tokens; add `--meeting` or `--create-space` when you need a
12731273
Google Meet API proof as well.

extensions/google-meet/src/cli.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,39 @@ describe("google-meet CLI", () => {
594594
}
595595
});
596596

597+
it("accepts --json on session status", async () => {
598+
const stdout = captureStdout();
599+
try {
600+
await setupCli({
601+
runtime: {
602+
status: () => ({
603+
found: true,
604+
sessions: [
605+
{
606+
id: "meet_1",
607+
url: "https://meet.google.com/abc-defg-hij",
608+
state: "active",
609+
transport: "twilio",
610+
mode: "realtime",
611+
participantIdentity: "Twilio PSTN participant",
612+
createdAt: "2026-04-25T00:00:00.000Z",
613+
updatedAt: "2026-04-25T00:00:01.000Z",
614+
realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" },
615+
notes: [],
616+
},
617+
],
618+
}),
619+
},
620+
}).parseAsync(["googlemeet", "status", "--json"], { from: "user" });
621+
expect(JSON.parse(stdout.output())).toMatchObject({
622+
found: true,
623+
sessions: [{ id: "meet_1", transport: "twilio" }],
624+
});
625+
} finally {
626+
stdout.restore();
627+
}
628+
});
629+
597630
it("prints a dry-run export manifest without writing files", async () => {
598631
stubMeetArtifactsApi();
599632
const stdout = captureStdout();

extensions/google-meet/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,6 +1935,7 @@ export function registerGoogleMeetCli(params: {
19351935
root
19361936
.command("status")
19371937
.argument("[session-id]", "Meet session ID")
1938+
.option("--json", "Print JSON output", false)
19381939
.action(async (sessionId?: string) => {
19391940
const rt = await params.ensureRuntime();
19401941
writeStdoutJson(rt.status(sessionId));
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import { resolveGoogleMeetConfig } from "./config.js";
3+
import { joinMeetViaVoiceCallGateway } from "./voice-call-gateway.js";
4+
5+
const gatewayMocks = vi.hoisted(() => ({
6+
request: vi.fn(),
7+
stopAndWait: vi.fn(async () => {}),
8+
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })),
9+
}));
10+
11+
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
12+
GatewayClient: vi.fn(function MockGatewayClient(params: { onHelloOk?: () => void }) {
13+
queueMicrotask(() => params.onHelloOk?.());
14+
return {
15+
request: gatewayMocks.request,
16+
stopAndWait: gatewayMocks.stopAndWait,
17+
};
18+
}),
19+
startGatewayClientWhenEventLoopReady: gatewayMocks.startGatewayClientWhenEventLoopReady,
20+
}));
21+
22+
describe("Google Meet voice-call gateway", () => {
23+
beforeEach(() => {
24+
gatewayMocks.request.mockReset();
25+
gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
26+
gatewayMocks.stopAndWait.mockClear();
27+
gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
28+
});
29+
30+
it("starts Twilio Meet calls in conversation mode with the realtime intro by default", async () => {
31+
const config = resolveGoogleMeetConfig({
32+
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
33+
realtime: { introMessage: "Say exactly: I'm here and listening." },
34+
});
35+
36+
await joinMeetViaVoiceCallGateway({
37+
config,
38+
dialInNumber: "+15551234567",
39+
});
40+
41+
expect(gatewayMocks.request).toHaveBeenCalledWith(
42+
"voicecall.start",
43+
{
44+
to: "+15551234567",
45+
message: "Say exactly: I'm here and listening.",
46+
mode: "conversation",
47+
},
48+
{ timeoutMs: 30_000 },
49+
);
50+
});
51+
});

extensions/google-meet/src/voice-call-gateway.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function joinMeetViaVoiceCallGateway(params: {
7676
"voicecall.start",
7777
{
7878
to: params.dialInNumber,
79-
message: params.config.voiceCall.introMessage,
79+
message: params.config.voiceCall.introMessage ?? params.config.realtime.introMessage,
8080
mode: "conversation",
8181
},
8282
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },

extensions/voice-call/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
123123
openclaw voicecall continue --call-id <id> --message "Any questions?"
124124
openclaw voicecall speak --call-id <id> --message "One moment"
125125
openclaw voicecall end --call-id <id>
126+
openclaw voicecall status --json
126127
openclaw voicecall status --call-id <id>
127128
openclaw voicecall tail
128129
openclaw voicecall expose --mode funnel

extensions/voice-call/index.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime {
6363
endCall: vi.fn(async () => ({ success: true })),
6464
getCall: vi.fn((id: string) => (id === callId ? { callId } : undefined)),
6565
getCallByProviderCallId: vi.fn(() => undefined),
66+
getActiveCalls: vi.fn(() => [{ callId }]),
6667
} as unknown as VoiceCallRuntime["manager"],
6768
webhookServer: {} as VoiceCallRuntime["webhookServer"],
6869
webhookUrl: "http://127.0.0.1:3334/voice/webhook",
@@ -284,6 +285,26 @@ describe("voice-call plugin", () => {
284285
expect(payload.callId).toBe("call-1");
285286
});
286287

288+
it("preserves mode on legacy voicecall.start", async () => {
289+
const { methods } = setup({ provider: "mock" });
290+
const handler = methods.get("voicecall.start") as
291+
| ((ctx: {
292+
params: Record<string, unknown>;
293+
respond: ReturnType<typeof vi.fn>;
294+
}) => Promise<void>)
295+
| undefined;
296+
const respond = vi.fn();
297+
await handler?.({
298+
params: { message: "Hi", mode: "conversation", to: "+15550001234" },
299+
respond,
300+
});
301+
expect(runtimeStub.manager.initiateCall).toHaveBeenCalledWith("+15550001234", undefined, {
302+
message: "Hi",
303+
mode: "conversation",
304+
});
305+
expect(respond.mock.calls[0]?.[0]).toBe(true);
306+
});
307+
287308
it("returns call status", async () => {
288309
const { methods } = setup({ provider: "mock" });
289310
const handler = methods.get("voicecall.status") as
@@ -490,6 +511,22 @@ describe("voice-call plugin", () => {
490511
}
491512
});
492513

514+
it("CLI status lists active calls without a call id", async () => {
515+
const program = new Command();
516+
const stdout = captureStdout();
517+
await registerVoiceCallCli(program);
518+
519+
try {
520+
await program.parseAsync(["voicecall", "status", "--json"], { from: "user" });
521+
const parsed = JSON.parse(stdout.output()) as {
522+
calls?: Array<{ callId?: string }>;
523+
};
524+
expect(parsed.calls).toEqual([expect.objectContaining({ callId: "call-1" })]);
525+
} finally {
526+
stdout.restore();
527+
}
528+
});
529+
493530
it("CLI smoke dry-runs a live call unless --yes is passed", async () => {
494531
const program = new Command();
495532
const stdout = captureStdout();

extensions/voice-call/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,11 +456,14 @@ export default definePluginEntry({
456456
return;
457457
}
458458
const rt = await ensureRuntime();
459+
const mode =
460+
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
459461
await initiateCallAndRespond({
460462
rt,
461463
respond,
462464
to,
463465
message: message || undefined,
466+
mode,
464467
});
465468
} catch (err) {
466469
sendError(respond, err);

0 commit comments

Comments
 (0)