From b20208fa4c6cd4d64a61ce0094324162db1a55e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 22:11:07 +0100 Subject: [PATCH] feat(google-meet): create meeting spaces --- docs/plugins/google-meet.md | 63 +++++++++++++++++++++++ extensions/google-meet/index.test.ts | 76 ++++++++++++++++++++++++++++ extensions/google-meet/index.ts | 36 ++++++++++++- extensions/google-meet/src/cli.ts | 66 +++++++++++++++++++++++- extensions/google-meet/src/meet.ts | 41 +++++++++++++++ extensions/google-meet/src/oauth.ts | 1 + 6 files changed, 281 insertions(+), 2 deletions(-) diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index ea5e64b5732..92186901728 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -2,6 +2,7 @@ summary: "Google Meet plugin: join explicit Meet URLs through Chrome or Twilio with realtime voice defaults" read_when: - You want an OpenClaw agent to join a Google Meet call + - You want an OpenClaw agent to create a new Google Meet call - You are configuring Chrome, Chrome node, or Twilio as a Google Meet transport title: "Google Meet plugin" --- @@ -9,6 +10,8 @@ title: "Google Meet plugin" Google Meet participant support for OpenClaw — the plugin is explicit by design: - It only joins an explicit `https://meet.google.com/...` URL. +- It can create a new Meet space through the Google Meet API, then join the + returned URL. - `realtime` voice is the default mode. - Realtime voice can call back into the full OpenClaw agent when deeper reasoning or tools are needed. @@ -94,6 +97,33 @@ Or let an agent join through the `google_meet` tool: } ``` +Create a new meeting, then join it: + +```bash +openclaw googlemeet create +openclaw googlemeet join https://meet.google.com/new-abcd-xyz --transport chrome-node +``` + +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. + +```json +{ + "action": "create" +} +``` + +```json +{ + "action": "join", + "url": "https://meet.google.com/new-abcd-xyz", + "transport": "chrome-node", + "mode": "realtime" +} +``` + 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. @@ -381,6 +411,11 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE, localhost callback on `http://localhost:8085/oauth2callback`, and a manual copy/paste flow with `--manual`. +The OAuth consent includes Meet space creation, Meet space read access, and +Meet conference media read access. If you authenticated before meeting creation +support existed, rerun `openclaw googlemeet auth login --json` so the refresh +token has the `meetings.space.created` scope. + These environment variables are accepted as fallbacks: - `OPENCLAW_GOOGLE_MEET_CLIENT_ID` or `GOOGLE_MEET_CLIENT_ID` @@ -404,6 +439,22 @@ Run preflight before media work: openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij ``` +Create a fresh Meet space with the same OAuth config: + +```bash +openclaw googlemeet create +``` + +The command prints the new `meeting uri` and `space`. Agents can use the +`google_meet` tool with `action: "create"` to create a meeting, then call +`action: "join"` with the returned `meetingUri`. + +Creating a Meet space only creates 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` and asks the operator to finish Google login before +retrying the join. + Set `preview.enrollmentAcknowledged: true` only after confirming your Cloud project, OAuth principal, and meeting participants are enrolled in the Google Workspace Developer Preview Program for Meet media APIs. @@ -687,6 +738,18 @@ Common manual actions: - Grant Chrome microphone/camera permissions. - Close or repair a stuck Meet permission dialog. +### Meeting creation fails + +`googlemeet create` uses the Google Meet API `spaces.create` endpoint. Confirm: + +- `oauth.clientId` and `oauth.refreshToken` are configured, or matching + `OPENCLAW_GOOGLE_MEET_*` environment variables are present. +- The refresh token was minted after create support was added. Older tokens may + be missing the `meetings.space.created` scope; rerun + `openclaw googlemeet auth login --json` and update plugin config. +- The Google Cloud project and OAuth principal are allowed to use the required + Google Meet API scopes. + ### Agent joins but does not talk Check the realtime path: diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 7f110adc9a3..7e272336ed8 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -10,6 +10,7 @@ import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js"; import { buildGoogleMeetPreflightReport, + createGoogleMeetSpace, fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; @@ -348,6 +349,7 @@ describe("google-meet plugin", () => { type: "string", enum: [ "join", + "create", "status", "setup_status", "resolve_space", @@ -411,6 +413,37 @@ describe("google-meet plugin", () => { ); }); + it("creates Meet spaces and returns the meeting URL", async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + name: "spaces/new-space", + meetingCode: "new-abcd-xyz", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(createGoogleMeetSpace({ accessToken: "token" })).resolves.toMatchObject({ + meetingUri: "https://meet.google.com/new-abcd-xyz", + space: { name: "spaces/new-space" }, + }); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://meet.googleapis.com/v2/spaces", + init: expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer token" }), + body: "{}", + }), + policy: { allowedHostnames: ["meet.googleapis.com"] }, + auditContext: "google-meet.spaces.create", + }), + ); + }); + it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => { expect( buildGoogleMeetPreflightReport({ @@ -438,6 +471,7 @@ describe("google-meet plugin", () => { expect(url.searchParams.get("client_id")).toBe("client-id"); expect(url.searchParams.get("code_challenge")).toBe("challenge"); expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("scope")).toContain("meetings.space.created"); expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); await expect( @@ -701,6 +735,48 @@ describe("google-meet plugin", () => { } }); + it("CLI create prints the new meeting URL", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes("oauth2.googleapis.com")) { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + name: "spaces/new-space", + meetingCode: "new-abcd-xyz", + meetingUri: "https://meet.google.com/new-abcd-xyz", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({ + oauth: { clientId: "client-id", refreshToken: "refresh-token" }, + }), + ensureRuntime: async () => ({}) as GoogleMeetRuntime, + }); + + try { + await program.parseAsync(["googlemeet", "create"], { from: "user" }); + expect(stdout.output()).toContain("meeting uri: https://meet.google.com/new-abcd-xyz"); + expect(stdout.output()).toContain("space: spaces/new-space"); + } finally { + stdout.restore(); + } + }); + it("launches Chrome after the BlackHole check", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin" }); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index c9b64993728..e1a4e9eeed8 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -10,7 +10,11 @@ import { type GoogleMeetMode, type GoogleMeetTransport, } from "./src/config.js"; -import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js"; +import { + buildGoogleMeetPreflightReport, + createGoogleMeetSpace, + fetchGoogleMeetSpace, +} from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; @@ -134,6 +138,7 @@ const GoogleMeetToolSchema = Type.Object({ action: Type.String({ enum: [ "join", + "create", "status", "setup_status", "resolve_space", @@ -213,6 +218,18 @@ async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { + const token = await resolveGoogleMeetAccessToken({ + clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, + clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret, + refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken, + accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken, + expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt, + }); + const result = await createGoogleMeetSpace({ accessToken: token.accessToken }); + return { token, ...result }; +} + export default definePluginEntry({ id: "google-meet", name: "Google Meet", @@ -262,6 +279,19 @@ export default definePluginEntry({ }, ); + api.registerGatewayMethod( + "googlemeet.create", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const { token: _token, ...result } = await createSpaceFromParams(config, raw); + respond(true, result); + } catch (err) { + sendError(respond, err); + } + }, + ); + api.registerGatewayMethod( "googlemeet.status", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -364,6 +394,10 @@ export default definePluginEntry({ }), ); } + case "create": { + const { token: _token, ...result } = await createSpaceFromParams(config, raw); + return json(result); + } case "test_speech": { const rt = await ensureRuntime(); return json( diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 426efb03de9..a2f6fa8efe3 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -2,7 +2,11 @@ import { createInterface } from "node:readline/promises"; import { format } from "node:util"; import type { Command } from "commander"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; -import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./meet.js"; +import { + buildGoogleMeetPreflightReport, + createGoogleMeetSpace, + fetchGoogleMeetSpace, +} from "./meet.js"; import { buildGoogleMeetAuthUrl, createGoogleMeetOAuthState, @@ -44,6 +48,15 @@ type SetupOptions = { json?: boolean; }; +type CreateOptions = { + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; + json?: boolean; +}; + function writeStdoutJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } @@ -113,6 +126,25 @@ function resolveTokenOptions( }; } +function resolveCreateTokenOptions( + config: GoogleMeetConfig, + options: CreateOptions, +): { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; +} { + return { + clientId: options.clientId?.trim() || config.oauth.clientId, + clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret, + refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken, + accessToken: options.accessToken?.trim() || config.oauth.accessToken, + expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, + }; +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -184,6 +216,38 @@ export function registerGoogleMeetCli(params: { writeStdoutJson(payload); }); + root + .command("create") + .description("Create a new Google Meet space and print its meeting URL") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .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("--json", "Print JSON output", false) + .action(async (options: CreateOptions) => { + const token = await resolveGoogleMeetAccessToken( + resolveCreateTokenOptions(params.config, options), + ); + const result = await createGoogleMeetSpace({ accessToken: token.accessToken }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeStdoutLine("meeting uri: %s", result.meetingUri); + writeStdoutLine("space: %s", result.space.name); + if (result.space.meetingCode) { + writeStdoutLine("meeting code: %s", result.space.meetingCode); + } + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("join") .argument("[url]", "Explicit https://meet.google.com/... URL") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 5d38bd3dd61..fac7bc94c9c 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -23,6 +23,11 @@ export type GoogleMeetPreflightReport = { blockers: string[]; }; +export type GoogleMeetCreateSpaceResult = { + space: GoogleMeetSpace; + meetingUri: string; +}; + export function normalizeGoogleMeetSpaceName(input: string): string { const trimmed = input.trim(); if (!trimmed) { @@ -87,6 +92,42 @@ export async function fetchGoogleMeetSpace(params: { } } +export async function createGoogleMeetSpace(params: { + accessToken: string; +}): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: `${GOOGLE_MEET_API_BASE_URL}/spaces`, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: "{}", + }, + policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + auditContext: "google-meet.spaces.create", + }); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google Meet spaces.create failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as GoogleMeetSpace; + if (!payload.name?.trim()) { + throw new Error("Google Meet spaces.create response was missing name"); + } + const meetingUri = payload.meetingUri?.trim(); + if (!meetingUri) { + throw new Error("Google Meet spaces.create response was missing meetingUri"); + } + return { space: payload, meetingUri }; + } finally { + await release(); + } +} + export function buildGoogleMeetPreflightReport(params: { input: string; space: GoogleMeetSpace; diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index c78d64fd205..d5ece94f886 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -11,6 +11,7 @@ export const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/aut export const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token"; const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com"; export const GOOGLE_MEET_SCOPES = [ + "https://www.googleapis.com/auth/meetings.space.created", "https://www.googleapis.com/auth/meetings.space.readonly", "https://www.googleapis.com/auth/meetings.conference.media.readonly", ] as const;