mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:10:45 +00:00
fix(google-meet): recover local chrome tabs
This commit is contained in:
@@ -1238,10 +1238,12 @@ openclaw googlemeet recover-tab https://meet.google.com/abc-defg-hij
|
||||
```
|
||||
|
||||
The equivalent tool action is `recover_current_tab`. It focuses and inspects an
|
||||
existing Meet tab on the configured Chrome node. It does not open a new tab or
|
||||
create a new session; it reports the current blocker, such as login, admission,
|
||||
permissions, or audio-choice state. The CLI command talks to the configured
|
||||
Gateway, so the Gateway must be running and the Chrome node must be connected.
|
||||
existing Meet tab for the selected transport. With `chrome`, it uses local
|
||||
browser control through the Gateway; with `chrome-node`, it uses the configured
|
||||
Chrome node. It does not open a new tab or create a new session; it reports the
|
||||
current blocker, such as login, admission, permissions, or audio-choice state.
|
||||
The CLI command talks to the configured Gateway, so the Gateway must be running;
|
||||
`chrome-node` also requires the Chrome node to be connected.
|
||||
|
||||
### Twilio setup checks fail
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
|
||||
import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
|
||||
import { normalizeMeetUrl } from "./src/runtime.js";
|
||||
import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js";
|
||||
import { __testing as chromeTransportTesting } from "./src/transports/chrome.js";
|
||||
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js";
|
||||
|
||||
const voiceCallMocks = vi.hoisted(() => ({
|
||||
@@ -224,6 +225,7 @@ describe("google-meet plugin", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
chromeTransportTesting.setDepsForTest(null);
|
||||
});
|
||||
|
||||
it("defaults to chrome realtime with safe read-only tools", () => {
|
||||
@@ -1607,6 +1609,76 @@ describe("google-meet plugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers and inspects an existing local Chrome Meet tab", async () => {
|
||||
const callGatewayFromCli = vi.fn(
|
||||
async (
|
||||
_method: string,
|
||||
_opts: unknown,
|
||||
params?: unknown,
|
||||
_extra?: unknown,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const request = params as { path?: string; body?: { targetId?: string } };
|
||||
if (request.path === "/tabs") {
|
||||
return {
|
||||
tabs: [
|
||||
{
|
||||
targetId: "local-meet-tab",
|
||||
title: "Meet",
|
||||
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (request.path === "/tabs/focus") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (request.path === "/act") {
|
||||
return {
|
||||
result: JSON.stringify({
|
||||
inCall: false,
|
||||
manualActionRequired: true,
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected browser request path ${request.path}`);
|
||||
},
|
||||
);
|
||||
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
||||
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
|
||||
const tool = tools[0] as {
|
||||
execute: (
|
||||
id: string,
|
||||
params: unknown,
|
||||
) => Promise<{ details: { transport?: string; found?: boolean; browser?: unknown } }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", {
|
||||
action: "recover_current_tab",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
transport: "chrome",
|
||||
found: true,
|
||||
targetId: "local-meet-tab",
|
||||
browser: {
|
||||
manualActionRequired: true,
|
||||
manualActionReason: "meet-admission-required",
|
||||
},
|
||||
});
|
||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
||||
"browser.request",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ method: "POST", path: "/tabs/focus" }),
|
||||
{ progress: false },
|
||||
);
|
||||
expect(nodesInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes a test-speech action that joins the requested meeting", async () => {
|
||||
const { tools, nodesInvoke } = setup(
|
||||
{
|
||||
|
||||
@@ -557,7 +557,13 @@ export default definePluginEntry({
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const rt = await ensureRuntime();
|
||||
respond(true, await rt.recoverCurrentTab({ url: normalizeOptionalString(params?.url) }));
|
||||
respond(
|
||||
true,
|
||||
await rt.recoverCurrentTab({
|
||||
url: normalizeOptionalString(params?.url),
|
||||
transport: normalizeTransport(params?.transport),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
@@ -793,7 +799,12 @@ export default definePluginEntry({
|
||||
}
|
||||
case "recover_current_tab": {
|
||||
const rt = await ensureRuntime();
|
||||
return json(await rt.recoverCurrentTab({ url: normalizeOptionalString(raw.url) }));
|
||||
return json(
|
||||
await rt.recoverCurrentTab({
|
||||
url: normalizeOptionalString(raw.url),
|
||||
transport: normalizeTransport(raw.transport),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "setup_status": {
|
||||
const rt = await ensureRuntime();
|
||||
|
||||
@@ -793,6 +793,7 @@ describe("google-meet CLI", () => {
|
||||
config: { defaultTransport: "chrome-node" },
|
||||
runtime: {
|
||||
recoverCurrentTab: async () => ({
|
||||
transport: "chrome-node",
|
||||
nodeId: "node-1",
|
||||
found: true,
|
||||
targetId: "tab-1",
|
||||
|
||||
@@ -148,6 +148,10 @@ type JsonOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type RecoverTabOptions = JsonOptions & {
|
||||
transport?: GoogleMeetTransport;
|
||||
};
|
||||
|
||||
type CreateOptions = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
@@ -431,7 +435,8 @@ function writeRecoverCurrentTabResult(
|
||||
result: Awaited<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
|
||||
): void {
|
||||
writeStdoutLine("Google Meet current tab: %s", result.found ? "found" : "not found");
|
||||
writeStdoutLine("node: %s", result.nodeId);
|
||||
writeStdoutLine("transport: %s", result.transport);
|
||||
writeStdoutLine("node: %s", result.nodeId ?? "local/none");
|
||||
if (result.targetId) {
|
||||
writeStdoutLine("target: %s", result.targetId);
|
||||
}
|
||||
@@ -445,12 +450,15 @@ function writeRecoverCurrentTabResult(
|
||||
session: {
|
||||
id: "current-tab",
|
||||
url: result.browser.browserUrl ?? result.tab?.url ?? "unknown",
|
||||
transport: "chrome-node",
|
||||
transport: result.transport,
|
||||
mode: "transcribe",
|
||||
state: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
participantIdentity: "signed-in Google Chrome profile on a paired node",
|
||||
participantIdentity:
|
||||
result.transport === "chrome-node"
|
||||
? "signed-in Google Chrome profile on a paired node"
|
||||
: "signed-in Google Chrome profile",
|
||||
realtime: { enabled: false, toolPolicy: "safe-read-only" },
|
||||
chrome: {
|
||||
audioBackend: "blackhole-2ch",
|
||||
@@ -1960,12 +1968,13 @@ export function registerGoogleMeetCli(params: {
|
||||
|
||||
root
|
||||
.command("recover-tab")
|
||||
.description("Focus and inspect an existing Google Meet tab on the Chrome node")
|
||||
.description("Focus and inspect an existing Google Meet tab")
|
||||
.argument("[url]", "Optional Meet URL to match")
|
||||
.option("--transport <transport>", "Transport to inspect: chrome or chrome-node")
|
||||
.option("--json", "Print JSON output", false)
|
||||
.action(async (url: string | undefined, options: JsonOptions) => {
|
||||
.action(async (url: string | undefined, options: RecoverTabOptions) => {
|
||||
const rt = await params.ensureRuntime();
|
||||
const result = await rt.recoverCurrentTab({ url });
|
||||
const result = await rt.recoverCurrentTab({ url, transport: options.transport });
|
||||
if (options.json) {
|
||||
writeStdoutJson(result);
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
assertBlackHole2chAvailable,
|
||||
launchChromeMeet,
|
||||
launchChromeMeetOnNode,
|
||||
recoverCurrentMeetTab,
|
||||
recoverCurrentMeetTabOnNode,
|
||||
} from "./transports/chrome.js";
|
||||
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js";
|
||||
@@ -181,11 +182,22 @@ export class GoogleMeetRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
async recoverCurrentTab(request: { url?: string } = {}) {
|
||||
return recoverCurrentMeetTabOnNode({
|
||||
runtime: this.params.runtime,
|
||||
async recoverCurrentTab(request: { url?: string; transport?: GoogleMeetTransport } = {}) {
|
||||
const transport = resolveTransport(request.transport, this.params.config);
|
||||
if (transport === "twilio") {
|
||||
throw new Error("recover_current_tab only supports chrome or chrome-node transports");
|
||||
}
|
||||
const url = request.url ? normalizeMeetUrl(request.url) : undefined;
|
||||
if (transport === "chrome-node") {
|
||||
return recoverCurrentMeetTabOnNode({
|
||||
runtime: this.params.runtime,
|
||||
config: this.params.config,
|
||||
url,
|
||||
});
|
||||
}
|
||||
return recoverCurrentMeetTab({
|
||||
config: this.params.config,
|
||||
url: request.url ? normalizeMeetUrl(request.url) : undefined,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { callGatewayFromCli } from "openclaw/plugin-sdk/browser-node-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
@@ -23,6 +24,27 @@ import type { GoogleMeetChromeHealth } from "./types.js";
|
||||
|
||||
export const GOOGLE_MEET_SYSTEM_PROFILER_COMMAND = "/usr/sbin/system_profiler";
|
||||
|
||||
type BrowserRequestParams = {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
path: string;
|
||||
body?: unknown;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type BrowserRequestCaller = (params: BrowserRequestParams) => Promise<unknown>;
|
||||
|
||||
const chromeTransportDeps: {
|
||||
callGatewayFromCli: typeof callGatewayFromCli;
|
||||
} = {
|
||||
callGatewayFromCli,
|
||||
};
|
||||
|
||||
export const __testing = {
|
||||
setDepsForTest(deps: { callGatewayFromCli?: typeof callGatewayFromCli } | null) {
|
||||
chromeTransportDeps.callGatewayFromCli = deps?.callGatewayFromCli ?? callGatewayFromCli;
|
||||
},
|
||||
};
|
||||
|
||||
export function outputMentionsBlackHole2ch(output: string): boolean {
|
||||
return /\bBlackHole\s+2ch\b/i.test(output);
|
||||
}
|
||||
@@ -215,6 +237,23 @@ function parseMeetBrowserStatus(result: unknown): GoogleMeetChromeHealth | undef
|
||||
};
|
||||
}
|
||||
|
||||
async function callLocalBrowserRequest(params: BrowserRequestParams) {
|
||||
return await chromeTransportDeps.callGatewayFromCli(
|
||||
"browser.request",
|
||||
{
|
||||
json: true,
|
||||
timeout: String(params.timeoutMs + 5_000),
|
||||
},
|
||||
{
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
},
|
||||
{ progress: false },
|
||||
);
|
||||
}
|
||||
|
||||
function meetStatusScript(params: { guestName: string; autoJoin: boolean }) {
|
||||
return `() => {
|
||||
const text = (node) => (node?.innerText || node?.textContent || "").trim();
|
||||
@@ -412,11 +451,96 @@ function isRecoverableMeetTab(tab: BrowserTab, url?: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectRecoverableMeetTab(params: {
|
||||
callBrowser: BrowserRequestCaller;
|
||||
config: GoogleMeetConfig;
|
||||
timeoutMs: number;
|
||||
tab: BrowserTab;
|
||||
targetId: string;
|
||||
}) {
|
||||
await params.callBrowser({
|
||||
method: "POST",
|
||||
path: "/tabs/focus",
|
||||
body: { targetId: params.targetId },
|
||||
timeoutMs: Math.min(params.timeoutMs, 5_000),
|
||||
});
|
||||
const evaluated = await params.callBrowser({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
body: {
|
||||
kind: "evaluate",
|
||||
targetId: params.targetId,
|
||||
fn: meetStatusScript({
|
||||
guestName: params.config.chrome.guestName,
|
||||
autoJoin: false,
|
||||
}),
|
||||
},
|
||||
timeoutMs: Math.min(params.timeoutMs, 10_000),
|
||||
});
|
||||
const browser = parseMeetBrowserStatus(evaluated);
|
||||
const manual = browser?.manualActionRequired
|
||||
? browser.manualActionMessage || browser.manualActionReason
|
||||
: undefined;
|
||||
return {
|
||||
found: true,
|
||||
targetId: params.targetId,
|
||||
tab: params.tab,
|
||||
browser,
|
||||
message:
|
||||
manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused."),
|
||||
};
|
||||
}
|
||||
|
||||
export async function recoverCurrentMeetTab(params: {
|
||||
config: GoogleMeetConfig;
|
||||
url?: string;
|
||||
}): Promise<{
|
||||
transport: "chrome";
|
||||
nodeId?: undefined;
|
||||
found: boolean;
|
||||
targetId?: string;
|
||||
tab?: BrowserTab;
|
||||
browser?: GoogleMeetChromeHealth;
|
||||
message: string;
|
||||
}> {
|
||||
const timeoutMs = Math.max(1_000, params.config.chrome.joinTimeoutMs);
|
||||
const tabs = asBrowserTabs(
|
||||
await callLocalBrowserRequest({
|
||||
method: "GET",
|
||||
path: "/tabs",
|
||||
timeoutMs: Math.min(timeoutMs, 5_000),
|
||||
}),
|
||||
);
|
||||
const tab = tabs.find((entry) => isRecoverableMeetTab(entry, params.url));
|
||||
const targetId = tab?.targetId;
|
||||
if (!tab || !targetId) {
|
||||
return {
|
||||
transport: "chrome",
|
||||
found: false,
|
||||
tab,
|
||||
message: params.url
|
||||
? `No existing Meet tab matched ${params.url}.`
|
||||
: "No existing Meet tab found in local Chrome.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
transport: "chrome",
|
||||
...(await inspectRecoverableMeetTab({
|
||||
callBrowser: callLocalBrowserRequest,
|
||||
config: params.config,
|
||||
timeoutMs,
|
||||
tab,
|
||||
targetId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function recoverCurrentMeetTabOnNode(params: {
|
||||
runtime: PluginRuntime;
|
||||
config: GoogleMeetConfig;
|
||||
url?: string;
|
||||
}): Promise<{
|
||||
transport: "chrome-node";
|
||||
nodeId: string;
|
||||
found: boolean;
|
||||
targetId?: string;
|
||||
@@ -442,6 +566,7 @@ export async function recoverCurrentMeetTabOnNode(params: {
|
||||
const targetId = tab?.targetId;
|
||||
if (!tab || !targetId) {
|
||||
return {
|
||||
transport: "chrome-node",
|
||||
nodeId,
|
||||
found: false,
|
||||
tab,
|
||||
@@ -450,44 +575,31 @@ export async function recoverCurrentMeetTabOnNode(params: {
|
||||
: "No existing Meet tab found on the selected Chrome node.",
|
||||
};
|
||||
}
|
||||
await callBrowserProxyOnNode({
|
||||
runtime: params.runtime,
|
||||
nodeId,
|
||||
method: "POST",
|
||||
path: "/tabs/focus",
|
||||
body: { targetId },
|
||||
timeoutMs: Math.min(timeoutMs, 5_000),
|
||||
});
|
||||
const evaluated = await callBrowserProxyOnNode({
|
||||
runtime: params.runtime,
|
||||
nodeId,
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
body: {
|
||||
kind: "evaluate",
|
||||
targetId,
|
||||
fn: meetStatusScript({
|
||||
guestName: params.config.chrome.guestName,
|
||||
autoJoin: false,
|
||||
}),
|
||||
},
|
||||
timeoutMs: Math.min(timeoutMs, 10_000),
|
||||
});
|
||||
const browser = parseMeetBrowserStatus(evaluated);
|
||||
const manual = browser?.manualActionRequired
|
||||
? browser.manualActionMessage || browser.manualActionReason
|
||||
: undefined;
|
||||
return {
|
||||
transport: "chrome-node",
|
||||
nodeId,
|
||||
found: true,
|
||||
targetId,
|
||||
tab,
|
||||
browser,
|
||||
message:
|
||||
manual ?? (browser?.inCall ? "Existing Meet tab is in-call." : "Existing Meet tab focused."),
|
||||
...(await inspectRecoverableMeetTab({
|
||||
callBrowser: async (request) =>
|
||||
await callBrowserProxyOnNode({
|
||||
runtime: params.runtime,
|
||||
nodeId,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
body: request.body,
|
||||
timeoutMs: request.timeoutMs,
|
||||
}),
|
||||
config: params.config,
|
||||
timeoutMs,
|
||||
tab,
|
||||
targetId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type GoogleMeetCurrentTabRecoveryResult = Awaited<
|
||||
ReturnType<typeof recoverCurrentMeetTab | typeof recoverCurrentMeetTabOnNode>
|
||||
>;
|
||||
|
||||
export async function launchChromeMeetOnNode(params: {
|
||||
runtime: PluginRuntime;
|
||||
config: GoogleMeetConfig;
|
||||
|
||||
Reference in New Issue
Block a user