From 59a8afe6fa030f18af662e125e7dd826ce9a53ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 21:18:45 +0100 Subject: [PATCH] feat: add Google Meet participant plugin --- .github/labeler.yml | 5 + docs/docs.json | 1 + docs/plugins/google-meet.md | 225 +++++++++ extensions/google-meet/index.test.ts | 470 ++++++++++++++++++ extensions/google-meet/index.ts | 362 ++++++++++++++ extensions/google-meet/openclaw.plugin.json | 316 ++++++++++++ extensions/google-meet/package.json | 40 ++ extensions/google-meet/src/cli.ts | 307 ++++++++++++ extensions/google-meet/src/config.ts | 318 ++++++++++++ extensions/google-meet/src/meet.ts | 100 ++++ extensions/google-meet/src/oauth.ts | 214 ++++++++ extensions/google-meet/src/realtime.ts | 239 +++++++++ extensions/google-meet/src/runtime.ts | 193 +++++++ extensions/google-meet/src/setup.ts | 86 ++++ .../google-meet/src/transports/chrome.ts | 128 +++++ .../google-meet/src/transports/twilio.ts | 46 ++ .../google-meet/src/transports/types.ts | 50 ++ .../google-meet/src/voice-call-gateway.ts | 84 ++++ extensions/google-meet/tsconfig.json | 16 + pnpm-lock.yaml | 16 + .../package-manifest.contract.test.ts | 4 + 21 files changed, 3220 insertions(+) create mode 100644 docs/plugins/google-meet.md create mode 100644 extensions/google-meet/index.test.ts create mode 100644 extensions/google-meet/index.ts create mode 100644 extensions/google-meet/openclaw.plugin.json create mode 100644 extensions/google-meet/package.json create mode 100644 extensions/google-meet/src/cli.ts create mode 100644 extensions/google-meet/src/config.ts create mode 100644 extensions/google-meet/src/meet.ts create mode 100644 extensions/google-meet/src/oauth.ts create mode 100644 extensions/google-meet/src/realtime.ts create mode 100644 extensions/google-meet/src/runtime.ts create mode 100644 extensions/google-meet/src/setup.ts create mode 100644 extensions/google-meet/src/transports/chrome.ts create mode 100644 extensions/google-meet/src/transports/twilio.ts create mode 100644 extensions/google-meet/src/transports/types.ts create mode 100644 extensions/google-meet/src/voice-call-gateway.ts create mode 100644 extensions/google-meet/tsconfig.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 5076dab1581..38ef333b691 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -24,6 +24,11 @@ - any-glob-to-any-file: - "extensions/googlechat/**" - "docs/channels/googlechat.md" +"plugin: google-meet": + - changed-files: + - any-glob-to-any-file: + - "extensions/google-meet/**" + - "docs/plugins/google-meet.md" "channel: imessage": - changed-files: - any-glob-to-any-file: diff --git a/docs/docs.json b/docs/docs.json index 99595347034..65403aeda9b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1130,6 +1130,7 @@ "plugins/community", "plugins/bundles", "plugins/codex-harness", + "plugins/google-meet", "plugins/webhooks", "plugins/voice-call", "plugins/memory-wiki", diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md new file mode 100644 index 00000000000..37a5aed32dc --- /dev/null +++ b/docs/plugins/google-meet.md @@ -0,0 +1,225 @@ +--- +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 are configuring Chrome or Twilio as a Google Meet transport +title: "Google Meet Plugin" +--- + +# 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. +- `realtime` voice is the default mode. +- Auth starts as personal Google OAuth or an already signed-in Chrome profile. +- There is no automatic consent announcement. +- The default Chrome audio backend is `BlackHole 2ch`. +- Twilio accepts a dial-in number plus optional PIN or DTMF sequence. +- The CLI command is `googlemeet`; `meet` is reserved for broader agent + teleconference workflows. + +## Transports + +### Chrome + +Chrome transport opens the Meet URL in Google Chrome and joins as the signed-in +Chrome profile. On macOS, the plugin checks for `BlackHole 2ch` before launch. +If configured, it also runs an audio bridge health command and startup command +before opening Chrome. + +```bash +openclaw googlemeet join https://meet.google.com/abc-defg-hij --transport chrome +``` + +Route Chrome microphone and speaker audio through the local OpenClaw audio +bridge. If `BlackHole 2ch` is not installed, the join fails with a setup error +instead of silently joining without an audio path. + +### Twilio + +Twilio transport is a strict dial plan delegated to the Voice Call plugin. It +does not parse Meet pages for phone numbers. + +```bash +openclaw googlemeet join https://meet.google.com/abc-defg-hij \ + --transport twilio \ + --dial-in-number +15551234567 \ + --pin 123456 +``` + +Use `--dtmf-sequence` when the meeting needs a custom sequence: + +```bash +openclaw googlemeet join https://meet.google.com/abc-defg-hij \ + --transport twilio \ + --dial-in-number +15551234567 \ + --dtmf-sequence ww123456# +``` + +## OAuth and preflight + +Google Meet Media API access uses a personal OAuth client first. Configure +`oauth.clientId` and optionally `oauth.clientSecret`, then run: + +```bash +openclaw googlemeet auth login --json +``` + +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`. + +These environment variables are accepted as fallbacks: + +- `OPENCLAW_GOOGLE_MEET_CLIENT_ID` or `GOOGLE_MEET_CLIENT_ID` +- `OPENCLAW_GOOGLE_MEET_CLIENT_SECRET` or `GOOGLE_MEET_CLIENT_SECRET` +- `OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN` or `GOOGLE_MEET_REFRESH_TOKEN` +- `OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN` or `GOOGLE_MEET_ACCESS_TOKEN` +- `OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT` or + `GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT` +- `OPENCLAW_GOOGLE_MEET_DEFAULT_MEETING` or `GOOGLE_MEET_DEFAULT_MEETING` +- `OPENCLAW_GOOGLE_MEET_PREVIEW_ACK` or `GOOGLE_MEET_PREVIEW_ACK` + +Resolve a Meet URL, code, or `spaces/{id}` through `spaces.get`: + +```bash +openclaw googlemeet resolve-space --meeting https://meet.google.com/abc-defg-hij +``` + +Run preflight before media work: + +```bash +openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij +``` + +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. + +## Config + +Set config under `plugins.entries.google-meet.config`: + +```json5 +{ + plugins: { + entries: { + "google-meet": { + enabled: true, + config: { + defaultTransport: "chrome", + defaultMode: "realtime", + defaults: { + meeting: "https://meet.google.com/abc-defg-hij", + }, + preview: { + enrollmentAcknowledged: false, + }, + chrome: { + audioBackend: "blackhole-2ch", + launch: true, + browserProfile: "Default", + // Command-pair bridge: input writes 8 kHz G.711 mu-law audio to stdout. + audioInputCommand: [ + "rec", + "-q", + "-t", + "raw", + "-r", + "8000", + "-c", + "1", + "-e", + "mu-law", + "-b", + "8", + "-", + ], + // Output reads 8 kHz G.711 mu-law audio from stdin. + audioOutputCommand: [ + "play", + "-q", + "-t", + "raw", + "-r", + "8000", + "-c", + "1", + "-e", + "mu-law", + "-b", + "8", + "-", + ], + }, + twilio: { + defaultDialInNumber: "+15551234567", + defaultPin: "123456", + }, + voiceCall: { + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789", + dtmfDelayMs: 2500, + }, + realtime: { + provider: "openai", + model: "gpt-realtime", + instructions: "You are joining a private Google Meet as Peter's OpenClaw agent. Keep replies brief unless asked.", + toolPolicy: "safe-read-only", + providers: { + openai: { + apiKey: { env: "OPENAI_API_KEY" }, + }, + }, + }, + auth: { + provider: "google-oauth", + }, + oauth: { + clientId: "your-google-oauth-client-id.apps.googleusercontent.com", + refreshToken: "stored-refresh-token", + }, + }, + }, + }, + }, +} +``` + +## Tool + +Agents can use the `google_meet` tool: + +```json +{ + "action": "join", + "url": "https://meet.google.com/abc-defg-hij", + "transport": "chrome", + "mode": "realtime" +} +``` + +Use `action: "status"` to list active sessions or inspect a session ID. Use +`action: "leave"` to mark a session ended. + +## Notes + +Google Meet's official media API is receive-oriented, so speaking into a Meet +call still needs a participant path. This plugin keeps that boundary visible: +Chrome handles browser participation and local audio routing; Twilio handles +phone dial-in participation. + +Chrome realtime mode needs either: + +- `chrome.audioInputCommand` plus `chrome.audioOutputCommand`: OpenClaw owns the + realtime model bridge and pipes 8 kHz G.711 mu-law audio between those + commands and the selected realtime voice provider. +- `chrome.audioBridgeCommand`: an external bridge command owns the whole local + audio path and must exit after starting or validating its daemon. + +For clean duplex audio, route Meet output and Meet microphone through separate +virtual devices or a Loopback-style virtual device graph. A single shared +BlackHole device can echo other participants back into the call. diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts new file mode 100644 index 00000000000..7f202177a38 --- /dev/null +++ b/extensions/google-meet/index.test.ts @@ -0,0 +1,470 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.ts"; +import plugin from "./index.js"; +import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js"; +import { + buildGoogleMeetPreflightReport, + fetchGoogleMeetSpace, + normalizeGoogleMeetSpaceName, +} from "./src/meet.js"; +import { + buildGoogleMeetAuthUrl, + refreshGoogleMeetAccessToken, + resolveGoogleMeetAccessToken, +} from "./src/oauth.js"; +import { startCommandRealtimeAudioBridge } from "./src/realtime.js"; +import { normalizeMeetUrl } from "./src/runtime.js"; +import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js"; + +const voiceCallMocks = vi.hoisted(() => ({ + joinMeetViaVoiceCallGateway: vi.fn(async () => ({ callId: "call-1", dtmfSent: true })), +})); + +vi.mock("./src/voice-call-gateway.js", () => ({ + joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, +})); + +const noopLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +type TestBridgeProcess = { + stdin?: { write(chunk: unknown): unknown } | null; + stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; + stderr: PassThrough; + killed: boolean; + kill: ReturnType; + on: EventEmitter["on"]; +}; + +function setup(config: Record = {}) { + const methods = new Map(); + const tools: unknown[] = []; + const cliRegistrations: unknown[] = []; + const runCommandWithTimeout = vi.fn(async (argv: string[]) => { + if (argv[0] === "system_profiler") { + return { code: 0, stdout: "BlackHole 2ch", stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }); + const api = createTestPluginApi({ + id: "google-meet", + name: "Google Meet", + description: "test", + version: "0", + source: "test", + pluginConfig: config, + runtime: { + system: { + runCommandWithTimeout, + formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."), + }, + } as unknown as OpenClawPluginApi["runtime"], + logger: noopLogger, + registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler), + registerTool: (tool: unknown) => tools.push(tool), + registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts), + }); + plugin.register(api); + return { cliRegistrations, methods, tools, runCommandWithTimeout }; +} + +describe("google-meet plugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("defaults to chrome realtime with safe read-only tools", () => { + expect(resolveGoogleMeetConfig({})).toMatchObject({ + enabled: true, + defaults: {}, + preview: { enrollmentAcknowledged: false }, + defaultTransport: "chrome", + defaultMode: "realtime", + chrome: { audioBackend: "blackhole-2ch", launch: true }, + voiceCall: { enabled: true, requestTimeoutMs: 30000, dtmfDelayMs: 2500 }, + realtime: { toolPolicy: "safe-read-only" }, + oauth: {}, + auth: { provider: "google-oauth" }, + }); + }); + + it("uses env fallbacks for OAuth, preview, and default meeting values", () => { + expect( + resolveGoogleMeetConfigWithEnv( + {}, + { + OPENCLAW_GOOGLE_MEET_CLIENT_ID: "client-id", + GOOGLE_MEET_CLIENT_SECRET: "client-secret", + OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN: "refresh-token", + GOOGLE_MEET_ACCESS_TOKEN: "access-token", + OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT: "123456", + GOOGLE_MEET_DEFAULT_MEETING: "https://meet.google.com/abc-defg-hij", + OPENCLAW_GOOGLE_MEET_PREVIEW_ACK: "true", + }, + ), + ).toMatchObject({ + defaults: { meeting: "https://meet.google.com/abc-defg-hij" }, + preview: { enrollmentAcknowledged: true }, + oauth: { + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + accessToken: "access-token", + expiresAt: 123456, + }, + }); + }); + + it("requires explicit Meet URLs", () => { + expect(normalizeMeetUrl("https://meet.google.com/abc-defg-hij")).toBe( + "https://meet.google.com/abc-defg-hij", + ); + expect(() => normalizeMeetUrl("https://example.com/abc-defg-hij")).toThrow("meet.google.com"); + }); + + it("advertises only the googlemeet CLI descriptor", () => { + const { cliRegistrations } = setup(); + + expect(cliRegistrations).toContainEqual({ + commands: ["googlemeet"], + descriptors: [ + { + name: "googlemeet", + description: "Join and manage Google Meet calls", + hasSubcommands: true, + }, + ], + }); + }); + + it("normalizes Meet URLs, codes, and space names for the Meet API", () => { + expect(normalizeGoogleMeetSpaceName("spaces/abc-defg-hij")).toBe("spaces/abc-defg-hij"); + expect(normalizeGoogleMeetSpaceName("abc-defg-hij")).toBe("spaces/abc-defg-hij"); + expect(normalizeGoogleMeetSpaceName("https://meet.google.com/abc-defg-hij")).toBe( + "spaces/abc-defg-hij", + ); + expect(() => normalizeGoogleMeetSpaceName("https://example.com/abc-defg-hij")).toThrow( + "meet.google.com", + ); + }); + + it("fetches Meet spaces without percent-encoding the spaces path separator", async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchGoogleMeetSpace({ + accessToken: "token", + meeting: "spaces/abc-defg-hij", + }), + ).resolves.toMatchObject({ name: "spaces/abc-defg-hij" }); + expect(fetchMock).toHaveBeenCalledWith( + "https://meet.googleapis.com/v2/spaces/abc-defg-hij", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + + it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => { + expect( + buildGoogleMeetPreflightReport({ + input: "abc-defg-hij", + space: { name: "spaces/abc-defg-hij" }, + previewAcknowledged: false, + tokenSource: "cached-access-token", + }), + ).toMatchObject({ + resolvedSpaceName: "spaces/abc-defg-hij", + previewAcknowledged: false, + blockers: [expect.stringContaining("Developer Preview Program")], + }); + }); + + it("builds Meet OAuth URLs and prefers fresh cached access tokens", async () => { + const url = new URL( + buildGoogleMeetAuthUrl({ + clientId: "client-id", + challenge: "challenge", + state: "state", + }), + ); + expect(url.hostname).toBe("accounts.google.com"); + 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.conference.media.readonly"); + + await expect( + resolveGoogleMeetAccessToken({ + accessToken: "cached-token", + expiresAt: Date.now() + 120_000, + }), + ).resolves.toEqual({ + accessToken: "cached-token", + expiresAt: expect.any(Number), + refreshed: false, + }); + }); + + it("refreshes Google Meet access tokens with a refresh-token grant", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response( + JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + refreshGoogleMeetAccessToken({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }), + ).resolves.toMatchObject({ + accessToken: "new-access-token", + tokenType: "Bearer", + }); + const body = fetchMock.mock.calls[0]?.[1]?.body; + expect(body).toBeInstanceOf(URLSearchParams); + const params = body as URLSearchParams; + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBe("refresh-token"); + }); + + it("builds Twilio dial plans from a PIN", () => { + expect(normalizeDialInNumber("+1 (555) 123-4567")).toBe("+15551234567"); + expect(buildMeetDtmfSequence({ pin: "123 456" })).toBe("123456#"); + expect(buildMeetDtmfSequence({ dtmfSequence: "ww123#" })).toBe("ww123#"); + }); + + it("joins a Twilio session through the tool without page parsing", async () => { + const { tools } = setup({ defaultTransport: "twilio" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise<{ details: { session: unknown } }>; + }; + const result = await tool.execute("id", { + action: "join", + url: "https://meet.google.com/abc-defg-hij", + dialInNumber: "+15551234567", + pin: "123456", + }); + + expect(result.details.session).toMatchObject({ + transport: "twilio", + mode: "realtime", + twilio: { + dialInNumber: "+15551234567", + pinProvided: true, + dtmfSequence: "123456#", + voiceCallId: "call-1", + dtmfSent: true, + }, + }); + expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith({ + config: expect.objectContaining({ defaultTransport: "twilio" }), + dialInNumber: "+15551234567", + dtmfSequence: "123456#", + }); + }); + + it("reports setup status through the tool", async () => { + const { tools } = setup({ + chrome: { + audioInputCommand: ["openclaw-audio-bridge", "capture"], + audioOutputCommand: ["openclaw-audio-bridge", "play"], + }, + }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise<{ details: { ok?: boolean } }>; + }; + + const result = await tool.execute("id", { action: "setup_status" }); + + expect(result.details.ok).toBe(true); + }); + + it("launches Chrome after the BlackHole check", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const { methods, runCommandWithTimeout } = setup({ + defaultMode: "transcribe", + }); + const handler = methods.get("googlemeet.join") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ + params: { url: "https://meet.google.com/abc-defg-hij" }, + respond, + }); + + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(runCommandWithTimeout).toHaveBeenNthCalledWith( + 1, + ["system_profiler", "SPAudioDataType"], + { timeoutMs: 10000 }, + ); + expect(runCommandWithTimeout).toHaveBeenNthCalledWith( + 2, + ["open", "-a", "Google Chrome", "https://meet.google.com/abc-defg-hij"], + { timeoutMs: 30000 }, + ); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + it("runs configured Chrome audio bridge commands before launch", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const { methods, runCommandWithTimeout } = setup({ + chrome: { + audioBridgeHealthCommand: ["bridge", "status"], + audioBridgeCommand: ["bridge", "start"], + }, + }); + const handler = methods.get("googlemeet.join") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ + params: { url: "https://meet.google.com/abc-defg-hij" }, + respond, + }); + + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(runCommandWithTimeout).toHaveBeenNthCalledWith(2, ["bridge", "status"], { + timeoutMs: 30000, + }); + expect(runCommandWithTimeout).toHaveBeenNthCalledWith(3, ["bridge", "start"], { + timeoutMs: 30000, + }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + it("pipes Chrome command-pair audio through the realtime provider", async () => { + let callbacks: + | { + onAudio: (audio: Buffer) => void; + onMark?: (markName: string) => void; + } + | undefined; + const sendAudio = vi.fn(); + const bridge = { + connect: vi.fn(async () => {}), + sendAudio, + setMediaTimestamp: vi.fn(), + submitToolResult: vi.fn(), + acknowledgeMark: vi.fn(), + close: vi.fn(), + isConnected: vi.fn(() => true), + }; + const provider: RealtimeVoiceProviderPlugin = { + id: "openai", + label: "OpenAI", + autoSelectOrder: 1, + resolveConfig: ({ rawConfig }) => rawConfig, + isConfigured: () => true, + createBridge: (req) => { + callbacks = req; + return bridge; + }, + }; + const inputStdout = new PassThrough(); + const outputStdinWrites: Buffer[] = []; + const makeProcess = (stdio: { + stdin?: { write(chunk: unknown): unknown } | null; + stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; + }): TestBridgeProcess => { + const proc = new EventEmitter() as unknown as TestBridgeProcess; + proc.stdin = stdio.stdin; + proc.stdout = stdio.stdout; + proc.stderr = new PassThrough(); + proc.killed = false; + proc.kill = vi.fn(() => { + proc.killed = true; + return true; + }); + return proc; + }; + const outputStdin = new Writable({ + write(chunk, _encoding, done) { + outputStdinWrites.push(Buffer.from(chunk)); + done(); + }, + }); + const inputProcess = makeProcess({ stdout: inputStdout, stdin: null }); + const outputProcess = makeProcess({ stdin: outputStdin, stdout: null }); + const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess); + + const handle = await startCommandRealtimeAudioBridge({ + config: resolveGoogleMeetConfig({ + realtime: { provider: "openai", model: "gpt-realtime" }, + }), + fullConfig: {} as never, + inputCommand: ["capture-meet"], + outputCommand: ["play-meet"], + logger: noopLogger, + providers: [provider], + spawn: spawnMock, + }); + + inputStdout.write(Buffer.from([1, 2, 3])); + callbacks?.onAudio(Buffer.from([4, 5])); + callbacks?.onMark?.("mark-1"); + + expect(spawnMock).toHaveBeenNthCalledWith(1, "play-meet", [], { + stdio: ["pipe", "ignore", "pipe"], + }); + expect(spawnMock).toHaveBeenNthCalledWith(2, "capture-meet", [], { + stdio: ["ignore", "pipe", "pipe"], + }); + expect(sendAudio).toHaveBeenCalledWith(Buffer.from([1, 2, 3])); + expect(outputStdinWrites).toEqual([Buffer.from([4, 5])]); + expect(bridge.acknowledgeMark).toHaveBeenCalled(); + + await handle.stop(); + expect(bridge.close).toHaveBeenCalled(); + expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM"); + expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts new file mode 100644 index 00000000000..cdefd416238 --- /dev/null +++ b/extensions/google-meet/index.ts @@ -0,0 +1,362 @@ +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime"; +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { Type } from "typebox"; +import { registerGoogleMeetCli } from "./src/cli.js"; +import { + resolveGoogleMeetConfig, + type GoogleMeetConfig, + type GoogleMeetMode, + type GoogleMeetTransport, +} from "./src/config.js"; +import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js"; +import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; +import { GoogleMeetRuntime } from "./src/runtime.js"; + +const googleMeetConfigSchema = { + parse(value: unknown) { + return resolveGoogleMeetConfig(value); + }, + uiHints: { + "defaults.meeting": { + label: "Default Meeting", + help: "Meet URL, meeting code, or spaces/{id} used when CLI commands omit a meeting.", + }, + "preview.enrollmentAcknowledged": { + label: "Preview Acknowledged", + help: "Confirms you understand the Google Meet Media API is still Developer Preview.", + advanced: true, + }, + defaultTransport: { + label: "Default Transport", + help: "Chrome uses a signed-in browser profile. Twilio uses Meet dial-in numbers.", + }, + defaultMode: { + label: "Default Mode", + help: "Realtime voice is the default.", + }, + "chrome.audioBackend": { + label: "Chrome Audio Backend", + help: "BlackHole 2ch is required for local duplex audio routing.", + }, + "chrome.launch": { label: "Launch Chrome" }, + "chrome.browserProfile": { label: "Chrome Profile", advanced: true }, + "chrome.audioInputCommand": { + label: "Audio Input Command", + help: "Command that writes 8 kHz G.711 mu-law meeting audio to stdout.", + advanced: true, + }, + "chrome.audioOutputCommand": { + label: "Audio Output Command", + help: "Command that reads 8 kHz G.711 mu-law assistant audio from stdin.", + advanced: true, + }, + "chrome.audioBridgeCommand": { label: "Audio Bridge Command", advanced: true }, + "chrome.audioBridgeHealthCommand": { + label: "Audio Bridge Health Command", + advanced: true, + }, + "twilio.defaultDialInNumber": { + label: "Default Dial-In Number", + placeholder: "+15551234567", + }, + "twilio.defaultPin": { label: "Default PIN", advanced: true }, + "twilio.defaultDtmfSequence": { label: "Default DTMF Sequence", advanced: true }, + "voiceCall.enabled": { label: "Delegate To Voice Call" }, + "voiceCall.gatewayUrl": { label: "Voice Call Gateway URL", advanced: true }, + "voiceCall.token": { + label: "Voice Call Gateway Token", + sensitive: true, + advanced: true, + }, + "voiceCall.requestTimeoutMs": { + label: "Voice Call Request Timeout (ms)", + advanced: true, + }, + "voiceCall.dtmfDelayMs": { label: "DTMF Delay (ms)", advanced: true }, + "voiceCall.introMessage": { label: "Voice Call Intro Message", advanced: true }, + "realtime.provider": { + label: "Realtime Provider", + help: "Uses the first registered realtime voice provider when unset.", + }, + "realtime.model": { label: "Realtime Model", advanced: true }, + "realtime.instructions": { label: "Realtime Instructions", advanced: true }, + "realtime.toolPolicy": { + label: "Realtime Tool Policy", + help: "Safe read-only tools are available by default; owner requests can unlock broader tools.", + advanced: true, + }, + "oauth.clientId": { label: "OAuth Client ID" }, + "oauth.clientSecret": { label: "OAuth Client Secret", sensitive: true }, + "oauth.refreshToken": { label: "OAuth Refresh Token", sensitive: true }, + "oauth.accessToken": { + label: "Cached Access Token", + sensitive: true, + advanced: true, + }, + "oauth.expiresAt": { + label: "Cached Access Token Expiry", + help: "Unix epoch milliseconds used only for the cached access-token fast path.", + advanced: true, + }, + }, +}; + +const GoogleMeetToolSchema = Type.Union([ + Type.Object({ + action: Type.Literal("join"), + url: Type.Optional(Type.String({ description: "Explicit https://meet.google.com/... URL" })), + transport: Type.Optional(Type.Union([Type.Literal("chrome"), Type.Literal("twilio")])), + mode: Type.Optional(Type.Union([Type.Literal("realtime"), Type.Literal("transcribe")])), + dialInNumber: Type.Optional(Type.String({ description: "Meet dial-in number for Twilio" })), + pin: Type.Optional(Type.String({ description: "Meet phone PIN for Twilio" })), + dtmfSequence: Type.Optional(Type.String({ description: "Explicit DTMF sequence for Twilio" })), + }), + Type.Object({ + action: Type.Literal("status"), + sessionId: Type.Optional(Type.String({ description: "Meet session ID" })), + }), + Type.Object({ + action: Type.Literal("setup_status"), + }), + Type.Object({ + action: Type.Literal("resolve_space"), + meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })), + accessToken: Type.Optional(Type.String({ description: "Access token override" })), + refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), + clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), + clientSecret: Type.Optional(Type.String({ description: "OAuth client secret override" })), + expiresAt: Type.Optional(Type.Number({ description: "Cached access token expiry ms" })), + }), + Type.Object({ + action: Type.Literal("preflight"), + meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })), + accessToken: Type.Optional(Type.String({ description: "Access token override" })), + refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), + clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), + clientSecret: Type.Optional(Type.String({ description: "OAuth client secret override" })), + expiresAt: Type.Optional(Type.Number({ description: "Cached access token expiry ms" })), + }), + Type.Object({ + action: Type.Literal("leave"), + sessionId: Type.String({ description: "Meet session ID" }), + }), +]); + +function asParamRecord(params: unknown): Record { + return params && typeof params === "object" && !Array.isArray(params) + ? (params as Record) + : {}; +} + +function json(payload: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + details: payload, + }; +} + +function normalizeTransport(value: unknown): GoogleMeetTransport | undefined { + return value === "chrome" || value === "twilio" ? value : undefined; +} + +function normalizeMode(value: unknown): GoogleMeetMode | undefined { + return value === "realtime" || value === "transcribe" ? value : undefined; +} + +function resolveMeetingInput(config: GoogleMeetConfig, value: unknown): string { + const meeting = normalizeOptionalString(value) ?? config.defaults.meeting; + if (!meeting) { + throw new Error("Meeting input is required"); + } + return meeting; +} + +async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { + const meeting = resolveMeetingInput(config, raw.meeting); + 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 space = await fetchGoogleMeetSpace({ + accessToken: token.accessToken, + meeting, + }); + return { meeting, token, space }; +} + +export default definePluginEntry({ + id: "google-meet", + name: "Google Meet", + description: "Join Google Meet calls through Chrome or Twilio transports", + configSchema: googleMeetConfigSchema, + register(api: OpenClawPluginApi) { + const config = googleMeetConfigSchema.parse(api.pluginConfig); + let runtime: GoogleMeetRuntime | null = null; + + const ensureRuntime = async () => { + if (!config.enabled) { + throw new Error("Google Meet plugin disabled in plugin config"); + } + if (!runtime) { + runtime = new GoogleMeetRuntime({ + config, + fullConfig: api.config, + runtime: api.runtime, + logger: api.logger, + }); + } + return runtime; + }; + + const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => { + respond(false, { error: formatErrorMessage(err) }); + }; + + api.registerGatewayMethod( + "googlemeet.join", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const rt = await ensureRuntime(); + const result = await rt.join({ + url: resolveMeetingInput(config, params?.url), + transport: normalizeTransport(params?.transport), + mode: normalizeMode(params?.mode), + dialInNumber: normalizeOptionalString(params?.dialInNumber), + pin: normalizeOptionalString(params?.pin), + dtmfSequence: normalizeOptionalString(params?.dtmfSequence), + }); + respond(true, result); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "googlemeet.status", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const rt = await ensureRuntime(); + respond(true, rt.status(normalizeOptionalString(params?.sessionId))); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "googlemeet.setup", + async ({ respond }: GatewayRequestHandlerOptions) => { + try { + const rt = await ensureRuntime(); + respond(true, rt.setupStatus()); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "googlemeet.leave", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const sessionId = normalizeOptionalString(params?.sessionId); + if (!sessionId) { + respond(false, { error: "sessionId required" }); + return; + } + const rt = await ensureRuntime(); + respond(true, await rt.leave(sessionId)); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerTool({ + name: "google_meet", + label: "Google Meet", + description: "Join and track Google Meet sessions through Chrome or Twilio.", + parameters: GoogleMeetToolSchema, + async execute(_toolCallId, params) { + const raw = asParamRecord(params); + try { + switch (raw.action) { + case "join": { + const rt = await ensureRuntime(); + return json( + await rt.join({ + url: resolveMeetingInput(config, raw.url), + transport: normalizeTransport(raw.transport), + mode: normalizeMode(raw.mode), + dialInNumber: normalizeOptionalString(raw.dialInNumber), + pin: normalizeOptionalString(raw.pin), + dtmfSequence: normalizeOptionalString(raw.dtmfSequence), + }), + ); + } + case "status": { + const rt = await ensureRuntime(); + return json(rt.status(normalizeOptionalString(raw.sessionId))); + } + case "setup_status": { + const rt = await ensureRuntime(); + return json(rt.setupStatus()); + } + case "resolve_space": { + const { token: _token, ...result } = await resolveSpaceFromParams(config, raw); + return json(result); + } + case "preflight": { + const { meeting, token, space } = await resolveSpaceFromParams(config, raw); + return json( + buildGoogleMeetPreflightReport({ + input: meeting, + space, + previewAcknowledged: config.preview.enrollmentAcknowledged, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }), + ); + } + case "leave": { + const rt = await ensureRuntime(); + const sessionId = normalizeOptionalString(raw.sessionId); + if (!sessionId) { + throw new Error("sessionId required"); + } + return json(await rt.leave(sessionId)); + } + default: + throw new Error("unknown google_meet action"); + } + } catch (err) { + return json({ error: formatErrorMessage(err) }); + } + }, + }); + + api.registerCli( + ({ program }) => + registerGoogleMeetCli({ + program, + config, + ensureRuntime, + }), + { + commands: ["googlemeet"], + descriptors: [ + { + name: "googlemeet", + description: "Join and manage Google Meet calls", + hasSubcommands: true, + }, + ], + }, + ); + }, +}); diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json new file mode 100644 index 00000000000..2881c0a2e45 --- /dev/null +++ b/extensions/google-meet/openclaw.plugin.json @@ -0,0 +1,316 @@ +{ + "id": "google-meet", + "name": "Google Meet", + "description": "Join Google Meet calls through Chrome or Twilio transports.", + "enabledByDefault": false, + "commandAliases": [{ "name": "googlemeet" }], + "activation": { + "onCommands": ["googlemeet"], + "onCapabilities": ["tool"] + }, + "uiHints": { + "defaults.meeting": { + "label": "Default Meeting", + "help": "Meet URL, meeting code, or spaces/{id} used when commands omit a meeting." + }, + "preview.enrollmentAcknowledged": { + "label": "Preview Acknowledged", + "help": "Confirms you understand the Google Meet Media API is still Developer Preview.", + "advanced": true + }, + "defaultTransport": { + "label": "Default Transport", + "help": "Chrome uses a signed-in browser profile. Twilio uses Meet dial-in numbers." + }, + "defaultMode": { + "label": "Default Mode", + "help": "Realtime voice is the default." + }, + "chrome.audioBackend": { + "label": "Chrome Audio Backend", + "help": "BlackHole 2ch is required for local duplex audio routing." + }, + "chrome.launch": { + "label": "Launch Chrome" + }, + "chrome.browserProfile": { + "label": "Chrome Profile", + "advanced": true + }, + "chrome.audioInputCommand": { + "label": "Audio Input Command", + "help": "Command that writes 8 kHz G.711 mu-law meeting audio to stdout.", + "advanced": true + }, + "chrome.audioOutputCommand": { + "label": "Audio Output Command", + "help": "Command that reads 8 kHz G.711 mu-law assistant audio from stdin.", + "advanced": true + }, + "chrome.audioBridgeCommand": { + "label": "Audio Bridge Command", + "advanced": true + }, + "chrome.audioBridgeHealthCommand": { + "label": "Audio Bridge Health Command", + "advanced": true + }, + "twilio.defaultDialInNumber": { + "label": "Default Dial-In Number", + "placeholder": "+15551234567" + }, + "twilio.defaultPin": { + "label": "Default PIN", + "advanced": true + }, + "twilio.defaultDtmfSequence": { + "label": "Default DTMF Sequence", + "advanced": true + }, + "voiceCall.enabled": { + "label": "Delegate To Voice Call" + }, + "voiceCall.gatewayUrl": { + "label": "Voice Call Gateway URL", + "advanced": true + }, + "voiceCall.token": { + "label": "Voice Call Gateway Token", + "sensitive": true, + "advanced": true + }, + "voiceCall.requestTimeoutMs": { + "label": "Voice Call Request Timeout (ms)", + "advanced": true + }, + "voiceCall.dtmfDelayMs": { + "label": "DTMF Delay (ms)", + "advanced": true + }, + "voiceCall.introMessage": { + "label": "Voice Call Intro Message", + "advanced": true + }, + "realtime.provider": { + "label": "Realtime Provider", + "help": "Uses the first registered realtime voice provider when unset." + }, + "realtime.model": { + "label": "Realtime Model", + "advanced": true + }, + "realtime.instructions": { + "label": "Realtime Instructions", + "advanced": true + }, + "realtime.toolPolicy": { + "label": "Realtime Tool Policy", + "help": "Safe read-only tools are available by default; owner requests can unlock broader tools.", + "advanced": true + }, + "oauth.clientId": { + "label": "OAuth Client ID" + }, + "oauth.clientSecret": { + "label": "OAuth Client Secret", + "sensitive": true + }, + "oauth.refreshToken": { + "label": "OAuth Refresh Token", + "sensitive": true + }, + "oauth.accessToken": { + "label": "Cached Access Token", + "sensitive": true, + "advanced": true + }, + "oauth.expiresAt": { + "label": "Cached Access Token Expiry", + "help": "Unix epoch milliseconds used only for the cached access-token fast path.", + "advanced": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "defaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "meeting": { + "type": "string" + } + } + }, + "preview": { + "type": "object", + "additionalProperties": false, + "properties": { + "enrollmentAcknowledged": { + "type": "boolean" + } + } + }, + "defaultTransport": { + "type": "string", + "enum": ["chrome", "twilio"] + }, + "defaultMode": { + "type": "string", + "enum": ["realtime", "transcribe"] + }, + "chrome": { + "type": "object", + "additionalProperties": false, + "properties": { + "audioBackend": { + "type": "string", + "enum": ["blackhole-2ch"] + }, + "launch": { + "type": "boolean" + }, + "browserProfile": { + "type": "string" + }, + "joinTimeoutMs": { + "type": "number" + }, + "audioInputCommand": { + "type": "array", + "items": { + "type": "string" + } + }, + "audioOutputCommand": { + "type": "array", + "items": { + "type": "string" + } + }, + "audioBridgeCommand": { + "type": "array", + "items": { + "type": "string" + } + }, + "audioBridgeHealthCommand": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "twilio": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultDialInNumber": { + "type": "string" + }, + "defaultPin": { + "type": "string" + }, + "defaultDtmfSequence": { + "type": "string" + } + } + }, + "voiceCall": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "gatewayUrl": { + "type": "string" + }, + "token": { + "type": "string" + }, + "requestTimeoutMs": { + "type": "number" + }, + "dtmfDelayMs": { + "type": "number" + }, + "introMessage": { + "type": "string" + } + } + }, + "realtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "toolPolicy": { + "type": "string", + "enum": ["safe-read-only", "owner", "none"] + }, + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "oauth": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "expiresAt": { + "type": "number" + } + } + }, + "auth": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "type": "string", + "enum": ["google-oauth"] + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "tokenPath": { + "type": "string" + } + } + } + } + } +} diff --git a/extensions/google-meet/package.json b/extensions/google-meet/package.json new file mode 100644 index 00000000000..ea945f6ae4c --- /dev/null +++ b/extensions/google-meet/package.json @@ -0,0 +1,40 @@ +{ + "name": "@openclaw/google-meet", + "version": "2026.4.20", + "description": "OpenClaw Google Meet participant plugin", + "type": "module", + "dependencies": { + "commander": "^14.0.3", + "typebox": "1.1.28" + }, + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*", + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.4.20" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "install": { + "minHostVersion": ">=2026.4.20" + }, + "compat": { + "pluginApi": ">=2026.4.20" + }, + "build": { + "openclawVersion": "2026.4.20" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + } +} diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts new file mode 100644 index 00000000000..ebe9eb4197d --- /dev/null +++ b/extensions/google-meet/src/cli.ts @@ -0,0 +1,307 @@ +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 { + buildGoogleMeetAuthUrl, + createGoogleMeetOAuthState, + createGoogleMeetPkce, + exchangeGoogleMeetAuthCode, + resolveGoogleMeetAccessToken, + waitForGoogleMeetAuthCode, +} from "./oauth.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +type JoinOptions = { + transport?: GoogleMeetTransport; + mode?: GoogleMeetMode; + dialInNumber?: string; + pin?: string; + dtmfSequence?: string; +}; + +type OAuthLoginOptions = { + clientId?: string; + clientSecret?: string; + manual?: boolean; + json?: boolean; + timeoutSec?: string; +}; + +type ResolveSpaceOptions = { + meeting?: string; + 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`); +} + +function writeStdoutLine(...values: unknown[]): void { + process.stdout.write(`${format(...values)}\n`); +} + +async function promptInput(message: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + return await rl.question(message); + } finally { + rl.close(); + } +} + +function parseOptionalNumber(value: string | undefined): number | undefined { + if (!value?.trim()) { + return undefined; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Expected a numeric value, received ${value}`); + } + return parsed; +} + +function resolveMeetingInput(config: GoogleMeetConfig, value?: string): string { + const meeting = value?.trim() || config.defaults.meeting; + if (!meeting) { + throw new Error( + "Meeting input is required. Pass a URL/meeting code or configure defaults.meeting.", + ); + } + return meeting; +} + +function resolveTokenOptions( + config: GoogleMeetConfig, + options: ResolveSpaceOptions, +): { + meeting: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; +} { + return { + meeting: resolveMeetingInput(config, options.meeting), + 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; + ensureRuntime: () => Promise; +}) { + const root = params.program + .command("googlemeet") + .description("Google Meet participant utilities") + .addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/plugins/google-meet\n`); + + const auth = root.command("auth").description("Google Meet OAuth helpers"); + + auth + .command("login") + .description("Run a PKCE OAuth flow and print refresh-token JSON to store in plugin config") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--manual", "Use copy/paste callback flow instead of localhost callback") + .option("--json", "Print the token payload as JSON", false) + .option("--timeout-sec ", "Local callback timeout in seconds", "300") + .action(async (options: OAuthLoginOptions) => { + const clientId = options.clientId?.trim() || params.config.oauth.clientId; + const clientSecret = options.clientSecret?.trim() || params.config.oauth.clientSecret; + if (!clientId) { + throw new Error( + "Missing Google Meet OAuth client id. Configure oauth.clientId or pass --client-id.", + ); + } + const { verifier, challenge } = createGoogleMeetPkce(); + const state = createGoogleMeetOAuthState(); + const authUrl = buildGoogleMeetAuthUrl({ + clientId, + challenge, + state, + }); + const code = await waitForGoogleMeetAuthCode({ + state, + manual: Boolean(options.manual), + timeoutMs: (parseOptionalNumber(options.timeoutSec) ?? 300) * 1000, + authUrl, + promptInput, + writeLine: (message) => writeStdoutLine("%s", message), + }); + const tokens = await exchangeGoogleMeetAuthCode({ + clientId, + clientSecret, + code, + verifier, + }); + if (!tokens.refreshToken) { + throw new Error( + "Google OAuth did not return a refresh token. Re-run the flow with consent and offline access.", + ); + } + const payload = { + oauth: { + clientId, + ...(clientSecret ? { clientSecret } : {}), + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + expiresAt: tokens.expiresAt, + }, + scope: tokens.scope, + tokenType: tokens.tokenType, + }; + if (!options.json) { + writeStdoutLine("Paste this into plugins.entries.google-meet.config:"); + } + writeStdoutJson(payload); + }); + + root + .command("join") + .argument("[url]", "Explicit https://meet.google.com/... URL") + .option("--transport ", "Transport: chrome or twilio") + .option("--mode ", "Mode: realtime or transcribe") + .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") + .action(async (url: string | undefined, options: JoinOptions) => { + const rt = await params.ensureRuntime(); + const result = await rt.join({ + url: resolveMeetingInput(params.config, url), + transport: options.transport, + mode: options.mode, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }); + writeStdoutJson(result.session); + }); + + root + .command("resolve-space") + .description("Resolve a Meet URL, meeting code, or spaces/{id} to its canonical space") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .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: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const space = await fetchGoogleMeetSpace({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + if (options.json) { + writeStdoutJson(space); + return; + } + writeStdoutLine("input: %s", resolved.meeting); + writeStdoutLine("space: %s", space.name); + if (space.meetingCode) { + writeStdoutLine("meeting code: %s", space.meetingCode); + } + if (space.meetingUri) { + writeStdoutLine("meeting uri: %s", space.meetingUri); + } + writeStdoutLine("active conference: %s", space.activeConference ? "yes" : "no"); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("preflight") + .description("Validate OAuth + meeting resolution prerequisites for Meet media work") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .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: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const space = await fetchGoogleMeetSpace({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + const report = buildGoogleMeetPreflightReport({ + input: resolved.meeting, + space, + previewAcknowledged: params.config.preview.enrollmentAcknowledged, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + if (options.json) { + writeStdoutJson(report); + return; + } + writeStdoutLine("input: %s", report.input); + writeStdoutLine("resolved space: %s", report.resolvedSpaceName); + if (report.meetingCode) { + writeStdoutLine("meeting code: %s", report.meetingCode); + } + if (report.meetingUri) { + writeStdoutLine("meeting uri: %s", report.meetingUri); + } + writeStdoutLine("active conference: %s", report.hasActiveConference ? "yes" : "no"); + writeStdoutLine("preview acknowledged: %s", report.previewAcknowledged ? "yes" : "no"); + writeStdoutLine("token source: %s", report.tokenSource); + if (report.blockers.length === 0) { + writeStdoutLine("blockers: none"); + return; + } + writeStdoutLine("blockers:"); + for (const blocker of report.blockers) { + writeStdoutLine("- %s", blocker); + } + }); + + root + .command("status") + .argument("[session-id]", "Meet session ID") + .action(async (sessionId?: string) => { + const rt = await params.ensureRuntime(); + writeStdoutJson(rt.status(sessionId)); + }); + + root + .command("setup") + .description("Show Google Meet transport setup status") + .action(async () => { + const rt = await params.ensureRuntime(); + writeStdoutJson(rt.setupStatus()); + }); + + root + .command("leave") + .argument("", "Meet session ID") + .action(async (sessionId: string) => { + const rt = await params.ensureRuntime(); + const result = await rt.leave(sessionId); + if (!result.found) { + throw new Error("session not found"); + } + writeStdoutLine("left %s", sessionId); + }); +} diff --git a/extensions/google-meet/src/config.ts b/extensions/google-meet/src/config.ts new file mode 100644 index 00000000000..93b6381eff0 --- /dev/null +++ b/extensions/google-meet/src/config.ts @@ -0,0 +1,318 @@ +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; + +export type GoogleMeetTransport = "chrome" | "twilio"; +export type GoogleMeetMode = "realtime" | "transcribe"; +export type GoogleMeetToolPolicy = "safe-read-only" | "owner" | "none"; + +export type GoogleMeetConfig = { + enabled: boolean; + defaults: { + meeting?: string; + }; + preview: { + enrollmentAcknowledged: boolean; + }; + defaultTransport: GoogleMeetTransport; + defaultMode: GoogleMeetMode; + chrome: { + audioBackend: "blackhole-2ch"; + launch: boolean; + browserProfile?: string; + joinTimeoutMs: number; + audioInputCommand?: string[]; + audioOutputCommand?: string[]; + audioBridgeCommand?: string[]; + audioBridgeHealthCommand?: string[]; + }; + twilio: { + defaultDialInNumber?: string; + defaultPin?: string; + defaultDtmfSequence?: string; + }; + voiceCall: { + enabled: boolean; + gatewayUrl?: string; + token?: string; + requestTimeoutMs: number; + dtmfDelayMs: number; + introMessage?: string; + }; + realtime: { + provider?: string; + model?: string; + instructions?: string; + toolPolicy: GoogleMeetToolPolicy; + providers: Record>; + }; + oauth: { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; + }; + auth: { + provider: "google-oauth"; + clientId?: string; + clientSecret?: string; + tokenPath?: string; + }; +}; + +export const DEFAULT_GOOGLE_MEET_CONFIG: GoogleMeetConfig = { + enabled: true, + defaults: {}, + preview: { + enrollmentAcknowledged: false, + }, + defaultTransport: "chrome", + defaultMode: "realtime", + chrome: { + audioBackend: "blackhole-2ch", + launch: true, + joinTimeoutMs: 30_000, + }, + twilio: {}, + voiceCall: { + enabled: true, + requestTimeoutMs: 30_000, + dtmfDelayMs: 2_500, + }, + realtime: { + toolPolicy: "safe-read-only", + providers: {}, + }, + oauth: {}, + auth: { + provider: "google-oauth", + }, +}; + +const GOOGLE_MEET_CLIENT_ID_KEYS = ["OPENCLAW_GOOGLE_MEET_CLIENT_ID", "GOOGLE_MEET_CLIENT_ID"]; +const GOOGLE_MEET_CLIENT_SECRET_KEYS = [ + "OPENCLAW_GOOGLE_MEET_CLIENT_SECRET", + "GOOGLE_MEET_CLIENT_SECRET", +] as const; +const GOOGLE_MEET_REFRESH_TOKEN_KEYS = [ + "OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN", + "GOOGLE_MEET_REFRESH_TOKEN", +] as const; +const GOOGLE_MEET_ACCESS_TOKEN_KEYS = [ + "OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN", + "GOOGLE_MEET_ACCESS_TOKEN", +] as const; +const GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS = [ + "OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT", + "GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT", +] as const; +const GOOGLE_MEET_DEFAULT_MEETING_KEYS = [ + "OPENCLAW_GOOGLE_MEET_DEFAULT_MEETING", + "GOOGLE_MEET_DEFAULT_MEETING", +] as const; +const GOOGLE_MEET_PREVIEW_ACK_KEYS = [ + "OPENCLAW_GOOGLE_MEET_PREVIEW_ACK", + "GOOGLE_MEET_PREVIEW_ACK", +] as const; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function resolveBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function resolveNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function resolveOptionalNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readEnvString(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = normalizeOptionalString(env[key]); + if (value) { + return value; + } + } + return undefined; +} + +function readEnvBoolean(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean | undefined { + const normalized = normalizeOptionalLowercaseString(readEnvString(env, keys)); + if (!normalized) { + return undefined; + } + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function readEnvNumber(env: NodeJS.ProcessEnv, keys: readonly string[]): number | undefined { + return resolveOptionalNumber(readEnvString(env, keys)); +} + +function resolveStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized = value + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveProvidersConfig(value: unknown): Record> { + const raw = asRecord(value); + const providers: Record> = {}; + for (const [key, entry] of Object.entries(raw)) { + const providerId = normalizeOptionalLowercaseString(key); + if (!providerId) { + continue; + } + providers[providerId] = asRecord(entry); + } + return providers; +} + +function resolveTransport(value: unknown, fallback: GoogleMeetTransport): GoogleMeetTransport { + const normalized = normalizeOptionalLowercaseString(value); + return normalized === "chrome" || normalized === "twilio" ? normalized : fallback; +} + +function resolveMode(value: unknown, fallback: GoogleMeetMode): GoogleMeetMode { + const normalized = normalizeOptionalLowercaseString(value); + return normalized === "realtime" || normalized === "transcribe" ? normalized : fallback; +} + +function resolveToolPolicy(value: unknown, fallback: GoogleMeetToolPolicy): GoogleMeetToolPolicy { + const normalized = normalizeOptionalLowercaseString(value); + return normalized === "safe-read-only" || normalized === "owner" || normalized === "none" + ? normalized + : fallback; +} + +export function resolveGoogleMeetConfig(input: unknown): GoogleMeetConfig { + return resolveGoogleMeetConfigWithEnv(input); +} + +export function resolveGoogleMeetConfigWithEnv( + input: unknown, + env: NodeJS.ProcessEnv = process.env, +): GoogleMeetConfig { + const raw = asRecord(input); + const defaults = asRecord(raw.defaults); + const preview = asRecord(raw.preview); + const chrome = asRecord(raw.chrome); + const twilio = asRecord(raw.twilio); + const voiceCall = asRecord(raw.voiceCall); + const realtime = asRecord(raw.realtime); + const oauth = asRecord(raw.oauth); + const auth = asRecord(raw.auth); + + return { + enabled: resolveBoolean(raw.enabled, DEFAULT_GOOGLE_MEET_CONFIG.enabled), + defaults: { + meeting: + normalizeOptionalString(defaults.meeting) ?? + readEnvString(env, GOOGLE_MEET_DEFAULT_MEETING_KEYS), + }, + preview: { + enrollmentAcknowledged: resolveBoolean( + preview.enrollmentAcknowledged, + readEnvBoolean(env, GOOGLE_MEET_PREVIEW_ACK_KEYS) ?? + DEFAULT_GOOGLE_MEET_CONFIG.preview.enrollmentAcknowledged, + ), + }, + defaultTransport: resolveTransport( + raw.defaultTransport, + DEFAULT_GOOGLE_MEET_CONFIG.defaultTransport, + ), + defaultMode: resolveMode(raw.defaultMode, DEFAULT_GOOGLE_MEET_CONFIG.defaultMode), + chrome: { + audioBackend: "blackhole-2ch", + launch: resolveBoolean(chrome.launch, DEFAULT_GOOGLE_MEET_CONFIG.chrome.launch), + browserProfile: normalizeOptionalString(chrome.browserProfile), + joinTimeoutMs: resolveNumber( + chrome.joinTimeoutMs, + DEFAULT_GOOGLE_MEET_CONFIG.chrome.joinTimeoutMs, + ), + audioInputCommand: resolveStringArray(chrome.audioInputCommand), + audioOutputCommand: resolveStringArray(chrome.audioOutputCommand), + audioBridgeCommand: resolveStringArray(chrome.audioBridgeCommand), + audioBridgeHealthCommand: resolveStringArray(chrome.audioBridgeHealthCommand), + }, + twilio: { + defaultDialInNumber: normalizeOptionalString(twilio.defaultDialInNumber), + defaultPin: normalizeOptionalString(twilio.defaultPin), + defaultDtmfSequence: normalizeOptionalString(twilio.defaultDtmfSequence), + }, + voiceCall: { + enabled: resolveBoolean(voiceCall.enabled, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.enabled), + gatewayUrl: normalizeOptionalString(voiceCall.gatewayUrl), + token: normalizeOptionalString(voiceCall.token), + requestTimeoutMs: resolveNumber( + voiceCall.requestTimeoutMs, + DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.requestTimeoutMs, + ), + dtmfDelayMs: resolveNumber( + voiceCall.dtmfDelayMs, + DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.dtmfDelayMs, + ), + introMessage: normalizeOptionalString(voiceCall.introMessage), + }, + realtime: { + provider: normalizeOptionalString(realtime.provider), + model: normalizeOptionalString(realtime.model), + instructions: normalizeOptionalString(realtime.instructions), + toolPolicy: resolveToolPolicy( + realtime.toolPolicy, + DEFAULT_GOOGLE_MEET_CONFIG.realtime.toolPolicy, + ), + providers: resolveProvidersConfig(realtime.providers), + }, + oauth: { + clientId: + normalizeOptionalString(oauth.clientId) ?? + normalizeOptionalString(auth.clientId) ?? + readEnvString(env, GOOGLE_MEET_CLIENT_ID_KEYS), + clientSecret: + normalizeOptionalString(oauth.clientSecret) ?? + normalizeOptionalString(auth.clientSecret) ?? + readEnvString(env, GOOGLE_MEET_CLIENT_SECRET_KEYS), + refreshToken: + normalizeOptionalString(oauth.refreshToken) ?? + readEnvString(env, GOOGLE_MEET_REFRESH_TOKEN_KEYS), + accessToken: + normalizeOptionalString(oauth.accessToken) ?? + readEnvString(env, GOOGLE_MEET_ACCESS_TOKEN_KEYS), + expiresAt: + resolveOptionalNumber(oauth.expiresAt) ?? + readEnvNumber(env, GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS), + }, + auth: { + provider: "google-oauth", + clientId: normalizeOptionalString(auth.clientId), + clientSecret: normalizeOptionalString(auth.clientSecret), + tokenPath: normalizeOptionalString(auth.tokenPath), + }, + }; +} diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts new file mode 100644 index 00000000000..224eaf43e0f --- /dev/null +++ b/extensions/google-meet/src/meet.ts @@ -0,0 +1,100 @@ +const GOOGLE_MEET_API_BASE_URL = "https://meet.googleapis.com/v2"; +const GOOGLE_MEET_URL_HOST = "meet.google.com"; + +export type GoogleMeetSpace = { + name: string; + meetingCode?: string; + meetingUri?: string; + activeConference?: Record; + config?: Record; +}; + +export type GoogleMeetPreflightReport = { + input: string; + resolvedSpaceName: string; + meetingCode?: string; + meetingUri?: string; + hasActiveConference: boolean; + previewAcknowledged: boolean; + tokenSource: "cached-access-token" | "refresh-token"; + blockers: string[]; +}; + +export function normalizeGoogleMeetSpaceName(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Meeting input is required"); + } + if (trimmed.startsWith("spaces/")) { + const suffix = trimmed.slice("spaces/".length).trim(); + if (!suffix) { + throw new Error("spaces/ input must include a meeting code or space id"); + } + return `spaces/${suffix}`; + } + if (/^https?:\/\//i.test(trimmed)) { + const url = new URL(trimmed); + if (url.hostname !== GOOGLE_MEET_URL_HOST) { + throw new Error(`Expected a ${GOOGLE_MEET_URL_HOST} URL, received ${url.hostname}`); + } + const firstSegment = url.pathname + .split("/") + .map((segment) => segment.trim()) + .find(Boolean); + if (!firstSegment) { + throw new Error("Google Meet URL did not include a meeting code"); + } + return `spaces/${firstSegment}`; + } + return `spaces/${trimmed}`; +} + +function encodeSpaceNameForPath(name: string): string { + return name.split("/").map(encodeURIComponent).join("/"); +} + +export async function fetchGoogleMeetSpace(params: { + accessToken: string; + meeting: string; +}): Promise { + const name = normalizeGoogleMeetSpaceName(params.meeting); + const response = await fetch(`${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(name)}`, { + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + }, + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google Meet spaces.get failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as GoogleMeetSpace; + if (!payload.name?.trim()) { + throw new Error("Google Meet spaces.get response was missing name"); + } + return payload; +} + +export function buildGoogleMeetPreflightReport(params: { + input: string; + space: GoogleMeetSpace; + previewAcknowledged: boolean; + tokenSource: "cached-access-token" | "refresh-token"; +}): GoogleMeetPreflightReport { + const blockers: string[] = []; + if (!params.previewAcknowledged) { + blockers.push( + "Set preview.enrollmentAcknowledged=true after confirming your Cloud project, OAuth principal, and meeting participants are enrolled in the Google Workspace Developer Preview Program.", + ); + } + return { + input: params.input, + resolvedSpaceName: params.space.name, + meetingCode: params.space.meetingCode, + meetingUri: params.space.meetingUri, + hasActiveConference: Boolean(params.space.activeConference), + previewAcknowledged: params.previewAcknowledged, + tokenSource: params.tokenSource, + blockers, + }; +} diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts new file mode 100644 index 00000000000..aa33939cd77 --- /dev/null +++ b/extensions/google-meet/src/oauth.ts @@ -0,0 +1,214 @@ +import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth"; +import { + generateOAuthState, + parseOAuthCallbackInput, + waitForLocalOAuthCallback, +} from "openclaw/plugin-sdk/provider-auth-runtime"; + +export const GOOGLE_MEET_REDIRECT_URI = "http://localhost:8085/oauth2callback"; +export const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +export const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token"; +export const GOOGLE_MEET_SCOPES = [ + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.conference.media.readonly", +] as const; + +export type GoogleMeetOAuthTokens = { + accessToken: string; + expiresAt: number; + refreshToken?: string; + scope?: string; + tokenType?: string; +}; + +export function buildGoogleMeetAuthUrl(params: { + clientId: string; + challenge: string; + state: string; + redirectUri?: string; + scopes?: readonly string[]; +}): string { + const search = new URLSearchParams({ + client_id: params.clientId, + response_type: "code", + redirect_uri: params.redirectUri ?? GOOGLE_MEET_REDIRECT_URI, + scope: (params.scopes ?? GOOGLE_MEET_SCOPES).join(" "), + code_challenge: params.challenge, + code_challenge_method: "S256", + access_type: "offline", + prompt: "consent", + state: params.state, + }); + return `${GOOGLE_MEET_AUTH_URL}?${search.toString()}`; +} + +async function executeGoogleTokenRequest(body: URLSearchParams): Promise { + const response = await fetch(GOOGLE_MEET_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "application/json", + }, + body, + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as { + access_token?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + token_type?: string; + }; + const accessToken = payload.access_token?.trim(); + if (!accessToken) { + throw new Error("Google OAuth token response was missing access_token"); + } + const expiresInSeconds = + typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) + ? payload.expires_in + : 3600; + return { + accessToken, + expiresAt: Date.now() + expiresInSeconds * 1000, + refreshToken: payload.refresh_token?.trim() || undefined, + scope: payload.scope?.trim() || undefined, + tokenType: payload.token_type?.trim() || undefined, + }; +} + +function tokenRequestBody(values: Record): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value?.trim()) { + body.set(key, value); + } + } + return body; +} + +export async function exchangeGoogleMeetAuthCode(params: { + clientId: string; + clientSecret?: string; + code: string; + verifier: string; + redirectUri?: string; +}): Promise { + return await executeGoogleTokenRequest( + tokenRequestBody({ + client_id: params.clientId, + client_secret: params.clientSecret, + code: params.code, + grant_type: "authorization_code", + redirect_uri: params.redirectUri ?? GOOGLE_MEET_REDIRECT_URI, + code_verifier: params.verifier, + }), + ); +} + +export async function refreshGoogleMeetAccessToken(params: { + clientId: string; + clientSecret?: string; + refreshToken: string; +}): Promise { + return await executeGoogleTokenRequest( + tokenRequestBody({ + client_id: params.clientId, + client_secret: params.clientSecret, + grant_type: "refresh_token", + refresh_token: params.refreshToken, + }), + ); +} + +export function shouldUseCachedGoogleMeetAccessToken(params: { + accessToken?: string; + expiresAt?: number; + now?: number; + safetyWindowMs?: number; +}): boolean { + const now = params.now ?? Date.now(); + const safetyWindowMs = params.safetyWindowMs ?? 60_000; + return Boolean( + params.accessToken?.trim() && + typeof params.expiresAt === "number" && + Number.isFinite(params.expiresAt) && + params.expiresAt > now + safetyWindowMs, + ); +} + +export async function resolveGoogleMeetAccessToken(params: { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; +}): Promise<{ accessToken: string; expiresAt?: number; refreshed: boolean }> { + if (shouldUseCachedGoogleMeetAccessToken(params)) { + return { + accessToken: params.accessToken!.trim(), + expiresAt: params.expiresAt, + refreshed: false, + }; + } + if (!params.clientId?.trim() || !params.refreshToken?.trim()) { + throw new Error( + "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.", + ); + } + const refreshed = await refreshGoogleMeetAccessToken({ + clientId: params.clientId, + clientSecret: params.clientSecret, + refreshToken: params.refreshToken, + }); + return { + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + refreshed: true, + }; +} + +export function createGoogleMeetPkce() { + const { verifier, challenge } = generateHexPkceVerifierChallenge(); + return { verifier, challenge }; +} + +export function createGoogleMeetOAuthState(): string { + return generateOAuthState(); +} + +export async function waitForGoogleMeetAuthCode(params: { + state: string; + manual: boolean; + timeoutMs: number; + authUrl: string; + promptInput: (message: string) => Promise; + writeLine: (message: string) => void; +}): Promise { + params.writeLine(`Open this URL in your browser:\n\n${params.authUrl}\n`); + if (params.manual) { + const input = await params.promptInput("Paste the full redirect URL here: "); + const parsed = parseOAuthCallbackInput(input, { + missingState: "Missing 'state' parameter. Paste the full redirect URL.", + invalidInput: "Paste the full redirect URL, not just the code.", + }); + if ("error" in parsed) { + throw new Error(parsed.error); + } + if (parsed.state !== params.state) { + throw new Error("OAuth state mismatch - please try again"); + } + return parsed.code; + } + const callback = await waitForLocalOAuthCallback({ + expectedState: params.state, + timeoutMs: params.timeoutMs, + port: 8085, + callbackPath: "/oauth2callback", + redirectUri: GOOGLE_MEET_REDIRECT_URI, + successTitle: "Google Meet OAuth complete", + }); + return callback.code; +} diff --git a/extensions/google-meet/src/realtime.ts b/extensions/google-meet/src/realtime.ts new file mode 100644 index 00000000000..a464f5fc336 --- /dev/null +++ b/extensions/google-meet/src/realtime.ts @@ -0,0 +1,239 @@ +import { spawn } from "node:child_process"; +import type { Writable } from "node:stream"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; +import { + getRealtimeVoiceProvider, + listRealtimeVoiceProviders, + type RealtimeVoiceBridgeCallbacks, + type RealtimeVoiceProviderConfig, + type RealtimeVoiceProviderPlugin, +} from "openclaw/plugin-sdk/realtime-voice"; +import type { GoogleMeetConfig } from "./config.js"; + +type BridgeProcess = { + pid?: number; + killed?: boolean; + stdin?: Writable | null; + stdout?: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null; + stderr?: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null; + kill(signal?: NodeJS.Signals): boolean; + on( + event: "exit", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): unknown; + on(event: "error", listener: (error: Error) => void): unknown; +}; + +type SpawnFn = ( + command: string, + args: string[], + options: { stdio: ["pipe" | "ignore", "pipe" | "ignore", "pipe" | "ignore"] }, +) => BridgeProcess; + +export type ChromeRealtimeAudioBridgeHandle = { + providerId: string; + inputCommand: string[]; + outputCommand: string[]; + stop: () => Promise; +}; + +type ResolvedRealtimeProvider = { + provider: RealtimeVoiceProviderPlugin; + providerConfig: RealtimeVoiceProviderConfig; +}; + +type ActiveRealtimeBridge = { + acknowledgeMark(): unknown; + close(): unknown; + connect(): Promise | void; + sendAudio(audio: Buffer): unknown; +}; + +function splitCommand(argv: string[]): { command: string; args: string[] } { + const [command, ...args] = argv; + if (!command) { + throw new Error("audio bridge command must not be empty"); + } + return { command, args }; +} + +function rawProviderConfig(params: { + config: GoogleMeetConfig; + providerId: string; + configuredProviderId?: string; +}): Record { + const raw = + params.config.realtime.providers[params.configuredProviderId ?? ""] ?? + params.config.realtime.providers[params.providerId] ?? + {}; + if (params.config.realtime.model && raw.model === undefined) { + return { ...raw, model: params.config.realtime.model }; + } + return raw; +} + +export function resolveGoogleMeetRealtimeProvider(params: { + config: GoogleMeetConfig; + fullConfig: OpenClawConfig; + providers?: RealtimeVoiceProviderPlugin[]; +}): ResolvedRealtimeProvider { + const configuredProviderId = params.config.realtime.provider; + const providers = params.providers ?? listRealtimeVoiceProviders(params.fullConfig); + const provider = configuredProviderId + ? (params.providers?.find((entry) => entry.id === configuredProviderId) ?? + getRealtimeVoiceProvider(configuredProviderId, params.fullConfig)) + : providers + .toSorted((left, right) => (left.autoSelectOrder ?? 1000) - (right.autoSelectOrder ?? 1000)) + .find((entry) => { + const rawConfig = rawProviderConfig({ + config: params.config, + providerId: entry.id, + }); + const providerConfig = + entry.resolveConfig?.({ + cfg: params.fullConfig, + rawConfig, + }) ?? rawConfig; + return entry.isConfigured({ cfg: params.fullConfig, providerConfig }); + }); + + if (!provider) { + throw new Error( + configuredProviderId + ? `Realtime voice provider "${configuredProviderId}" is not registered` + : "No configured realtime voice provider registered", + ); + } + + const rawConfig = rawProviderConfig({ + config: params.config, + providerId: provider.id, + configuredProviderId, + }); + const providerConfig = + provider.resolveConfig?.({ + cfg: params.fullConfig, + rawConfig, + }) ?? rawConfig; + if (!provider.isConfigured({ cfg: params.fullConfig, providerConfig })) { + throw new Error(`Realtime voice provider "${provider.id}" is not configured`); + } + + return { provider, providerConfig }; +} + +export async function startCommandRealtimeAudioBridge(params: { + config: GoogleMeetConfig; + fullConfig: OpenClawConfig; + inputCommand: string[]; + outputCommand: string[]; + logger: RuntimeLogger; + providers?: RealtimeVoiceProviderPlugin[]; + spawn?: SpawnFn; +}): Promise { + const input = splitCommand(params.inputCommand); + const output = splitCommand(params.outputCommand); + const spawnFn: SpawnFn = + params.spawn ?? + ((command, args, options) => spawn(command, args, options) as unknown as BridgeProcess); + const outputProcess = spawnFn(output.command, output.args, { + stdio: ["pipe", "ignore", "pipe"], + }); + const inputProcess = spawnFn(input.command, input.args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stopped = false; + let bridge: ActiveRealtimeBridge | null = null; + + const stop = async () => { + if (stopped) { + return; + } + stopped = true; + try { + bridge?.close(); + } catch (error) { + params.logger.debug?.( + `[google-meet] realtime voice bridge close ignored: ${formatErrorMessage(error)}`, + ); + } + inputProcess.kill("SIGTERM"); + outputProcess.kill("SIGTERM"); + }; + + const fail = (label: string) => (error: Error) => { + params.logger.warn(`[google-meet] ${label} failed: ${formatErrorMessage(error)}`); + void stop(); + }; + inputProcess.on("error", fail("audio input command")); + outputProcess.on("error", fail("audio output command")); + inputProcess.on("exit", (code, signal) => { + if (!stopped) { + params.logger.warn(`[google-meet] audio input command exited (${code ?? signal ?? "done"})`); + void stop(); + } + }); + outputProcess.on("exit", (code, signal) => { + if (!stopped) { + params.logger.warn(`[google-meet] audio output command exited (${code ?? signal ?? "done"})`); + void stop(); + } + }); + inputProcess.stderr?.on("data", (chunk) => { + params.logger.debug?.(`[google-meet] audio input: ${String(chunk).trim()}`); + }); + outputProcess.stderr?.on("data", (chunk) => { + params.logger.debug?.(`[google-meet] audio output: ${String(chunk).trim()}`); + }); + + const resolved = resolveGoogleMeetRealtimeProvider({ + config: params.config, + fullConfig: params.fullConfig, + providers: params.providers, + }); + const callbacks: RealtimeVoiceBridgeCallbacks = { + onAudio: (muLaw) => { + if (!stopped) { + outputProcess.stdin?.write(muLaw); + } + }, + onClearAudio: () => {}, + onMark: () => { + bridge?.acknowledgeMark(); + }, + onTranscript: (role, text, isFinal) => { + if (isFinal) { + params.logger.debug?.(`[google-meet] ${role}: ${text}`); + } + }, + onError: fail("realtime voice bridge"), + onClose: (reason) => { + if (reason === "error") { + void stop(); + } + }, + }; + + bridge = resolved.provider.createBridge({ + providerConfig: resolved.providerConfig, + instructions: params.config.realtime.instructions, + ...callbacks, + }); + + inputProcess.stdout?.on("data", (chunk) => { + const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (!stopped && audio.byteLength > 0) { + bridge?.sendAudio(Buffer.from(audio)); + } + }); + + await bridge.connect(); + return { + providerId: resolved.provider.id, + inputCommand: params.inputCommand, + outputCommand: params.outputCommand, + stop, + }; +} diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts new file mode 100644 index 00000000000..9e64d00dc00 --- /dev/null +++ b/extensions/google-meet/src/runtime.ts @@ -0,0 +1,193 @@ +import { randomUUID } from "node:crypto"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; +import { getGoogleMeetSetupStatus } from "./setup.js"; +import { launchChromeMeet } from "./transports/chrome.js"; +import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js"; +import type { + GoogleMeetJoinRequest, + GoogleMeetJoinResult, + GoogleMeetSession, +} from "./transports/types.js"; +import { joinMeetViaVoiceCallGateway } from "./voice-call-gateway.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +export function normalizeMeetUrl(input: unknown): string { + const raw = normalizeOptionalString(input); + if (!raw) { + throw new Error("url required"); + } + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("url must be a valid Google Meet URL"); + } + if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "meet.google.com") { + throw new Error("url must be an explicit https://meet.google.com/... URL"); + } + if (!/^\/[a-z]{3}-[a-z]{4}-[a-z]{3}(?:$|[/?#])/i.test(url.pathname)) { + throw new Error("url must include a Google Meet meeting code"); + } + return url.toString(); +} + +function resolveTransport(input: GoogleMeetTransport | undefined, config: GoogleMeetConfig) { + return input ?? config.defaultTransport; +} + +function resolveMode(input: GoogleMeetMode | undefined, config: GoogleMeetConfig) { + return input ?? config.defaultMode; +} + +export class GoogleMeetRuntime { + readonly #sessions = new Map(); + readonly #sessionStops = new Map Promise>(); + + constructor( + private readonly params: { + config: GoogleMeetConfig; + fullConfig: OpenClawConfig; + runtime: PluginRuntime; + logger: RuntimeLogger; + }, + ) {} + + list(): GoogleMeetSession[] { + return [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + status(sessionId?: string): { + found: boolean; + session?: GoogleMeetSession; + sessions?: GoogleMeetSession[]; + } { + if (!sessionId) { + return { found: true, sessions: this.list() }; + } + const session = this.#sessions.get(sessionId); + return session ? { found: true, session } : { found: false }; + } + + setupStatus() { + return getGoogleMeetSetupStatus(this.params.config); + } + + async join(request: GoogleMeetJoinRequest): Promise { + const url = normalizeMeetUrl(request.url); + const transport = resolveTransport(request.transport, this.params.config); + const mode = resolveMode(request.mode, this.params.config); + const createdAt = nowIso(); + + const session: GoogleMeetSession = { + id: `meet_${randomUUID()}`, + url, + transport, + mode, + state: "active", + createdAt, + updatedAt: createdAt, + participantIdentity: + transport === "chrome" ? "signed-in Google Chrome profile" : "Twilio phone participant", + realtime: { + enabled: mode === "realtime", + provider: this.params.config.realtime.provider, + model: this.params.config.realtime.model, + toolPolicy: this.params.config.realtime.toolPolicy, + }, + notes: [], + }; + + try { + if (transport === "chrome") { + const result = await launchChromeMeet({ + runtime: this.params.runtime, + config: this.params.config, + fullConfig: this.params.fullConfig, + mode, + url, + logger: this.params.logger, + }); + session.chrome = { + audioBackend: this.params.config.chrome.audioBackend, + launched: result.launched, + browserProfile: this.params.config.chrome.browserProfile, + audioBridge: result.audioBridge + ? { + type: result.audioBridge.type, + provider: + result.audioBridge.type === "command-pair" + ? result.audioBridge.providerId + : undefined, + } + : undefined, + }; + if (result.audioBridge?.type === "command-pair") { + this.#sessionStops.set(session.id, result.audioBridge.stop); + } + session.notes.push( + result.audioBridge + ? "Chrome transport joins as the signed-in Google profile and routes realtime audio through the configured bridge." + : "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing.", + ); + } else { + const dialInNumber = normalizeDialInNumber( + request.dialInNumber ?? this.params.config.twilio.defaultDialInNumber, + ); + if (!dialInNumber) { + throw new Error("dialInNumber required for twilio transport"); + } + const dtmfSequence = buildMeetDtmfSequence({ + pin: request.pin ?? this.params.config.twilio.defaultPin, + dtmfSequence: request.dtmfSequence ?? this.params.config.twilio.defaultDtmfSequence, + }); + const voiceCallResult = this.params.config.voiceCall.enabled + ? await joinMeetViaVoiceCallGateway({ + config: this.params.config, + dialInNumber, + dtmfSequence, + }) + : undefined; + session.twilio = { + dialInNumber, + pinProvided: Boolean(request.pin ?? this.params.config.twilio.defaultPin), + dtmfSequence, + voiceCallId: voiceCallResult?.callId, + dtmfSent: voiceCallResult?.dtmfSent, + }; + session.notes.push( + this.params.config.voiceCall.enabled + ? "Twilio transport delegated the call to the voice-call plugin and sent configured DTMF." + : "Twilio transport is an explicit dial plan; voice-call delegation is disabled.", + ); + } + } catch (err) { + this.params.logger.warn(`[google-meet] join failed: ${formatErrorMessage(err)}`); + throw err; + } + + this.#sessions.set(session.id, session); + return { session }; + } + + async leave(sessionId: string): Promise<{ found: boolean; session?: GoogleMeetSession }> { + const session = this.#sessions.get(sessionId); + if (!session) { + return { found: false }; + } + const stop = this.#sessionStops.get(sessionId); + if (stop) { + this.#sessionStops.delete(sessionId); + await stop(); + } + session.state = "ended"; + session.updatedAt = nowIso(); + return { found: true, session }; + } +} diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts new file mode 100644 index 00000000000..2816f8fd6a7 --- /dev/null +++ b/extensions/google-meet/src/setup.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { GoogleMeetConfig } from "./config.js"; + +type SetupCheck = { + id: string; + ok: boolean; + message: string; +}; + +function resolveUserPath(input: string): string { + if (input === "~") { + return os.homedir(); + } + if (input.startsWith("~/")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): { + ok: boolean; + checks: SetupCheck[]; +} { + const checks: SetupCheck[] = []; + + if (config.auth.tokenPath) { + const tokenPath = resolveUserPath(config.auth.tokenPath); + checks.push({ + id: "google-oauth-token", + ok: fs.existsSync(tokenPath), + message: fs.existsSync(tokenPath) + ? "Google OAuth token file found" + : `Google OAuth token file missing at ${config.auth.tokenPath}`, + }); + } else { + checks.push({ + id: "google-oauth-token", + ok: true, + message: "Google OAuth token path not configured; Chrome profile auth will be used", + }); + } + + if (config.chrome.browserProfile) { + const profilePath = path.join( + os.homedir(), + "Library", + "Application Support", + "Google", + "Chrome", + config.chrome.browserProfile, + ); + checks.push({ + id: "chrome-profile", + ok: fs.existsSync(profilePath), + message: fs.existsSync(profilePath) + ? "Chrome profile found" + : `Chrome profile missing: ${config.chrome.browserProfile}`, + }); + } else { + checks.push({ + id: "chrome-profile", + ok: true, + message: "Chrome profile not pinned; default signed-in profile will be used", + }); + } + + checks.push({ + id: "audio-bridge", + ok: Boolean( + config.chrome.audioBridgeCommand || + (config.chrome.audioInputCommand && config.chrome.audioOutputCommand), + ), + message: config.chrome.audioBridgeCommand + ? "Chrome audio bridge command configured" + : config.chrome.audioInputCommand && config.chrome.audioOutputCommand + ? "Chrome command-pair realtime audio bridge configured" + : "Chrome realtime audio bridge not configured", + }); + + return { + ok: checks.every((check) => check.ok), + checks, + }; +} diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts new file mode 100644 index 00000000000..15d34d301ab --- /dev/null +++ b/extensions/google-meet/src/transports/chrome.ts @@ -0,0 +1,128 @@ +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"; +import type { GoogleMeetConfig } from "../config.js"; +import { + startCommandRealtimeAudioBridge, + type ChromeRealtimeAudioBridgeHandle, +} from "../realtime.js"; + +export function outputMentionsBlackHole2ch(output: string): boolean { + return /\bBlackHole\s+2ch\b/i.test(output); +} + +export async function assertBlackHole2chAvailable(params: { + runtime: PluginRuntime; + timeoutMs: number; +}): Promise { + if (process.platform !== "darwin") { + throw new Error("Chrome Meet transport with blackhole-2ch audio is currently macOS-only"); + } + + const result = await params.runtime.system.runCommandWithTimeout( + ["system_profiler", "SPAudioDataType"], + { timeoutMs: params.timeoutMs }, + ); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.code !== 0 || !outputMentionsBlackHole2ch(output)) { + const hint = + params.runtime.system.formatNativeDependencyHint?.({ + packageName: "BlackHole 2ch", + downloadCommand: "brew install blackhole-2ch", + }) ?? ""; + throw new Error( + [ + "BlackHole 2ch audio device not found.", + "Install BlackHole 2ch and route Chrome input/output through the OpenClaw audio bridge.", + hint, + ] + .filter(Boolean) + .join(" "), + ); + } +} + +export async function launchChromeMeet(params: { + runtime: PluginRuntime; + config: GoogleMeetConfig; + fullConfig: OpenClawConfig; + mode: "realtime" | "transcribe"; + url: string; + logger: RuntimeLogger; +}): Promise<{ + launched: boolean; + audioBridge?: + | { type: "external-command" } + | ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle); +}> { + await assertBlackHole2chAvailable({ + runtime: params.runtime, + timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 10_000), + }); + + if (params.config.chrome.audioBridgeHealthCommand) { + const health = await params.runtime.system.runCommandWithTimeout( + params.config.chrome.audioBridgeHealthCommand, + { timeoutMs: params.config.chrome.joinTimeoutMs }, + ); + if (health.code !== 0) { + throw new Error( + `Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`, + ); + } + } + + let audioBridge: + | { type: "external-command" } + | ({ type: "command-pair" } & ChromeRealtimeAudioBridgeHandle) + | undefined; + + if (params.config.chrome.audioBridgeCommand) { + const bridge = await params.runtime.system.runCommandWithTimeout( + params.config.chrome.audioBridgeCommand, + { timeoutMs: params.config.chrome.joinTimeoutMs }, + ); + if (bridge.code !== 0) { + throw new Error( + `failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`, + ); + } + audioBridge = { type: "external-command" }; + } else if (params.mode === "realtime") { + if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) { + throw new Error( + "Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.", + ); + } + audioBridge = { + type: "command-pair", + ...(await startCommandRealtimeAudioBridge({ + config: params.config, + fullConfig: params.fullConfig, + inputCommand: params.config.chrome.audioInputCommand, + outputCommand: params.config.chrome.audioOutputCommand, + logger: params.logger, + })), + }; + } + + if (!params.config.chrome.launch) { + return { launched: false, audioBridge }; + } + + const argv = ["open", "-a", "Google Chrome"]; + if (params.config.chrome.browserProfile) { + argv.push("--args", `--profile-directory=${params.config.chrome.browserProfile}`); + } + argv.push(params.url); + + const result = await params.runtime.system.runCommandWithTimeout(argv, { + timeoutMs: params.config.chrome.joinTimeoutMs, + }); + if (result.code !== 0) { + throw new Error( + `failed to launch Chrome for Meet: ${result.stderr || result.stdout || result.code}`, + ); + } + return { launched: true, audioBridge }; +} diff --git a/extensions/google-meet/src/transports/twilio.ts b/extensions/google-meet/src/transports/twilio.ts new file mode 100644 index 00000000000..cc1ddfa79a8 --- /dev/null +++ b/extensions/google-meet/src/transports/twilio.ts @@ -0,0 +1,46 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +const DTMF_PATTERN = /^[0-9*#wWpP,]+$/; + +export function normalizeDialInNumber(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + const compact = normalized.replace(/[()\s.-]/g, ""); + if (!/^\+?[0-9]{5,20}$/.test(compact)) { + throw new Error("dialInNumber must be a phone number"); + } + return compact; +} + +export function normalizeDtmfSequence(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + const compact = normalized.replace(/\s+/g, ""); + if (!DTMF_PATTERN.test(compact)) { + throw new Error("dtmfSequence may only contain digits, *, #, comma, w, p"); + } + return compact; +} + +export function buildMeetDtmfSequence(params: { + pin?: string; + dtmfSequence?: string; +}): string | undefined { + const explicit = normalizeDtmfSequence(params.dtmfSequence); + if (explicit) { + return explicit; + } + const pin = normalizeOptionalString(params.pin); + if (!pin) { + return undefined; + } + const compactPin = pin.replace(/\s+/g, ""); + if (!/^[0-9]+#?$/.test(compactPin)) { + throw new Error("pin may only contain digits and an optional trailing #"); + } + return compactPin.endsWith("#") ? compactPin : `${compactPin}#`; +} diff --git a/extensions/google-meet/src/transports/types.ts b/extensions/google-meet/src/transports/types.ts new file mode 100644 index 00000000000..9d0d59124c3 --- /dev/null +++ b/extensions/google-meet/src/transports/types.ts @@ -0,0 +1,50 @@ +import type { GoogleMeetMode, GoogleMeetTransport } from "../config.js"; + +export type GoogleMeetSessionState = "active" | "ended"; + +export type GoogleMeetJoinRequest = { + url: string; + transport?: GoogleMeetTransport; + mode?: GoogleMeetMode; + dialInNumber?: string; + pin?: string; + dtmfSequence?: string; +}; + +export type GoogleMeetSession = { + id: string; + url: string; + transport: GoogleMeetTransport; + mode: GoogleMeetMode; + state: GoogleMeetSessionState; + createdAt: string; + updatedAt: string; + participantIdentity: string; + realtime: { + enabled: boolean; + provider?: string; + model?: string; + toolPolicy: string; + }; + chrome?: { + audioBackend: "blackhole-2ch"; + launched: boolean; + browserProfile?: string; + audioBridge?: { + type: "command-pair" | "external-command"; + provider?: string; + }; + }; + twilio?: { + dialInNumber: string; + pinProvided: boolean; + dtmfSequence?: string; + voiceCallId?: string; + dtmfSent?: boolean; + }; + notes: string[]; +}; + +export type GoogleMeetJoinResult = { + session: GoogleMeetSession; +}; diff --git a/extensions/google-meet/src/voice-call-gateway.ts b/extensions/google-meet/src/voice-call-gateway.ts new file mode 100644 index 00000000000..b712ccd87d1 --- /dev/null +++ b/extensions/google-meet/src/voice-call-gateway.ts @@ -0,0 +1,84 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import type { GoogleMeetConfig } from "./config.js"; + +type VoiceCallGatewayClient = InstanceType; + +type VoiceCallStartResult = { + callId?: string; + initiated?: boolean; + error?: string; +}; + +export type VoiceCallMeetJoinResult = { + callId: string; + dtmfSent: boolean; +}; + +async function createConnectedGatewayClient( + config: GoogleMeetConfig, +): Promise { + let client: VoiceCallGatewayClient; + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("gateway connect timeout")), + config.voiceCall.requestTimeoutMs, + ); + client = new GatewayClient({ + url: config.voiceCall.gatewayUrl, + token: config.voiceCall.token, + requestTimeoutMs: config.voiceCall.requestTimeoutMs, + clientName: "cli", + clientDisplayName: "Google Meet plugin", + scopes: ["operator.write"], + onHelloOk: () => { + clearTimeout(timer); + resolve(); + }, + onConnectError: (err) => { + clearTimeout(timer); + reject(err); + }, + }); + client.start(); + }); + return client!; +} + +export async function joinMeetViaVoiceCallGateway(params: { + config: GoogleMeetConfig; + dialInNumber: string; + dtmfSequence?: string; +}): Promise { + let client: VoiceCallGatewayClient | undefined; + + try { + client = await createConnectedGatewayClient(params.config); + const start = (await client.request( + "voicecall.start", + { + to: params.dialInNumber, + message: params.config.voiceCall.introMessage, + mode: "conversation", + }, + { timeoutMs: params.config.voiceCall.requestTimeoutMs }, + )) as VoiceCallStartResult; + if (!start.callId) { + throw new Error(start.error || "voicecall.start did not return callId"); + } + if (params.dtmfSequence) { + await sleep(params.config.voiceCall.dtmfDelayMs); + await client.request( + "voicecall.dtmf", + { + callId: start.callId, + digits: params.dtmfSequence, + }, + { timeoutMs: params.config.voiceCall.requestTimeoutMs }, + ); + } + return { callId: start.callId, dtmfSent: Boolean(params.dtmfSequence) }; + } finally { + await client?.stopAndWait({ timeoutMs: 1_000 }); + } +} diff --git a/extensions/google-meet/tsconfig.json b/extensions/google-meet/tsconfig.json new file mode 100644 index 00000000000..b8a85a99ac3 --- /dev/null +++ b/extensions/google-meet/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts", "./src/**/*.ts"], + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 467198e94b1..20e2d4471ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,6 +604,22 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/google-meet: + dependencies: + commander: + specifier: ^14.0.3 + version: 14.0.3 + typebox: + specifier: 1.1.28 + version: 1.1.28 + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + extensions/googlechat: dependencies: gaxios: diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index 112963c3183..6597163dfbc 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -22,6 +22,10 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ minHostVersionBaseline: "2026.3.22", }, { pluginId: "google", pluginLocalRuntimeDeps: ["@google/genai"] }, + { + pluginId: "google-meet", + mirroredRootRuntimeDeps: ["commander", "typebox"], + }, { pluginId: "googlechat", pluginLocalRuntimeDeps: ["gaxios", "google-auth-library"],