mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(google-meet): reuse meet tabs across retries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| 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<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(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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -27,6 +27,8 @@ export type GoogleMeetChromeHealth = {
|
||||
manualActionMessage?: string;
|
||||
providerConnected?: boolean;
|
||||
realtimeReady?: boolean;
|
||||
audioInputActive?: boolean;
|
||||
audioOutputActive?: boolean;
|
||||
lastInputAt?: string;
|
||||
lastOutputAt?: string;
|
||||
lastInputBytes?: number;
|
||||
|
||||
Reference in New Issue
Block a user