diff --git a/CHANGELOG.md b/CHANGELOG.md index 628d6cc8a39..529db9613a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/failover: forward embedded run abort signals into provider-owned model streams, cap implicit LLM idle watchdogs below long run timeouts, and mark 429 responses without usable retry timing as non-retryable so GitHub Copilot rate limits fail over or surface promptly instead of hanging until run timeout. Fixes #71120. +- Plugins/Google Meet: make meeting creation join by default, with an explicit URL-only opt-out, so agents that create a Meet also enter it. - Browser/tool: keep explicit AI snapshots from inheriting the efficient role-snapshot default and preserve numeric Playwright AI refs, so `--format ai` remains a real AI snapshot path. Fixes #62550. Thanks @ly85206559. - Gateway/config: keep in-process config patch reload comparisons on the resolved source snapshot when `${VAR}` env refs are restored on disk, avoiding false full gateway restarts for unchanged gateway/plugin secrets. Fixes #71208. Thanks @robbiethompson18. - Slack/messages: serialize write-client requests and whole outbound sends per target so rapid multi-message Slack replies preserve send order. Fixes #69101. (#69105) Thanks @nightq and @ztexydt-cqh. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index c2c4baafed8..d9b6fb48017 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -97,11 +97,16 @@ Or let an agent join through the `google_meet` tool: } ``` -Create a new meeting, then join it: +Create a new meeting and join it: ```bash -openclaw googlemeet create -openclaw googlemeet join https://meet.google.com/new-abcd-xyz --transport chrome-node +openclaw googlemeet create --transport chrome-node --mode realtime +``` + +Create only the URL without joining: + +```bash +openclaw googlemeet create --no-join ``` `googlemeet create` has two paths: @@ -115,24 +120,18 @@ openclaw googlemeet join https://meet.google.com/new-abcd-xyz --transport chrome Browser automation handles Meet's own first-run microphone prompt; that prompt is not treated as a Google login failure. -The command output includes a `source` field (`api` or `browser`) so agents can -explain which path was used. +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 +returns `joined: true` plus the join session. To only mint the URL, use +`create --no-join` on the CLI or pass `"join": false` to the tool. Or tell an agent: "Create a Google Meet, join it with realtime voice, and send -me the link." The agent should call `google_meet` with `action: "create"`, copy -the returned `meetingUri`, then call `google_meet` with `action: "join"` and -that URL. +me the link." The agent should call `google_meet` with `action: "create"` and +then share the returned `meetingUri`. ```json { - "action": "create" -} -``` - -```json -{ - "action": "join", - "url": "https://meet.google.com/new-abcd-xyz", + "action": "create", "transport": "chrome-node", "mode": "realtime" } @@ -475,11 +474,11 @@ Create a fresh Meet space: openclaw googlemeet create ``` -The command prints the new `meeting uri` and source. With OAuth credentials it -uses the official Google Meet API. Without OAuth credentials it uses the pinned -Chrome node's signed-in browser profile as a fallback. Agents can use the -`google_meet` tool with `action: "create"` to create a meeting, then call -`action: "join"` with the returned `meetingUri`. +The command prints the new `meeting uri`, source, and join session. With OAuth +credentials it uses the official Google Meet API. Without OAuth credentials it +uses the pinned Chrome node's signed-in browser profile as a fallback. Agents can +use the `google_meet` tool with `action: "create"` to create and join in one +step. For URL-only creation, pass `"join": false`. Example JSON output from the browser fallback: @@ -487,9 +486,16 @@ Example JSON output from the browser fallback: { "source": "browser", "meetingUri": "https://meet.google.com/abc-defg-hij", + "joined": true, "browser": { "nodeId": "ba0f4e4bc...", "targetId": "tab-1" + }, + "join": { + "session": { + "id": "meet_...", + "url": "https://meet.google.com/abc-defg-hij" + } } } ``` @@ -500,19 +506,26 @@ Example JSON output from API create: { "source": "api", "meetingUri": "https://meet.google.com/abc-defg-hij", + "joined": true, "space": { "name": "spaces/abc-defg-hij", "meetingCode": "abc-defg-hij", "meetingUri": "https://meet.google.com/abc-defg-hij" + }, + "join": { + "session": { + "id": "meet_...", + "url": "https://meet.google.com/abc-defg-hij" + } } } ``` -Creating a Meet only creates or discovers the meeting URL. The Chrome or -Chrome-node transport still needs a signed-in Google Chrome profile to join -through the browser. If the profile is signed out, OpenClaw reports -`manualActionRequired: true` or a browser fallback error and asks the operator -to finish Google login before retrying. +Creating a Meet joins by default. The Chrome or Chrome-node transport still +needs a signed-in Google Chrome profile to join through the browser. If the +profile is signed out, OpenClaw reports `manualActionRequired: true` or a +browser fallback error and asks the operator to finish Google login before +retrying. Set `preview.enrollmentAcknowledged: true` only after confirming your Cloud project, OAuth principal, and meeting participants are enrolled in the Google diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 55e83fc004c..e2338bfde8f 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -803,7 +803,7 @@ describe("google-meet plugin", () => { }); try { - await program.parseAsync(["googlemeet", "create"], { from: "user" }); + await program.parseAsync(["googlemeet", "create", "--no-join"], { from: "user" }); expect(stdout.output()).toContain("meeting uri: https://meet.google.com/new-abcd-xyz"); expect(stdout.output()).toContain("space: spaces/new-space"); } finally { @@ -811,7 +811,7 @@ describe("google-meet plugin", () => { } }); - it("creates a Meet through browser fallback when OAuth is not configured", async () => { + it("can create a Meet through browser fallback without joining when requested", async () => { const { methods, nodesInvoke } = setup( { defaultTransport: "chrome-node", @@ -861,12 +861,13 @@ describe("google-meet plugin", () => { | undefined; const respond = vi.fn(); - await handler?.({ params: {}, respond }); + await handler?.({ params: { join: false }, respond }); expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[1]).toMatchObject({ source: "browser", meetingUri: "https://meet.google.com/browser-made-url", + joined: false, browser: { nodeId: "node-1", targetId: "tab-1" }, }); expect(nodesInvoke).toHaveBeenCalledWith( @@ -880,6 +881,101 @@ describe("google-meet plugin", () => { ); }); + it("creates and joins a Meet through the create tool action by default", async () => { + const { tools, nodesInvoke } = setup( + { + defaultTransport: "chrome-node", + defaultMode: "transcribe", + chromeNode: { node: "parallels-macos" }, + }, + { + nodesInvokeHandler: async (params) => { + if (params.command === "googlemeet.chrome") { + return { payload: { launched: true } }; + } + const proxy = params.params as { + path?: string; + body?: { url?: string; targetId?: string; fn?: string }; + }; + if (proxy.path === "/tabs") { + return { payload: { result: { tabs: [] } } }; + } + if (proxy.path === "/tabs/open") { + return { + payload: { + result: { + targetId: + proxy.body?.url === "https://meet.google.com/new" ? "create-tab" : "join-tab", + title: "Meet", + url: proxy.body?.url, + }, + }, + }; + } + if (proxy.path === "/act" && proxy.body?.fn?.includes("meetUrlPattern")) { + return { + payload: { + result: { + ok: true, + targetId: "create-tab", + result: { + meetingUri: "https://meet.google.com/new-abcd-xyz", + browserUrl: "https://meet.google.com/new-abcd-xyz", + browserTitle: "Meet", + }, + }, + }, + }; + } + if (proxy.path === "/act") { + return { + payload: { + result: { + ok: true, + targetId: "join-tab", + result: JSON.stringify({ + inCall: true, + micMuted: false, + title: "Meet call", + url: "https://meet.google.com/new-abcd-xyz", + }), + }, + }, + }; + } + return { payload: { result: { ok: true } } }; + }, + }, + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ + details: { joined?: boolean; meetingUri?: string; join?: { session: { url: string } } }; + }>; + }; + + const result = await tool.execute("id", { action: "create" }); + + expect(result.details).toMatchObject({ + source: "browser", + joined: true, + meetingUri: "https://meet.google.com/new-abcd-xyz", + join: { session: { url: "https://meet.google.com/new-abcd-xyz" } }, + }); + expect(nodesInvoke).toHaveBeenCalledWith( + expect.objectContaining({ + command: "googlemeet.chrome", + params: expect.objectContaining({ + action: "start", + url: "https://meet.google.com/new-abcd-xyz", + launch: false, + }), + }), + ); + }); + it("reuses an existing browser create tab instead of opening duplicates", async () => { const { methods, nodesInvoke } = setup( { @@ -934,7 +1030,7 @@ describe("google-meet plugin", () => { | undefined; const respond = vi.fn(); - await handler?.({ params: {}, respond }); + await handler?.({ params: { join: false }, respond }); expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[1]).toMatchObject({ diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 57b9ec8b466..fc5582f803d 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -148,8 +148,14 @@ const GoogleMeetToolSchema = Type.Object({ "speak", "test_speech", ], - description: "Google Meet action to run", + description: + "Google Meet action to run. create creates a meeting and joins it by default; pass join=false to only mint a meeting URL.", }), + join: Type.Optional( + Type.Boolean({ + description: "For action=create, set false to create the URL without joining.", + }), + ), url: Type.Optional(Type.String({ description: "Explicit https://meet.google.com/... URL" })), transport: Type.Optional( Type.String({ enum: ["chrome", "chrome-node", "twilio"], description: "Join transport" }), @@ -240,6 +246,10 @@ function hasGoogleMeetOAuth(config: GoogleMeetConfig, raw: Record): boolean { + return raw.join !== false && raw.join !== "false"; +} + async function createMeetFromParams(params: { config: GoogleMeetConfig; runtime: OpenClawPluginApi["runtime"]; @@ -247,7 +257,12 @@ async function createMeetFromParams(params: { }) { if (hasGoogleMeetOAuth(params.config, params.raw)) { const { token: _token, ...result } = await createSpaceFromParams(params.config, params.raw); - return result; + return { + ...result, + joined: false, + nextAction: + "URL-only creation was requested. Call google_meet with action=join and url=meetingUri to enter the meeting.", + }; } const browser = await createMeetWithBrowserProxyOnNode({ runtime: params.runtime, @@ -256,6 +271,9 @@ async function createMeetFromParams(params: { return { source: browser.source, meetingUri: browser.meetingUri, + joined: false, + nextAction: + "URL-only creation was requested. Call google_meet with action=join and url=meetingUri to enter the meeting.", space: { name: `browser/${browser.meetingUri.split("/").pop()}`, meetingUri: browser.meetingUri, @@ -270,6 +288,31 @@ async function createMeetFromParams(params: { }; } +async function createAndJoinMeetFromParams(params: { + config: GoogleMeetConfig; + runtime: OpenClawPluginApi["runtime"]; + raw: Record; + ensureRuntime: () => Promise; +}) { + const created = await createMeetFromParams(params); + const rt = await params.ensureRuntime(); + const join = await rt.join({ + url: created.meetingUri, + transport: normalizeTransport(params.raw.transport), + mode: normalizeMode(params.raw.mode), + dialInNumber: normalizeOptionalString(params.raw.dialInNumber), + pin: normalizeOptionalString(params.raw.pin), + dtmfSequence: normalizeOptionalString(params.raw.dtmfSequence), + message: normalizeOptionalString(params.raw.message), + }); + return { + ...created, + joined: true, + nextAction: "Share meetingUri with participants; the OpenClaw agent has started the join flow.", + join, + }; +} + export default definePluginEntry({ id: "google-meet", name: "Google Meet", @@ -324,7 +367,17 @@ export default definePluginEntry({ async ({ params, respond }: GatewayRequestHandlerOptions) => { try { const raw = asParamRecord(params); - respond(true, await createMeetFromParams({ config, runtime: api.runtime, raw })); + respond( + true, + shouldJoinCreatedMeet(raw) + ? await createAndJoinMeetFromParams({ + config, + runtime: api.runtime, + raw, + ensureRuntime, + }) + : await createMeetFromParams({ config, runtime: api.runtime, raw }), + ); } catch (err) { sendError(respond, err); } @@ -434,7 +487,16 @@ export default definePluginEntry({ ); } case "create": { - return json(await createMeetFromParams({ config, runtime: api.runtime, raw })); + return json( + shouldJoinCreatedMeet(raw) + ? await createAndJoinMeetFromParams({ + config, + runtime: api.runtime, + raw, + ensureRuntime, + }) + : await createMeetFromParams({ config, runtime: api.runtime, raw }), + ); } case "test_speech": { const rt = await ensureRuntime(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 862ca7ff310..8a168de7391 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -54,6 +54,13 @@ type CreateOptions = { clientId?: string; clientSecret?: string; expiresAt?: string; + join?: boolean; + transport?: GoogleMeetTransport; + mode?: GoogleMeetMode; + message?: string; + dialInNumber?: string; + pin?: string; + dtmfSequence?: string; json?: boolean; }; @@ -233,14 +240,38 @@ export function registerGoogleMeetCli(params: { .option("--client-id ", "OAuth client id override") .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--no-join", "Only create the meeting URL; do not join it") + .option("--transport ", "Join transport: chrome, chrome-node, or twilio") + .option( + "--mode ", + "Join mode: realtime for live talk-back, transcribe for observe/control", + ) + .option("--message ", "Realtime speech to trigger after join") + .option("--dial-in-number ", "Meet dial-in number for Twilio transport") + .option("--pin ", "Meet phone PIN; # is appended if omitted") + .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") .option("--json", "Print JSON output", false) .action(async (options: CreateOptions) => { if (!hasCreateOAuth(params.config, options)) { const rt = await params.ensureRuntime(); const result = await rt.createViaBrowser(); + const join = + options.join !== false + ? await rt.join({ + url: result.meetingUri, + transport: options.transport, + mode: options.mode, + message: options.message, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }) + : undefined; const payload = { source: result.source, meetingUri: result.meetingUri, + joined: Boolean(join), + ...(join ? { join } : {}), browser: { nodeId: result.nodeId, targetId: result.targetId, @@ -255,16 +286,37 @@ export function registerGoogleMeetCli(params: { writeStdoutLine("meeting uri: %s", result.meetingUri); writeStdoutLine("source: browser"); writeStdoutLine("node: %s", result.nodeId); + if (join) { + writeStdoutLine("joined: %s", join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); + } return; } const token = await resolveGoogleMeetAccessToken( resolveCreateTokenOptions(params.config, options), ); const result = await createGoogleMeetSpace({ accessToken: token.accessToken }); + const join = + options.join !== false + ? await ( + await params.ensureRuntime() + ).join({ + url: result.meetingUri, + transport: options.transport, + mode: options.mode, + message: options.message, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }) + : undefined; if (options.json) { writeStdoutJson({ ...result, tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + joined: Boolean(join), + ...(join ? { join } : {}), }); return; } @@ -277,6 +329,11 @@ export function registerGoogleMeetCli(params: { "token source: %s", token.refreshed ? "refresh-token" : "cached-access-token", ); + if (join) { + writeStdoutLine("joined: %s", join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); + } }); root