diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 05447c0401b..cbb2f83b949 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -119,6 +119,10 @@ openclaw googlemeet create --no-join the OpenClaw Chrome profile on the node to already be signed in to Google. Browser automation handles Meet's own first-run microphone prompt; that prompt is not treated as a Google login failure. + Join and create flows also try to reuse an existing Meet tab before opening a + new one. Matching ignores harmless URL query strings such as `authuser`, so an + agent retry should focus the already-open meeting instead of creating a second + Chrome tab. The command/tool output includes a `source` field (`api` or `browser`) so agents can explain which path was used. `create` joins the new meeting by default and @@ -141,6 +145,14 @@ For an observe-only/browser-control join, set `"mode": "transcribe"`. That does not start the duplex realtime model bridge, so it will not talk back into the meeting. +During realtime sessions, `google_meet` status includes browser and audio bridge +health such as `inCall`, `manualActionRequired`, `providerConnected`, +`realtimeReady`, `audioInputActive`, `audioOutputActive`, last input/output +timestamps, byte counters, and bridge closed state. If a safe Meet page prompt +appears, browser automation handles it when it can. Login, host admission, and +browser/OS permission prompts are reported as manual action with a reason and +message for the agent to relay. + Chrome joins as the signed-in Chrome profile. In Meet, pick `BlackHole 2ch` for the microphone/speaker path used by OpenClaw. For clean duplex audio, use separate virtual devices or a Loopback-style graph; a single BlackHole device is diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index ca8893db76f..16af1e4ec76 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -766,6 +766,128 @@ describe("google-meet plugin", () => { }); }); + it("reuses active Meet sessions across URL query differences", async () => { + const { methods, nodesInvoke } = setup( + { + defaultTransport: "chrome-node", + defaultMode: "transcribe", + }, + { + nodesInvokeResult: { + payload: { + launched: true, + browser: { inCall: true, micMuted: false }, + }, + }, + }, + ); + const handler = methods.get("googlemeet.join") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const first = vi.fn(); + const second = vi.fn(); + + await handler?.({ + params: { url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com" }, + respond: first, + }); + await handler?.({ + params: { url: "https://meet.google.com/abc-defg-hij" }, + respond: second, + }); + + expect( + nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"), + ).toHaveLength(1); + expect(second.mock.calls[0]?.[1]).toMatchObject({ + session: { + notes: expect.arrayContaining(["Reused existing active Meet session."]), + }, + }); + }); + + it("reuses existing Meet browser tabs across URL query differences", 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: "existing-meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com", + }, + ], + }, + }, + }; + } + 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?authuser=me@example.com", + }), + }, + }, + }; + } + 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" }, + respond, + }); + + expect(nodesInvoke).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + path: "/tabs/focus", + body: { targetId: "existing-meet-tab" }, + }), + }), + ); + expect(nodesInvoke).not.toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ path: "/tabs/open" }), + }), + ); + }); + it("exposes a test-speech action that joins the requested meeting", async () => { const { tools, nodesInvoke } = setup( { @@ -1072,6 +1194,14 @@ describe("google-meet plugin", () => { expect(bridge.triggerGreeting).not.toHaveBeenCalled(); handle.speak("Say exactly: hello from the meeting."); expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the meeting."); + expect(handle.getHealth()).toMatchObject({ + providerConnected: true, + realtimeReady: true, + audioInputActive: true, + audioOutputActive: true, + lastInputBytes: 3, + lastOutputBytes: 2, + }); expect(callbacks).toMatchObject({ tools: [ expect.objectContaining({ @@ -1226,6 +1356,14 @@ describe("google-meet plugin", () => { nodeId: "node-1", bridgeId: "bridge-1", }); + expect(handle.getHealth()).toMatchObject({ + providerConnected: true, + realtimeReady: true, + audioInputActive: true, + audioOutputActive: true, + lastInputBytes: 3, + lastOutputBytes: 3, + }); await handle.stop(); diff --git a/extensions/google-meet/src/realtime-node.ts b/extensions/google-meet/src/realtime-node.ts index 4dbd7e61bbd..43e578804b1 100644 --- a/extensions/google-meet/src/realtime-node.ts +++ b/extensions/google-meet/src/realtime-node.ts @@ -213,6 +213,8 @@ export async function startNodeRealtimeAudioBridge(params: { getHealth: () => ({ providerConnected: bridge?.bridge.isConnected() ?? false, realtimeReady, + audioInputActive: lastInputBytes > 0, + audioOutputActive: lastOutputBytes > 0, lastInputAt, lastOutputAt, lastInputBytes, diff --git a/extensions/google-meet/src/realtime.ts b/extensions/google-meet/src/realtime.ts index f37fad1230f..42deefc2323 100644 --- a/extensions/google-meet/src/realtime.ts +++ b/extensions/google-meet/src/realtime.ts @@ -234,6 +234,8 @@ export async function startCommandRealtimeAudioBridge(params: { getHealth: () => ({ providerConnected: bridge?.bridge.isConnected() ?? false, realtimeReady, + audioInputActive: lastInputBytes > 0, + audioOutputActive: lastOutputBytes > 0, lastInputAt, lastOutputAt, lastInputBytes, diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 9dcc7939953..ec80e12b2ba 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-ru import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; import { addGoogleMeetSetupCheck, getGoogleMeetSetupStatus } from "./setup.js"; -import { resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js"; +import { isSameMeetUrlForReuse, resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js"; import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js"; import { launchChromeMeet, launchChromeMeetOnNode } from "./transports/chrome.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js"; @@ -126,7 +126,7 @@ export class GoogleMeetRuntime { const reusable = this.list().find( (session) => session.state === "active" && - session.url === url && + isSameMeetUrlForReuse(session.url, url) && session.transport === transport && session.mode === mode, ); diff --git a/extensions/google-meet/src/transports/chrome-browser-proxy.ts b/extensions/google-meet/src/transports/chrome-browser-proxy.ts index e78ac568b1d..04383a87536 100644 --- a/extensions/google-meet/src/transports/chrome-browser-proxy.ts +++ b/extensions/google-meet/src/transports/chrome-browser-proxy.ts @@ -10,6 +10,31 @@ export type BrowserTab = { url?: string; }; +export function normalizeMeetUrlForReuse(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "meet.google.com") { + return undefined; + } + const match = parsed.pathname.match(/^\/(new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:\/)?$/i); + if (!match?.[1]) { + return undefined; + } + return `https://meet.google.com/${match[1].toLowerCase()}`; + } catch { + return undefined; + } +} + +export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefined): boolean { + const normalizedA = normalizeMeetUrlForReuse(a); + const normalizedB = normalizeMeetUrlForReuse(b); + return Boolean(normalizedA && normalizedB && normalizedA === normalizedB); +} + export type GoogleMeetNodeInfo = { caps?: string[]; commands?: string[]; diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index 39e0b6d7f91..ab577f450c3 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -13,6 +13,7 @@ import { import { asBrowserTabs, callBrowserProxyOnNode, + isSameMeetUrlForReuse, readBrowserTab, resolveChromeNode, type BrowserTab, @@ -305,7 +306,7 @@ async function openMeetWithBrowserProxy(params: { timeoutMs: Math.min(timeoutMs, 5_000), }), ); - tab = tabs.find((entry) => entry.url === params.url); + tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url)); targetId = tab?.targetId; if (targetId) { await callBrowserProxyOnNode({ diff --git a/extensions/google-meet/src/transports/types.ts b/extensions/google-meet/src/transports/types.ts index 29f855a5418..87c81ec6f71 100644 --- a/extensions/google-meet/src/transports/types.ts +++ b/extensions/google-meet/src/transports/types.ts @@ -27,6 +27,8 @@ export type GoogleMeetChromeHealth = { manualActionMessage?: string; providerConnected?: boolean; realtimeReady?: boolean; + audioInputActive?: boolean; + audioOutputActive?: boolean; lastInputAt?: string; lastOutputAt?: string; lastInputBytes?: number;