mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
feat(google-meet): create meeting spaces
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<stri
|
||||
return { meeting, token, space };
|
||||
}
|
||||
|
||||
async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
|
||||
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(
|
||||
|
||||
@@ -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 <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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")
|
||||
|
||||
@@ -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<GoogleMeetCreateSpaceResult> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user