From 940487e20f02d9e6110edbc034ef31b93168aec7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 23:22:32 +0100 Subject: [PATCH] fix: detect muted Google Meet microphone --- CHANGELOG.md | 1 + extensions/google-meet/index.test.ts | 96 +++++++++++++++++++ extensions/google-meet/src/runtime.ts | 7 ++ .../google-meet/src/transports/chrome.ts | 21 +++- .../google-meet/src/transports/types.ts | 3 +- 5 files changed, 124 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ca137f030..f661dbdf973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted. - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. - Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear. - Google Meet: grant Meet media permissions through the Playwright browser context when CDP grants do not affect the attached Chrome page, and report in-call microphone/speaker permission problems instead of marking realtime speech ready. diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 4d1a80470c6..3c6858aa864 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -2176,6 +2176,102 @@ describe("google-meet plugin", () => { expect(result.manualActionMessage).toContain("Allow microphone/camera/speaker permissions"); }); + it("uses the local Meet microphone control instead of remote participant mute buttons", () => { + const makeButton = (label: string, disabled = false) => ({ + disabled, + innerText: "", + textContent: "", + click: vi.fn(), + getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)), + }); + const remoteMute = makeButton("You can't remotely mute Peter Steinberger's microphone", true); + const localMic = makeButton("Turn on microphone"); + const document = { + body: { innerText: "", textContent: "" }, + title: "Meet", + querySelector: vi.fn(() => null), + querySelectorAll: vi.fn((selector: string) => { + if (selector === "button") { + return [makeButton("Leave call"), remoteMute, localMic]; + } + if (selector === "input") { + return []; + } + return []; + }), + }; + const context = createContext({ + JSON, + document, + location: { + href: "https://meet.google.com/abc-defg-hij", + hostname: "meet.google.com", + }, + window: {}, + }); + const inspect = new Script( + `(${chromeTransportTesting.meetStatusScriptForTest({ + allowMicrophone: true, + autoJoin: false, + captureCaptions: false, + guestName: "OpenClaw Agent", + })})`, + ).runInContext(context) as () => string; + + const result = JSON.parse(inspect()) as { micMuted?: boolean; notes?: string[] }; + + expect(result.micMuted).toBe(true); + expect(localMic.click).toHaveBeenCalledTimes(1); + expect(remoteMute.click).not.toHaveBeenCalled(); + expect(result.notes).toContain("Attempted to turn on the Meet microphone for realtime mode."); + }); + + it("blocks realtime speech while the Meet microphone remains muted", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + mockLocalMeetBrowserRequest({ + inCall: true, + micMuted: true, + title: "Meet call", + url: "https://meet.google.com/abc-defg-hij", + }); + const { methods } = setup({ + chrome: { + audioBridgeCommand: ["bridge", "start"], + waitForInCallMs: 1, + }, + }); + const handler = methods.get("googlemeet.join") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ + params: { url: "https://meet.google.com/abc-defg-hij" }, + respond, + }); + + expect(respond.mock.calls[0]?.[1]).toMatchObject({ + spoken: false, + session: { + chrome: { + health: { + micMuted: true, + speechReady: false, + speechBlockedReason: "meet-microphone-muted", + }, + }, + }, + }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + it("joins Chrome on a paired node without local Chrome or BlackHole", async () => { const { methods, nodesList, nodesInvoke } = setup( { diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 429399f31f7..ea3972225b3 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -144,6 +144,13 @@ function evaluateSpeechReadiness(session: GoogleMeetSession): { }; } if (health?.inCall === true) { + if (health.micMuted === true) { + return { + ready: false, + reason: "meet-microphone-muted", + message: "Turn on the OpenClaw Google Meet microphone before asking OpenClaw to speak.", + }; + } if (session.chrome.audioBridge) { return { ready: true }; } diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index 90c71553e44..32c66afb88e 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -350,6 +350,11 @@ function meetStatusScript(params: { const label = buttonLabel(button); return pattern.test(label) && !button.disabled; }); + const findCallControlButton = (pattern) => + buttons.find((button) => { + const label = buttonLabel(button); + return pattern.test(label) && !/remotely mute|someone else/i.test(label) && !button.disabled; + }); const input = [...document.querySelectorAll('input')].find((el) => /your name/i.test(el.getAttribute('aria-label') || el.placeholder || '') ); @@ -364,7 +369,17 @@ function meetStatusScript(params: { const host = location.hostname.toLowerCase(); const pageUrl = location.href; const permissionNeeded = /permission needed|microphone problem|speaker problem|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(permissionText); - const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button))); + let mic = findCallControlButton(/^\\s*turn (?:off|on) microphone\\b/i); + if (!mic) { + const callControls = document.querySelector('[role="region"][aria-label="Call controls"]'); + mic = [...(callControls?.querySelectorAll('button') || [])].find((button) => + /^\\s*turn (?:off|on) microphone\\b/i.test(buttonLabel(button)) + ); + } + if (!readOnly && allowMicrophone && mic && /turn on microphone/i.test(buttonLabel(mic))) { + mic.click(); + notes.push("Attempted to turn on the Meet microphone for realtime mode."); + } if (!readOnly && !allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) { mic.click(); notes.push("Muted Meet microphone for observe-only mode."); @@ -495,7 +510,7 @@ function meetStatusScript(params: { clickedJoin: Boolean(join), clickedMicrophoneChoice: Boolean(allowMicrophone && microphoneChoice), inCall, - micMuted: mic ? /turn on microphone/i.test(mic.getAttribute('aria-label') || text(mic)) : undefined, + micMuted: mic ? /turn on microphone/i.test(buttonLabel(mic)) : undefined, lobbyWaiting, leaveReason, captioning, @@ -623,7 +638,7 @@ async function openMeetWithBrowserRequest(params: { timeoutMs: Math.min(timeoutMs, 10_000), }); browser = mergeBrowserNotes(parseMeetBrowserStatus(evaluated) ?? browser, permissionNotes); - if (browser?.inCall === true) { + if (browser?.inCall === true && (params.mode !== "realtime" || browser.micMuted !== true)) { return { launched: true, browser }; } if (browser?.manualActionRequired === true) { diff --git a/extensions/google-meet/src/transports/types.ts b/extensions/google-meet/src/transports/types.ts index 1dbc9e04808..6ba7da5bf4b 100644 --- a/extensions/google-meet/src/transports/types.ts +++ b/extensions/google-meet/src/transports/types.ts @@ -24,7 +24,8 @@ type GoogleMeetSpeechBlockedReason = | GoogleMeetManualActionReason | "not-in-call" | "browser-unverified" - | "audio-bridge-unavailable"; + | "audio-bridge-unavailable" + | "meet-microphone-muted"; export type GoogleMeetChromeHealth = { inCall?: boolean;