fix: detect muted Google Meet microphone

This commit is contained in:
Peter Steinberger
2026-05-03 23:22:32 +01:00
parent d3043345ca
commit 940487e20f
5 changed files with 124 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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