diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 53c1811c8cd5..1f5568546056 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -595,6 +595,16 @@ describe("google-meet create flow", () => { if (proxy.path === "/tabs/focus") { return { payload: { result: { ok: true } } }; } + if (proxy.path === "/navigate") { + return { + payload: { + result: { + targetId: "navigated-create-tab", + url: "https://meet.google.com/new?hl=en", + }, + }, + }; + } if (proxy.path === "/act") { return { payload: { @@ -629,7 +639,7 @@ describe("google-meet create flow", () => { expect(payload.meetingUri).toBe("https://meet.google.com/reu-sedx-tab"); const browser = requireRecord(payload.browser, "browser payload"); expect(browser.nodeId).toBe("node-1"); - expect(browser.targetId).toBe("existing-create-tab"); + expect(browser.targetId).toBe("navigated-create-tab"); findNodeInvokeParams(nodesInvoke, "focus existing tab", (params) => { if (!params.params || typeof params.params !== "object") { return false; @@ -641,6 +651,32 @@ describe("google-meet create flow", () => { const body = proxy.body as Record; return proxy.path === "/tabs/focus" && body.targetId === "existing-create-tab"; }); + findNodeInvokeParams(nodesInvoke, "navigate reused tab to English UI", (params) => { + if (!params.params || typeof params.params !== "object") { + return false; + } + const proxy = params.params as Record; + if (!proxy.body || typeof proxy.body !== "object") { + return false; + } + const body = proxy.body as Record; + return ( + proxy.path === "/navigate" && + body.targetId === "existing-create-tab" && + body.url === "https://meet.google.com/new?hl=en" + ); + }); + findNodeInvokeParams(nodesInvoke, "act uses navigated target id", (params) => { + if (!params.params || typeof params.params !== "object") { + return false; + } + const proxy = params.params as Record; + if (!proxy.body || typeof proxy.body !== "object") { + return false; + } + const body = proxy.body as Record; + return proxy.path === "/act" && body.targetId === "navigated-create-tab"; + }); const openedCreateTab = mockCalls(nodesInvoke, "nodes invoke").some(([value]) => { if (!value || typeof value !== "object") { return false; @@ -655,6 +691,76 @@ describe("google-meet create flow", () => { expect(openedCreateTab).toBe(false); }); + it("does not navigate a reused tab that is already using English UI", async () => { + const { methods, nodesInvoke } = setup( + { + defaultTransport: "chrome-node", + chromeNode: { node: "parallels-macos" }, + }, + { + nodesInvokeHandler: async (params) => { + const proxy = params.params as { path?: string; body?: { targetId?: string } }; + if (proxy.path === "/tabs") { + return { + payload: { + result: { + tabs: [ + { + targetId: "english-create-tab", + title: "Meet", + url: "https://meet.google.com/new?hl=en", + }, + ], + }, + }, + }; + } + if (proxy.path === "/tabs/focus") { + return { payload: { result: { ok: true } } }; + } + if (proxy.path === "/act") { + return { + payload: { + result: { + ok: true, + targetId: proxy.body?.targetId ?? "english-create-tab", + result: { + meetingUri: "https://meet.google.com/eng-lish-tab", + browserUrl: "https://meet.google.com/eng-lish-tab", + browserTitle: "Meet", + }, + }, + }, + }; + } + throw new Error(`unexpected browser proxy path ${proxy.path}`); + }, + }, + ); + const handler = methods.get("googlemeet.create") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ params: { join: false }, respond }); + + const navigated = mockCalls(nodesInvoke, "nodes invoke").some(([value]) => { + if (!value || typeof value !== "object") { + return false; + } + const params = value as Record; + if (!params.params || typeof params.params !== "object") { + return false; + } + const proxy = params.params as Record; + return proxy.path === "/navigate"; + }); + expect(navigated).toBe(false); + }); + it.each([ ["Use microphone", "Accepted Meet microphone prompt with browser automation."], [ diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 0229035b8516..8fd11414ef8e 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -288,6 +288,12 @@ function mockLocalMeetBrowserRequest( if (request.path === "/tabs/focus") { return { ok: true }; } + if (request.path === "/navigate") { + return { + targetId: request.body?.targetId ?? "local-meet-tab", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij", + }; + } if (request.path === "/permissions/grant") { return { ok: true, @@ -2744,7 +2750,7 @@ describe("google-meet plugin", () => { { targetId: "local-meet-tab", title: "Meet", - url: "https://meet.google.com/abc-defg-hij", + url: "https://meet.google.com/abc-defg-hij?hl=en", }, ], } @@ -2755,12 +2761,18 @@ describe("google-meet plugin", () => { return { targetId: "local-meet-tab", title: "Meet", - url: request.body?.url ?? "https://meet.google.com/abc-defg-hij", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", }; } if (request.path === "/tabs/focus") { return { ok: true }; } + if (request.path === "/navigate") { + return { + targetId: request.body?.targetId ?? "local-meet-tab", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", + }; + } if (request.path === "/act") { actCount += 1; return { @@ -2863,7 +2875,7 @@ describe("google-meet plugin", () => { { targetId: "tab-1", title: "Meet", - url: "https://meet.google.com/abc-defg-hij", + url: "https://meet.google.com/abc-defg-hij?hl=en", }, ] : [], @@ -2878,7 +2890,7 @@ describe("google-meet plugin", () => { result: { targetId: "tab-1", title: "Meet", - url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij", + url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", }, }, }; @@ -2886,6 +2898,16 @@ describe("google-meet plugin", () => { if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") { return { payload: { result: { ok: true } } }; } + if (raw.path === "/navigate") { + return { + payload: { + result: { + targetId: raw.body?.targetId ?? "tab-1", + url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", + }, + }, + }; + } if (raw.path === "/act") { return { payload: { @@ -3074,6 +3096,53 @@ describe("google-meet plugin", () => { expect(result.manualActionMessage).toContain("Allow microphone/camera/speaker permissions"); }); + it("does not auto-join when Meet is already active elsewhere", async () => { + const joinElsewhere = { + disabled: false, + innerText: "Join here too", + textContent: "Join here too", + click: vi.fn(), + getAttribute: vi.fn(() => null), + }; + const document = { + body: { innerText: "", textContent: "" }, + title: "Meet", + querySelector: vi.fn(() => null), + querySelectorAll: vi.fn((selector: string) => { + if (selector === "button") { + return [joinElsewhere]; + } + return []; + }), + }; + const context = createContext({ + JSON, + document, + location: { + href: "https://meet.google.com/abc-defg-hij?hl=en", + hostname: "meet.google.com", + }, + window: {}, + }); + const inspect = new Script( + `(${chromeTransportTesting.meetStatusScriptForTest({ + allowMicrophone: false, + autoJoin: true, + captureCaptions: false, + guestName: "OpenClaw Agent", + })})`, + ).runInContext(context) as () => string | Promise; + + const result = JSON.parse(await inspect()) as { + clickedJoin?: boolean; + manualActionReason?: string; + }; + + expect(result.clickedJoin).toBe(false); + expect(result.manualActionReason).toBe("meet-session-conflict"); + expect(joinElsewhere.click).not.toHaveBeenCalled(); + }); + it("uses the local Meet microphone control instead of remote participant mute buttons", async () => { const makeButton = (label: string, disabled = false) => ({ disabled, @@ -3392,7 +3461,7 @@ describe("google-meet plugin", () => { expect(session.notes).toContain("Reused existing active Meet session."); }); - it("reuses existing Meet browser tabs across URL query differences", async () => { + it("opens an English replacement without touching an ambiguous matching tab", async () => { const { methods, nodesInvoke } = setup( { defaultTransport: "chrome-node", @@ -3413,6 +3482,11 @@ describe("google-meet plugin", () => { result: { running: true, tabs: [ + { + targetId: "wrong-account-english-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en", + }, { targetId: "existing-meet-tab", title: "Meet", @@ -3423,6 +3497,133 @@ describe("google-meet plugin", () => { }, }; } + if (proxy.path === "/tabs/open") { + return { + payload: { + result: { + targetId: "english-meet-tab", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }, + }, + }; + } + if (proxy.path === "/permissions/grant") { + return { payload: { result: { ok: true } } }; + } + if (proxy.path === "/act") { + return { + payload: { + result: { + result: JSON.stringify({ + inCall: true, + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }), + }, + }, + }; + } + throw new Error(`unexpected browser proxy path ${proxy.path}`); + }, + }, + ); + 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?authuser=me@example.com" }, + respond, + }); + + const openCall = nodesInvoke.mock.calls.find(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + const params = requireRecord(call.params, "node invoke params"); + return params.path === "/tabs/open"; + }); + if (!openCall) { + throw new Error("Expected browser.proxy /tabs/open node invoke"); + } + expect( + requireRecord(requireRecord(openCall[0], "open node invoke").params, "open params"), + ).toEqual({ + method: "POST", + path: "/tabs/open", + timeoutMs: 30000, + body: { + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }, + }); + expect( + nodesInvoke.mock.calls.some(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + const params = requireRecord(call.params, "node invoke params"); + return params.path === "/tabs/focus" || params.path === "/navigate"; + }), + ).toBe(false); + const actCalls = nodesInvoke.mock.calls.filter(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + const params = requireRecord(call.params, "node invoke params"); + return params.path === "/act"; + }); + expect(actCalls.length).toBeGreaterThanOrEqual(1); + const englishTabActCall = actCalls.find(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + const params = requireRecord(call.params, "node invoke params"); + return requireRecord(params.body, "act body").targetId === "english-meet-tab"; + }); + if (!englishTabActCall) { + throw new Error("Expected browser.proxy /act on the English replacement tab"); + } + expect( + requireRecord(requireRecord(englishTabActCall[0], "act node invoke").params, "act params"), + ).toEqual({ + method: "POST", + path: "/act", + timeoutMs: 10000, + body: { + kind: "evaluate", + targetId: "english-meet-tab", + fn: expect.any(String), + }, + }); + }); + + it("does not navigate a reused join tab that is already using English UI", async () => { + const { methods, nodesInvoke } = setup( + { + defaultTransport: "chrome-node", + defaultMode: "transcribe", + }, + { + nodesInvokeHandler: async (params) => { + if (params.command !== "browser.proxy") { + return { payload: { launched: true } }; + } + const proxy = params.params as { + path?: string; + body?: { targetId?: string; url?: string }; + }; + if (proxy.path === "/tabs") { + return { + payload: { + result: { + running: true, + tabs: [ + { + targetId: "english-meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?hl=en", + }, + ], + }, + }, + }; + } if (proxy.path === "/tabs/focus") { return { payload: { result: { ok: true } } }; } @@ -3433,7 +3634,7 @@ describe("google-meet plugin", () => { result: JSON.stringify({ inCall: true, title: "Meet", - url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + url: "https://meet.google.com/abc-defg-hij?hl=en", }), }, }, @@ -3456,22 +3657,12 @@ describe("google-meet plugin", () => { respond, }); - const focusCall = nodesInvoke.mock.calls.find(([rawCall]) => { - const call = requireRecord(rawCall, "node invoke"); - const params = requireRecord(call.params, "node invoke params"); - return params.path === "/tabs/focus"; - }); - if (!focusCall) { - throw new Error("Expected browser.proxy /tabs/focus node invoke"); - } expect( - requireRecord(requireRecord(focusCall[0], "focus node invoke").params, "focus params"), - ).toEqual({ - method: "POST", - path: "/tabs/focus", - timeoutMs: 5000, - body: { targetId: "existing-meet-tab" }, - }); + nodesInvoke.mock.calls.some(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + return requireRecord(call.params, "node invoke params").path === "/navigate"; + }), + ).toBe(false); expect( nodesInvoke.mock.calls.some(([rawCall]) => { const call = requireRecord(rawCall, "node invoke"); @@ -3480,7 +3671,7 @@ describe("google-meet plugin", () => { ).toBe(false); }); - it("recovers and inspects an existing Meet tab without opening a new one", async () => { + it("reports an ambiguous recovered Meet tab without reloading it", async () => { const { tools, nodesInvoke } = setup( { defaultTransport: "chrome-node", @@ -3509,6 +3700,16 @@ describe("google-meet plugin", () => { if (proxy.path === "/tabs/focus") { return { payload: { result: { ok: true } } }; } + if (proxy.path === "/navigate") { + return { + payload: { + result: { + targetId: proxy.body?.targetId ?? "existing-meet-tab", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }, + }, + }; + } if (proxy.path === "/act") { return { payload: { @@ -3519,7 +3720,7 @@ describe("google-meet plugin", () => { manualActionReason: "meet-admission-required", manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", title: "Meet", - url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", }), }, }, @@ -3545,7 +3746,8 @@ describe("google-meet plugin", () => { expect(result.details.targetId).toBe("existing-meet-tab"); const browser = requireRecord(result.details.browser, "recovered browser state"); expect(browser.manualActionRequired).toBe(true); - expect(browser.manualActionReason).toBe("meet-admission-required"); + expect(browser.manualActionReason).toBe("meet-locale-required"); + expect(browser.manualActionMessage).toContain("not pinned to English"); const focusCall = nodesInvoke.mock.calls.find(([rawCall]) => { const call = requireRecord(rawCall, "node invoke"); const params = requireRecord(call.params, "node invoke params"); @@ -3565,12 +3767,160 @@ describe("google-meet plugin", () => { expect( nodesInvoke.mock.calls.some(([rawCall]) => { const call = requireRecord(rawCall, "node invoke"); - return requireRecord(call.params, "node invoke params").path === "/tabs/open"; + const requestPath = requireRecord(call.params, "node invoke params").path; + return ( + requestPath === "/tabs/open" || requestPath === "/navigate" || requestPath === "/act" + ); }), ).toBe(false); }); - it("recovers and inspects an existing local Chrome Meet tab", async () => { + it("prefers an English replacement when recovering matching Meet tabs", async () => { + const { tools, nodesInvoke } = setup( + { defaultTransport: "chrome-node" }, + { + nodesInvokeHandler: async (params) => { + if (params.command !== "browser.proxy") { + throw new Error(`unexpected command ${params.command}`); + } + const proxy = params.params as { path?: string; body?: { targetId?: string } }; + if (proxy.path === "/tabs") { + return { + payload: { + result: { + tabs: [ + { + targetId: "wrong-account-english-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=other%40example.com&hl=en", + }, + { + targetId: "ambiguous-meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com", + }, + { + targetId: "english-meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }, + ], + }, + }, + }; + } + if (proxy.path === "/tabs/focus") { + return { payload: { result: { ok: true } } }; + } + if (proxy.path === "/act") { + return { + payload: { + result: { + result: JSON.stringify({ + inCall: true, + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?hl=en", + }), + }, + }, + }; + } + throw new Error(`unexpected browser proxy path ${proxy.path}`); + }, + }, + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { targetId?: string; browser?: unknown } }>; + }; + + const result = await tool.execute("id", { + action: "recover_current_tab", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com", + readOnly: true, + }); + + expect(result.details.targetId).toBe("english-meet-tab"); + expect(requireRecord(result.details.browser, "recovered browser state").inCall).toBe(true); + expect( + nodesInvoke.mock.calls.some(([rawCall]) => { + const call = requireRecord(rawCall, "node invoke"); + const params = requireRecord(call.params, "node invoke params"); + return ( + params.path === "/tabs/focus" && + requireRecord(params.body, "focus body").targetId === "english-meet-tab" + ); + }), + ).toBe(true); + }); + + it("preserves the Google sign-in diagnostic during tab recovery", async () => { + const { tools } = setup( + { defaultTransport: "chrome-node" }, + { + nodesInvokeHandler: async (params) => { + if (params.command !== "browser.proxy") { + throw new Error(`unexpected command ${params.command}`); + } + const proxy = params.params as { path?: string }; + if (proxy.path === "/tabs") { + return { + payload: { + result: { + tabs: [ + { + targetId: "google-sign-in-tab", + title: "Sign in - Google Accounts - Meet", + url: "https://accounts.google.com/signin", + }, + { + targetId: "unrelated-english-meet-tab", + title: "Meet", + url: "https://meet.google.com/xyz-wxyz-xyz?hl=en", + }, + ], + }, + }, + }; + } + if (proxy.path === "/tabs/focus") { + return { payload: { result: { ok: true } } }; + } + if (proxy.path === "/act") { + return { + payload: { + result: { + result: JSON.stringify({ + inCall: false, + manualActionRequired: true, + manualActionReason: "google-login-required", + manualActionMessage: "Sign in to Google, then retry.", + url: "https://accounts.google.com/signin", + }), + }, + }, + }; + } + throw new Error(`unexpected browser proxy path ${proxy.path}`); + }, + }, + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { browser?: unknown; targetId?: string } }>; + }; + + const result = await tool.execute("id", { action: "recover_current_tab" }); + const browser = requireRecord(result.details.browser, "recovered browser state"); + expect(result.details.targetId).toBe("google-sign-in-tab"); + expect(browser.manualActionReason).toBe("google-login-required"); + }); + + it("reports an ambiguous local Chrome Meet tab without reloading it", async () => { const callGatewayFromCli = vi.fn( async ( _method: string, @@ -3593,6 +3943,12 @@ describe("google-meet plugin", () => { if (request.path === "/tabs/focus") { return { ok: true }; } + if (request.path === "/navigate") { + return { + targetId: request.body?.targetId ?? "local-meet-tab", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", + }; + } if (request.path === "/act") { return { result: JSON.stringify({ @@ -3601,7 +3957,7 @@ describe("google-meet plugin", () => { manualActionReason: "meet-admission-required", manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.", title: "Meet", - url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + url: "https://meet.google.com/abc-defg-hij?authuser=me%40example.com&hl=en", }), }; } @@ -3634,7 +3990,8 @@ describe("google-meet plugin", () => { expect(result.details.targetId).toBe("local-meet-tab"); const browser = requireRecord(result.details.browser, "recovered browser state"); expect(browser.manualActionRequired).toBe(true); - expect(browser.manualActionReason).toBe("meet-admission-required"); + expect(browser.manualActionReason).toBe("meet-locale-required"); + expect(browser.manualActionMessage).toContain("not pinned to English"); const focusCall = callGatewayFromCli.mock.calls.find( (call) => requireRecord(call[2], "browser request").path === "/tabs/focus", ); @@ -3646,6 +4003,12 @@ describe("google-meet plugin", () => { expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus"); expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined(); expect(focusCall[3]).toEqual({ progress: false }); + expect( + callGatewayFromCli.mock.calls.some((call) => { + const requestPath = requireRecord(call[2], "browser request").path; + return requestPath === "/navigate" || requestPath === "/act"; + }), + ).toBe(false); expect(nodesInvoke).not.toHaveBeenCalled(); }); @@ -3712,7 +4075,7 @@ describe("google-meet plugin", () => { { targetId: "local-meet-tab", title: "Meet", - url: "https://meet.google.com/abc-defg-hij", + url: "https://meet.google.com/abc-defg-hij?hl=en", }, ] : [], @@ -3723,12 +4086,18 @@ describe("google-meet plugin", () => { return { targetId: "local-meet-tab", title: "Meet", - url: request.body?.url ?? "https://meet.google.com/abc-defg-hij", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", }; } if (request.path === "/tabs/focus" || request.path === "/permissions/grant") { return { ok: true }; } + if (request.path === "/navigate") { + return { + targetId: request.body?.targetId ?? "local-meet-tab", + url: request.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", + }; + } if (request.path === "/act") { return { result: JSON.stringify(browserState) }; } @@ -4368,7 +4737,7 @@ describe("google-meet plugin", () => { { targetId: "tab-1", title: "Meet", - url: "https://meet.google.com/abc-defg-hij", + url: "https://meet.google.com/abc-defg-hij?hl=en", }, ] : [], @@ -4383,7 +4752,7 @@ describe("google-meet plugin", () => { result: { targetId: "tab-1", title: "Meet", - url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij", + url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", }, }, }; @@ -4391,6 +4760,16 @@ describe("google-meet plugin", () => { if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") { return { payload: { result: { ok: true } } }; } + if (raw.path === "/navigate") { + return { + payload: { + result: { + targetId: raw.body?.targetId ?? "tab-1", + url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij?hl=en", + }, + }, + }; + } if (raw.path === "/act") { return { payload: { diff --git a/extensions/google-meet/src/transports/chrome-browser-proxy.test.ts b/extensions/google-meet/src/transports/chrome-browser-proxy.test.ts index 2ffc19bdb38b..469660de42f3 100644 --- a/extensions/google-meet/src/transports/chrome-browser-proxy.test.ts +++ b/extensions/google-meet/src/transports/chrome-browser-proxy.test.ts @@ -2,7 +2,11 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { describe, expect, it, vi } from "vitest"; -import { callBrowserProxyOnNode, forceMeetEnglishUi } from "./chrome-browser-proxy.js"; +import { + callBrowserProxyOnNode, + forceMeetEnglishUi, + isEnglishMeetTab, +} from "./chrome-browser-proxy.js"; describe("forceMeetEnglishUi", () => { it("pins hl=en on Meet URLs", () => { @@ -21,6 +25,16 @@ describe("forceMeetEnglishUi", () => { }); }); +describe("isEnglishMeetTab", () => { + it("accepts only Meet tabs explicitly pinned to English", () => { + expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=en")).toBe(true); + expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=EN&authuser=1")).toBe(true); + expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij")).toBe(false); + expect(isEnglishMeetTab("https://meet.google.com/abc-defg-hij?hl=ja")).toBe(false); + expect(isEnglishMeetTab("https://example.com/?hl=en")).toBe(false); + }); +}); + describe("Google Meet Chrome browser proxy", () => { it("reports malformed node proxy payloadJSON with an owned error", async () => { const invoke = vi.fn(async () => ({ diff --git a/extensions/google-meet/src/transports/chrome-browser-proxy.ts b/extensions/google-meet/src/transports/chrome-browser-proxy.ts index 6a8d01bc4ee8..9373fedd9e29 100644 --- a/extensions/google-meet/src/transports/chrome-browser-proxy.ts +++ b/extensions/google-meet/src/transports/chrome-browser-proxy.ts @@ -50,6 +50,22 @@ export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefin return Boolean(normalizedA && normalizedB && normalizedA === normalizedB); } +export function isEnglishMeetTab(url: string | undefined): boolean { + if (!url) { + return false; + } + try { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" && + parsed.hostname.toLowerCase() === "meet.google.com" && + parsed.searchParams.get("hl")?.toLowerCase() === "en" + ); + } catch { + return false; + } +} + type GoogleMeetNodeInfo = { caps?: string[]; commands?: string[]; diff --git a/extensions/google-meet/src/transports/chrome-create.ts b/extensions/google-meet/src/transports/chrome-create.ts index 934958d539bd..4b98783eb672 100644 --- a/extensions/google-meet/src/transports/chrome-create.ts +++ b/extensions/google-meet/src/transports/chrome-create.ts @@ -284,6 +284,29 @@ export async function createMeetWithBrowserProxyOnNode(params: { targetId: tab.targetId, timeoutMs: stepTimeoutMs, }); + // Meet automation scripts match English UI labels; a reused tab may have + // been opened by the browser/profile in a non-English locale. Only force + // English on the /new creation page or sign-in flow; a reused tab that + // already has a meeting code may be an active call, and reloading it would + // interrupt the meeting and replace its target. + const reusedUrl = tab.url ?? ""; + const isCreatePage = + /^https:\/\/meet\.google\.com\/new(?:$|[/?#])/i.test(reusedUrl) || + reusedUrl.startsWith("https://accounts.google.com/"); + const englishUrl = isCreatePage && reusedUrl ? forceMeetEnglishUi(reusedUrl) : undefined; + if (englishUrl && englishUrl !== reusedUrl) { + tab = + readBrowserTab( + await callBrowserProxyOnNode({ + runtime: params.runtime, + nodeId, + method: "POST", + path: "/navigate", + body: { targetId: tab.targetId, url: englishUrl }, + timeoutMs: stepTimeoutMs, + }), + ) ?? tab; + } } else { tab = readBrowserTab( await callBrowserProxyOnNode({ diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index 24a1968927e4..6eb943af93fa 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -24,6 +24,7 @@ import { asBrowserTabs, callBrowserProxyOnNode, forceMeetEnglishUi, + isEnglishMeetTab, isSameMeetUrlForReuse, normalizeMeetUrlForReuse, readBrowserTab, @@ -60,6 +61,17 @@ function isGoogleMeetTalkBackMode(mode: GoogleMeetMode): boolean { return mode === "agent" || mode === "bidi"; } +function readMeetAuthUser(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).searchParams.get("authuser") ?? undefined; + } catch { + return undefined; + } +} + export async function assertBlackHole2chAvailable(params: { runtime: PluginRuntime; timeoutMs: number; @@ -433,8 +445,9 @@ function meetStatusScript(params: { mic.click(); notes.push("Muted Meet microphone for observe-only mode."); } + const joinElsewhere = findButton(/join here too/i); const join = !readOnly && ${JSON.stringify(params.autoJoin)} - ? findButton(/join now|ask to join|join here too/i) + ? findButton(/join now|ask to join/i) : null; if (join) join.click(); const microphoneChoice = findButton(/\\buse microphone\\b/i); @@ -590,6 +603,9 @@ function meetStatusScript(params: { if (!inCall && (host === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)/i.test(pageText))) { manualActionReason = "google-login-required"; manualActionMessage = "Sign in to Google in the OpenClaw browser profile, then retry the Meet join."; + } else if (!inCall && joinElsewhere) { + manualActionReason = "meet-session-conflict"; + manualActionMessage = "Meet is already active in another tab or device. Leave that session or reuse an English-pinned tab before retrying."; } else if (!inCall && /asking to be let in|you.?ll join when someone lets you in|waiting to be let in|ask to join/i.test(pageText)) { manualActionReason = "meet-admission-required"; manualActionMessage = "Admit the OpenClaw browser participant in Google Meet, then retry speech."; @@ -667,6 +683,7 @@ async function openMeetWithBrowserRequest(params: { const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs); let targetId: string | undefined; let tab: BrowserTab | undefined; + let openUrl = params.url; if (params.config.chrome.reuseExistingTab) { const tabs = asBrowserTabs( await params.callBrowser({ @@ -675,9 +692,21 @@ async function openMeetWithBrowserRequest(params: { timeoutMs: Math.min(timeoutMs, 5_000), }), ); - tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url)); + const matchingTabs = tabs.filter((entry) => isSameMeetUrlForReuse(entry.url, params.url)); + const requestedAuthUser = readMeetAuthUser(params.url); + tab = matchingTabs.find( + (entry) => + isEnglishMeetTab(entry.url) && + (!requestedAuthUser || readMeetAuthUser(entry.url) === requestedAuthUser), + ); + if (!tab) { + const requestedUrl = new URL(params.url); + if (!requestedUrl.searchParams.has("authuser")) { + openUrl = matchingTabs.find((entry) => entry.url)?.url ?? params.url; + } + } targetId = tab?.targetId; - if (targetId) { + if (tab && targetId) { await params.callBrowser({ method: "POST", path: "/tabs/focus", @@ -691,7 +720,7 @@ async function openMeetWithBrowserRequest(params: { await params.callBrowser({ method: "POST", path: "/tabs/open", - body: { url: forceMeetEnglishUi(params.url) }, + body: { url: forceMeetEnglishUi(openUrl) }, timeoutMs, }), ); @@ -790,6 +819,18 @@ function isRecoverableMeetTab(tab: BrowserTab, url?: string): boolean { ); } +function findRecoverableMeetTab(tabs: BrowserTab[], url?: string): BrowserTab | undefined { + const candidates = tabs.filter((tab) => isRecoverableMeetTab(tab, url)); + if (!url) { + return candidates[0]; + } + const requestedAuthUser = readMeetAuthUser(url); + const accountCandidates = requestedAuthUser + ? candidates.filter((tab) => readMeetAuthUser(tab.url) === requestedAuthUser) + : candidates; + return accountCandidates.find((tab) => isEnglishMeetTab(tab.url)) ?? accountCandidates[0]; +} + async function inspectRecoverableMeetTab(params: { callBrowser: BrowserRequestCaller; config: GoogleMeetConfig; @@ -806,6 +847,26 @@ async function inspectRecoverableMeetTab(params: { body: { targetId: params.targetId }, timeoutMs: Math.min(params.timeoutMs, 5_000), }); + // Recovery must never reload an unknown meeting-code tab: it may be an active + // call. English-only automation can safely inspect only tabs pinned by us. + if (normalizeMeetUrlForReuse(params.tab.url) && !isEnglishMeetTab(params.tab.url)) { + const manualActionMessage = + "The existing Meet tab is not pinned to English. Open the meeting with ?hl=en, then retry recovery."; + return { + found: true, + targetId: params.targetId, + tab: params.tab, + browser: { + status: "browser-control" as const, + browserUrl: params.tab.url, + browserTitle: params.tab.title, + manualActionRequired: true, + manualActionReason: "meet-locale-required" as const, + manualActionMessage, + }, + message: manualActionMessage, + }; + } const permissionNotes = params.readOnly ? [] : await grantMeetMediaPermissions({ @@ -873,7 +934,7 @@ export async function recoverCurrentMeetTab(params: { timeoutMs: Math.min(timeoutMs, 5_000), }), ); - const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url)); + const tab = findRecoverableMeetTab(tabs, params.url); const targetId = tab?.targetId; if (!tab || !targetId) { return { @@ -928,7 +989,7 @@ export async function recoverCurrentMeetTabOnNode(params: { timeoutMs: Math.min(timeoutMs, 5_000), }), ); - const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url)); + const tab = findRecoverableMeetTab(tabs, params.url); const targetId = tab?.targetId; if (!tab || !targetId) { return { diff --git a/extensions/google-meet/src/transports/types.ts b/extensions/google-meet/src/transports/types.ts index b659d1cda89a..a5712ae084a7 100644 --- a/extensions/google-meet/src/transports/types.ts +++ b/extensions/google-meet/src/transports/types.ts @@ -22,6 +22,8 @@ type GoogleMeetManualActionReason = | "meet-admission-required" | "meet-permission-required" | "meet-audio-choice-required" + | "meet-locale-required" + | "meet-session-conflict" | "browser-control-unavailable"; type GoogleMeetSpeechBlockedReason =