From b2f21853489d110623017bcb12341a5fdcb56f02 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Sun, 3 May 2026 21:18:46 -0700 Subject: [PATCH] fix(google-meet): keep realtime Twilio joins alive --- .../src/voice-call-gateway.test.ts | 31 ++++++++++++++++++- .../google-meet/src/voice-call-gateway.ts | 16 +++++++--- extensions/voice-call/index.test.ts | 24 ++++++++++++++ extensions/voice-call/index.ts | 7 +++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/extensions/google-meet/src/voice-call-gateway.test.ts b/extensions/google-meet/src/voice-call-gateway.test.ts index 7f977f12a53..ffe810ef1d1 100644 --- a/extensions/google-meet/src/voice-call-gateway.test.ts +++ b/extensions/google-meet/src/voice-call-gateway.test.ts @@ -28,7 +28,7 @@ describe("Google Meet voice-call gateway", () => { gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear(); }); - it("starts Twilio Meet calls, sends delayed DTMF, then speaks the intro", async () => { + it("starts Twilio Meet calls, sends delayed DTMF, then speaks the intro without TwiML fallback", async () => { const config = resolveGoogleMeetConfig({ voiceCall: { gatewayUrl: "ws://127.0.0.1:18789", @@ -70,10 +70,39 @@ describe("Google Meet voice-call gateway", () => { "voicecall.speak", { callId: "call-1", + allowTwimlFallback: false, message: "Say exactly: I'm here and listening.", }, { timeoutMs: 30_000 }, ); expect(gatewayMocks.request).toHaveBeenCalledTimes(3); }); + + it("skips the intro without failing when the realtime bridge is not ready", async () => { + gatewayMocks.request + .mockResolvedValueOnce({ callId: "call-1" }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: "No active realtime bridge for call" }); + const config = resolveGoogleMeetConfig({ + voiceCall: { + gatewayUrl: "ws://127.0.0.1:18789", + dtmfDelayMs: 1, + postDtmfSpeechDelayMs: 1, + }, + }); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + + const result = await joinMeetViaVoiceCallGateway({ + config, + dialInNumber: "+15551234567", + dtmfSequence: "123456#", + logger, + message: "Say exactly: I'm here and listening.", + }); + + expect(result).toMatchObject({ callId: "call-1", dtmfSent: true, introSent: false }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Skipped intro speech because realtime bridge was not ready"), + ); + }); }); diff --git a/extensions/google-meet/src/voice-call-gateway.ts b/extensions/google-meet/src/voice-call-gateway.ts index fd6fb7afa94..c39bb90c47b 100644 --- a/extensions/google-meet/src/voice-call-gateway.ts +++ b/extensions/google-meet/src/voice-call-gateway.ts @@ -145,17 +145,23 @@ export async function joinMeetViaVoiceCallGateway(params: { "voicecall.speak", { callId: start.callId, + allowTwimlFallback: false, message: params.message, }, { timeoutMs: params.config.voiceCall.requestTimeoutMs }, )) as VoiceCallSpeakResult; if (spoken.success === false) { - throw new Error(spoken.error || "voicecall.speak failed"); + params.logger?.warn?.( + `[google-meet] Skipped intro speech because realtime bridge was not ready: ${ + spoken.error || "voicecall.speak failed" + }`, + ); + } else { + introSent = true; + params.logger?.info( + `[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`, + ); } - introSent = true; - params.logger?.info( - `[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`, - ); } return { callId: start.callId, diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index c9a717cebaa..739768eec04 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -466,6 +466,30 @@ describe("voice-call plugin", () => { expect(respond.mock.calls[0]).toEqual([true, { success: true }]); }); + it("does not fall back to one-shot TwiML speak when realtime-only speech is requested", async () => { + runtimeStub.config.realtime.enabled = true; + const { methods } = setup({ provider: "mock" }); + const handler = methods.get("voicecall.speak") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ + params: { allowTwimlFallback: false, callId: "call-1", message: "hello" }, + respond, + }); + + expect(runtimeStub.webhookServer.speakRealtime).toHaveBeenCalledWith("call-1", "hello"); + expect(runtimeStub.manager.speak).not.toHaveBeenCalled(); + expect(respond.mock.calls[0]).toEqual([ + true, + { success: false, error: "No active realtime bridge for call" }, + ]); + }); + it("reports ended call history when speaking to a stale call", async () => { runtimeStub.manager.getCall = vi.fn(() => undefined); runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined); diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index abf14ce541c..b6c4bf0a9ff 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -504,6 +504,13 @@ export default definePluginEntry({ respond(true, { success: true }); return; } + if (params?.allowTwimlFallback === false) { + respond(true, { + success: false, + error: realtimeResult.error ?? "Realtime bridge is not active", + }); + return; + } } const result = await request.rt.manager.speak(request.callId, request.message); if (!result.success) {