fix: restore Twilio Meet voice intro

This commit is contained in:
Peter Steinberger
2026-05-01 05:41:29 +01:00
parent 5d1ba08e3c
commit 54f44ec321
11 changed files with 145 additions and 6 deletions

View File

@@ -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.

View File

@@ -19,6 +19,7 @@ Primary doc:
```bash
openclaw voicecall setup
openclaw voicecall smoke
openclaw voicecall status --json
openclaw voicecall status --call-id <id>
openclaw voicecall call --to "+15555550123" --message "Hello" --mode notify
openclaw voicecall continue --call-id <id> --message "Any questions?"
@@ -33,6 +34,9 @@ scripts:
openclaw voicecall setup --json
```
`status` prints active calls as JSON by default. Pass `--call-id <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.

View File

@@ -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.

View File

@@ -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();

View File

@@ -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));

View File

@@ -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 },
);
});
});

View File

@@ -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 },

View File

@@ -123,6 +123,7 @@ openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall speak --call-id <id> --message "One moment"
openclaw voicecall end --call-id <id>
openclaw voicecall status --json
openclaw voicecall status --call-id <id>
openclaw voicecall tail
openclaw voicecall expose --mode funnel

View File

@@ -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<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| 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();

View File

@@ -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);

View File

@@ -387,11 +387,19 @@ export function registerVoiceCallCli(params: {
root
.command("status")
.description("Show call status")
.requiredOption("--call-id <id>", "Call ID")
.action(async (options: { callId: string }) => {
.option("--call-id <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