mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
feat: add Google Meet participant plugin
This commit is contained in:
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
225
docs/plugins/google-meet.md
Normal 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.
|
||||
470
extensions/google-meet/index.test.ts
Normal file
470
extensions/google-meet/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
362
extensions/google-meet/index.ts
Normal file
362
extensions/google-meet/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
316
extensions/google-meet/openclaw.plugin.json
Normal file
316
extensions/google-meet/openclaw.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
extensions/google-meet/package.json
Normal file
40
extensions/google-meet/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
307
extensions/google-meet/src/cli.ts
Normal file
307
extensions/google-meet/src/cli.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
318
extensions/google-meet/src/config.ts
Normal file
318
extensions/google-meet/src/config.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
100
extensions/google-meet/src/meet.ts
Normal file
100
extensions/google-meet/src/meet.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
214
extensions/google-meet/src/oauth.ts
Normal file
214
extensions/google-meet/src/oauth.ts
Normal 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;
|
||||
}
|
||||
239
extensions/google-meet/src/realtime.ts
Normal file
239
extensions/google-meet/src/realtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
193
extensions/google-meet/src/runtime.ts
Normal file
193
extensions/google-meet/src/runtime.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
86
extensions/google-meet/src/setup.ts
Normal file
86
extensions/google-meet/src/setup.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
128
extensions/google-meet/src/transports/chrome.ts
Normal file
128
extensions/google-meet/src/transports/chrome.ts
Normal 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 };
|
||||
}
|
||||
46
extensions/google-meet/src/transports/twilio.ts
Normal file
46
extensions/google-meet/src/transports/twilio.ts
Normal 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}#`;
|
||||
}
|
||||
50
extensions/google-meet/src/transports/types.ts
Normal file
50
extensions/google-meet/src/transports/types.ts
Normal 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;
|
||||
};
|
||||
84
extensions/google-meet/src/voice-call-gateway.ts
Normal file
84
extensions/google-meet/src/voice-call-gateway.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
extensions/google-meet/tsconfig.json
Normal file
16
extensions/google-meet/tsconfig.json
Normal 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
16
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user