feat: add Google Meet participant plugin

This commit is contained in:
Peter Steinberger
2026-04-23 21:18:45 +01:00
parent e0072ef91a
commit 59a8afe6fa
21 changed files with 3220 additions and 0 deletions

5
.github/labeler.yml vendored
View File

@@ -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:

View File

@@ -1130,6 +1130,7 @@
"plugins/community",
"plugins/bundles",
"plugins/codex-harness",
"plugins/google-meet",
"plugins/webhooks",
"plugins/voice-call",
"plugins/memory-wiki",

225
docs/plugins/google-meet.md Normal file
View File

@@ -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.

View File

@@ -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<typeof vi.fn>;
on: EventEmitter["on"];
};
function setup(config: Record<string, unknown> = {}) {
const methods = new Map<string, unknown>();
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<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| 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<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| 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");
});
});

View File

@@ -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<string, unknown> {
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
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<string, unknown>) {
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,
},
],
},
);
},
});

View File

@@ -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"
}
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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<string> {
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<GoogleMeetRuntime>;
}) {
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 <id>", "OAuth client id override")
.option("--client-secret <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 <n>", "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>", "Transport: chrome or twilio")
.option("--mode <mode>", "Mode: realtime or transcribe")
.option("--dial-in-number <phone>", "Meet dial-in number for Twilio transport")
.option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
.option("--dtmf-sequence <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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (options: 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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (options: 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("<session-id>", "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);
});
}

View File

@@ -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<string, Record<string, unknown>>;
};
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<string, Record<string, unknown>> {
const raw = asRecord(value);
const providers: Record<string, Record<string, unknown>> = {};
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),
},
};
}

View File

@@ -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<string, unknown>;
config?: Record<string, unknown>;
};
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<GoogleMeetSpace> {
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,
};
}

View File

@@ -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<GoogleMeetOAuthTokens> {
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<string, string | undefined>): 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<GoogleMeetOAuthTokens> {
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<GoogleMeetOAuthTokens> {
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<string>;
writeLine: (message: string) => void;
}): Promise<string> {
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;
}

View File

@@ -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<void>;
};
type ResolvedRealtimeProvider = {
provider: RealtimeVoiceProviderPlugin;
providerConfig: RealtimeVoiceProviderConfig;
};
type ActiveRealtimeBridge = {
acknowledgeMark(): unknown;
close(): unknown;
connect(): Promise<void> | 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<string, unknown> {
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<ChromeRealtimeAudioBridgeHandle> {
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,
};
}

View File

@@ -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<string, GoogleMeetSession>();
readonly #sessionStops = new Map<string, () => Promise<void>>();
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<GoogleMeetJoinResult> {
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 };
}
}

View File

@@ -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,
};
}

View File

@@ -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<void> {
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 };
}

View File

@@ -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}#`;
}

View File

@@ -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;
};

View File

@@ -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<typeof GatewayClient>;
type VoiceCallStartResult = {
callId?: string;
initiated?: boolean;
error?: string;
};
export type VoiceCallMeetJoinResult = {
callId: string;
dtmfSent: boolean;
};
async function createConnectedGatewayClient(
config: GoogleMeetConfig,
): Promise<VoiceCallGatewayClient> {
let client: VoiceCallGatewayClient;
await new Promise<void>((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<VoiceCallMeetJoinResult> {
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 });
}
}

View File

@@ -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"
]
}

16
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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"],