Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
// Google Meet plugin module implements agent consult behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import {
buildRealtimeVoiceAgentConsultWorkingResponse,
consultRealtimeVoiceAgent,
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
resolveRealtimeVoiceAgentConsultTools,
resolveRealtimeVoiceAgentConsultToolsAllow,
type RealtimeVoiceBridgeSession,
type RealtimeVoiceToolCallEvent,
type RealtimeVoiceTool,
type TalkEventInput,
} from "openclaw/plugin-sdk/realtime-voice";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { GoogleMeetConfig, GoogleMeetToolPolicy } from "./config.js";
const GOOGLE_MEET_CONSULT_SYSTEM_PROMPT = [
"You are a behind-the-scenes consultant for a live meeting voice agent.",
"Prioritize a fast, speakable answer over exhaustive investigation.",
"For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
"Be accurate, brief, and speakable.",
].join(" ");
export function resolveGoogleMeetRealtimeTools(policy: GoogleMeetToolPolicy): RealtimeVoiceTool[] {
return resolveRealtimeVoiceAgentConsultTools(policy);
}
export function submitGoogleMeetConsultWorkingResponse(
session: RealtimeVoiceBridgeSession,
callId: string,
): void {
if (!session.bridge.supportsToolResultContinuation) {
return;
}
session.submitToolResult(callId, buildRealtimeVoiceAgentConsultWorkingResponse("participant"), {
willContinue: true,
});
}
export async function consultOpenClawAgentForGoogleMeet(params: {
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
runtime: PluginRuntime;
logger: RuntimeLogger;
meetingSessionId: string;
requesterSessionKey?: string;
args: unknown;
transcript: Array<{ role: "user" | "assistant"; text: string }>;
}): Promise<{ text: string }> {
const agentId = normalizeAgentId(params.config.realtime.agentId);
const requesterSessionKey =
normalizeOptionalString(params.requesterSessionKey) ?? `agent:${agentId}:main`;
const sessionKey = `agent:${agentId}:subagent:google-meet:${params.meetingSessionId}`;
return await consultRealtimeVoiceAgent({
cfg: params.fullConfig,
agentRuntime: params.runtime.agent,
logger: params.logger,
agentId,
sessionKey,
messageProvider: "google-meet",
lane: "google-meet",
runIdPrefix: `google-meet:${params.meetingSessionId}`,
spawnedBy: requesterSessionKey,
contextMode: "fork",
args: params.args,
transcript: params.transcript,
surface: "a private Google Meet",
userLabel: "Participant",
assistantLabel: "Agent",
questionSourceLabel: "participant",
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(params.config.realtime.toolPolicy),
extraSystemPrompt: GOOGLE_MEET_CONSULT_SYSTEM_PROMPT,
});
}
export function handleGoogleMeetRealtimeConsultToolCall(params: {
strategy: string;
session: RealtimeVoiceBridgeSession;
event: RealtimeVoiceToolCallEvent;
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
runtime: PluginRuntime;
logger: RuntimeLogger;
meetingSessionId: string;
requesterSessionKey?: string;
transcript: Array<{ role: "user" | "assistant"; text: string }>;
onTalkEvent?: (event: TalkEventInput) => void;
}): void {
const callId = params.event.callId || params.event.itemId;
if (params.strategy !== "bidi") {
params.onTalkEvent?.({
type: "tool.error",
callId,
payload: {
name: params.event.name,
error: `Tool "${params.event.name}" is only available in bidi realtime strategy`,
},
final: true,
});
params.session.submitToolResult(callId, {
error: `Tool "${params.event.name}" is only available in bidi realtime strategy`,
});
return;
}
if (params.event.name !== REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
params.onTalkEvent?.({
type: "tool.error",
callId,
payload: { name: params.event.name, error: `Tool "${params.event.name}" not available` },
final: true,
});
params.session.submitToolResult(callId, {
error: `Tool "${params.event.name}" not available`,
});
return;
}
params.onTalkEvent?.({
type: "tool.progress",
callId,
payload: { name: params.event.name, status: "working" },
});
submitGoogleMeetConsultWorkingResponse(params.session, callId);
void consultOpenClawAgentForGoogleMeet({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: params.event.args,
transcript: params.transcript,
})
.then((result) => {
params.onTalkEvent?.({
type: "tool.result",
callId,
payload: { name: params.event.name, result },
final: true,
});
params.session.submitToolResult(callId, result);
})
.catch((error: unknown) => {
params.onTalkEvent?.({
type: "tool.error",
callId,
payload: { name: params.event.name, error: formatErrorMessage(error) },
final: true,
});
params.session.submitToolResult(callId, {
error: formatErrorMessage(error),
});
});
}

View File

@@ -0,0 +1,255 @@
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
// Google Meet plugin module implements calendar behavior.
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { googleApiError } from "./google-api-errors.js";
const GOOGLE_CALENDAR_API_BASE_URL = "https://www.googleapis.com/calendar/v3";
const GOOGLE_CALENDAR_API_HOST = "www.googleapis.com";
const GOOGLE_MEET_URL_HOST = "meet.google.com";
const GOOGLE_CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
type GoogleCalendarEventDate = {
date?: string;
dateTime?: string;
timeZone?: string;
};
type GoogleCalendarConferenceEntryPoint = {
entryPointType?: string;
uri?: string;
label?: string;
};
type GoogleMeetCalendarEvent = {
id?: string;
summary?: string;
description?: string;
location?: string;
status?: string;
htmlLink?: string;
hangoutLink?: string;
start?: GoogleCalendarEventDate;
end?: GoogleCalendarEventDate;
conferenceData?: {
conferenceId?: string;
conferenceSolution?: {
key?: { type?: string };
name?: string;
};
entryPoints?: GoogleCalendarConferenceEntryPoint[];
};
};
export type GoogleMeetCalendarLookupResult = {
calendarId: string;
event: GoogleMeetCalendarEvent;
meetingUri: string;
};
type GoogleMeetCalendarEventsResult = {
calendarId: string;
events: Array<{
event: GoogleMeetCalendarEvent;
meetingUri: string;
selected: boolean;
}>;
};
function appendQuery(url: string, query: Record<string, string | number | boolean | undefined>) {
const parsed = new URL(url);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
parsed.searchParams.set(key, String(value));
}
}
return parsed.toString();
}
function isGoogleMeetUri(value: string | undefined): value is string {
if (!value?.trim()) {
return false;
}
try {
return new URL(value).hostname === GOOGLE_MEET_URL_HOST;
} catch {
return false;
}
}
function extractGoogleMeetUriFromText(value: string | undefined): string | undefined {
const match = value?.match(/https:\/\/meet\.google\.com\/[a-z0-9-]+/i);
return match?.[0];
}
export function extractGoogleMeetUriFromCalendarEvent(
event: GoogleMeetCalendarEvent,
): string | undefined {
if (isGoogleMeetUri(event.hangoutLink)) {
return event.hangoutLink;
}
const entryPoints = event.conferenceData?.entryPoints ?? [];
const videoEntry = entryPoints.find(
(entry) => entry.entryPointType === "video" && isGoogleMeetUri(entry.uri),
);
if (videoEntry?.uri) {
return videoEntry.uri;
}
const meetEntry = entryPoints.find((entry) => isGoogleMeetUri(entry.uri));
if (meetEntry?.uri) {
return meetEntry.uri;
}
return (
extractGoogleMeetUriFromText(event.location) ?? extractGoogleMeetUriFromText(event.description)
);
}
export function buildGoogleMeetCalendarDayWindow(now = new Date()): {
timeMin: string;
timeMax: string;
} {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return { timeMin: start.toISOString(), timeMax: end.toISOString() };
}
function parseCalendarEventTime(value: GoogleCalendarEventDate | undefined): number | undefined {
const raw = value?.dateTime ?? value?.date;
if (!raw) {
return undefined;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
function rankCalendarEvent(event: GoogleMeetCalendarEvent, nowMs: number): number {
const startMs = parseCalendarEventTime(event.start) ?? Number.POSITIVE_INFINITY;
const endMs = parseCalendarEventTime(event.end) ?? startMs;
if (startMs <= nowMs && endMs >= nowMs) {
return 0;
}
if (startMs > nowMs) {
return startMs - nowMs;
}
return nowMs - startMs + 30 * 24 * 60 * 60 * 1000;
}
function chooseBestMeetCalendarEvent(
events: GoogleMeetCalendarEvent[],
now: Date,
): GoogleMeetCalendarLookupResult["event"] | undefined {
const nowMs = now.getTime();
let selected: GoogleMeetCalendarEvent | undefined;
let selectedRank = Number.POSITIVE_INFINITY;
for (const event of events) {
if (event.status === "cancelled" || !extractGoogleMeetUriFromCalendarEvent(event)) {
continue;
}
const rank = rankCalendarEvent(event, nowMs);
if (!selected || rank < selectedRank) {
selected = event;
selectedRank = rank;
}
}
return selected;
}
async function fetchGoogleCalendarEvents(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
timeMin?: string;
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<{ calendarId: string; events: GoogleMeetCalendarEvent[]; now: Date }> {
const calendarId = params.calendarId?.trim() || "primary";
const now = params.now ?? new Date();
const defaultTimeMax = new Date(now);
defaultTimeMax.setDate(defaultTimeMax.getDate() + 7);
const { response, release } = await fetchWithSsrFGuard({
url: appendQuery(
`${GOOGLE_CALENDAR_API_BASE_URL}/calendars/${encodeURIComponent(calendarId)}/events`,
{
maxResults: params.maxResults ?? 50,
orderBy: "startTime",
q: params.eventQuery?.trim() || undefined,
showDeleted: false,
singleEvents: true,
timeMin: params.timeMin ?? now.toISOString(),
timeMax: params.timeMax ?? defaultTimeMax.toISOString(),
},
),
init: {
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: "application/json",
},
},
policy: { allowedHostnames: [GOOGLE_CALENDAR_API_HOST] },
auditContext: "google-meet.calendar.events.list",
});
try {
if (!response.ok) {
throw await googleApiError({
response,
prefix: "Google Calendar events.list",
scopes: [GOOGLE_CALENDAR_EVENTS_SCOPE],
});
}
const payload = await readProviderJsonResponse<{ items?: unknown }>(
response,
"Google Calendar events.list",
);
if (payload.items !== undefined && !Array.isArray(payload.items)) {
throw new Error("Google Calendar events.list response had non-array items");
}
return { calendarId, events: (payload.items ?? []) as GoogleMeetCalendarEvent[], now };
} finally {
await release();
}
}
export async function listGoogleMeetCalendarEvents(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
timeMin?: string;
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<GoogleMeetCalendarEventsResult> {
const { calendarId, events, now } = await fetchGoogleCalendarEvents(params);
const best = chooseBestMeetCalendarEvent(events, now);
return {
calendarId,
events: events
.map((event) => {
const meetingUri = extractGoogleMeetUriFromCalendarEvent(event);
return meetingUri ? { event, meetingUri, selected: event === best } : undefined;
})
.filter((event): event is GoogleMeetCalendarEventsResult["events"][number] => Boolean(event)),
};
}
export async function findGoogleMeetCalendarEvent(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
timeMin?: string;
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<GoogleMeetCalendarLookupResult> {
const result = await listGoogleMeetCalendarEvents(params);
const selected = result.events.find((event) => event.selected) ?? result.events[0];
if (!selected) {
throw new Error("No Google Calendar event with a Google Meet link matched the query");
}
return {
calendarId: result.calendarId,
event: selected.event,
meetingUri: selected.meetingUri,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
// Google Meet tests cover config compat plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
legacyConfigRules,
migrateGoogleMeetLegacyRealtimeProvider,
normalizeCompatibilityConfig,
} from "./config-compat.js";
describe("google-meet config compatibility", () => {
it("detects legacy Google realtime provider config", () => {
expect(
legacyConfigRules[0]?.match({
provider: "google",
model: "gemini-2.5-flash-native-audio-preview-12-2025",
}),
).toBe(true);
});
it("migrates legacy Google bidi provider intent to scoped realtime providers", () => {
const config = {
plugins: {
entries: {
"google-meet": {
enabled: true,
config: {
defaultMode: "agent",
realtime: {
provider: "google",
model: "gemini-2.5-flash-native-audio-preview-12-2025",
providers: {
google: {
voice: "Kore",
},
},
},
},
},
},
},
} as OpenClawConfig;
const migration = migrateGoogleMeetLegacyRealtimeProvider(config);
expect(migration?.changes).toEqual([
'Moved Google Meet legacy realtime.provider="google" intent to realtime.voiceProvider="google" and realtime.transcriptionProvider="openai".',
]);
expect(
(
migration!.config.plugins!.entries!["google-meet"] as {
config?: { realtime?: Record<string, unknown> };
}
).config?.realtime,
).toEqual({
provider: "openai",
transcriptionProvider: "openai",
voiceProvider: "google",
model: "gemini-2.5-flash-native-audio-preview-12-2025",
providers: {
google: {
voice: "Kore",
},
},
});
});
it("leaves fully scoped provider configs alone", () => {
const config = {
plugins: {
entries: {
"google-meet": {
config: {
realtime: {
provider: "google",
transcriptionProvider: "custom-stt",
voiceProvider: "custom-voice",
},
},
},
},
},
} as OpenClawConfig;
const migration = normalizeCompatibilityConfig({ cfg: config });
expect(migration.changes).toStrictEqual([]);
expect(
(
migration.config.plugins!.entries!["google-meet"] as {
config?: { realtime?: Record<string, unknown> };
}
).config?.realtime,
).toEqual({
provider: "google",
transcriptionProvider: "custom-stt",
voiceProvider: "custom-voice",
});
});
});

View File

@@ -0,0 +1,79 @@
// Google Meet helper module supports config compat behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asNullableRecord as asRecord,
normalizeOptionalLowercaseString as normalizeProviderId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
type LegacyConfigRule = {
path: Array<string | number>;
message: string;
match: (value: unknown) => boolean;
};
function hasOwn(record: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(record, key);
}
function hasLegacyGoogleRealtimeProvider(value: unknown): boolean {
const realtime = asRecord(value);
if (!realtime || normalizeProviderId(realtime.provider) !== "google") {
return false;
}
return !hasOwn(realtime, "voiceProvider") || !hasOwn(realtime, "transcriptionProvider");
}
export const legacyConfigRules: LegacyConfigRule[] = [
{
path: ["plugins", "entries", "google-meet", "config", "realtime"],
message:
'plugins.entries.google-meet.config.realtime.provider="google" is legacy for Gemini Live bidi mode; use realtime.voiceProvider="google" and realtime.transcriptionProvider="openai". Run "openclaw doctor --fix".',
match: hasLegacyGoogleRealtimeProvider,
},
];
export function migrateGoogleMeetLegacyRealtimeProvider(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const rawEntry = asRecord(config.plugins?.entries?.["google-meet"]);
const rawPluginConfig = asRecord(rawEntry?.config);
const rawRealtime = asRecord(rawPluginConfig?.realtime);
if (!rawRealtime || !hasLegacyGoogleRealtimeProvider(rawRealtime)) {
return null;
}
const nextConfig = structuredClone(config);
const nextPlugins = asRecord(nextConfig.plugins) ?? {};
nextConfig.plugins = nextPlugins;
const nextEntries = asRecord(nextPlugins.entries) ?? {};
nextPlugins.entries = nextEntries;
const nextEntry = asRecord(nextEntries["google-meet"]) ?? {};
nextEntries["google-meet"] = nextEntry;
const nextPluginConfig = asRecord(nextEntry.config) ?? {};
nextEntry.config = nextPluginConfig;
const nextRealtime = asRecord(nextPluginConfig.realtime) ?? {};
nextPluginConfig.realtime = nextRealtime;
nextRealtime.provider = "openai";
if (!hasOwn(nextRealtime, "transcriptionProvider")) {
nextRealtime.transcriptionProvider = "openai";
}
if (!hasOwn(nextRealtime, "voiceProvider")) {
nextRealtime.voiceProvider = "google";
}
return {
config: nextConfig,
changes: [
'Moved Google Meet legacy realtime.provider="google" intent to realtime.voiceProvider="google" and realtime.transcriptionProvider="openai".',
],
};
}
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateGoogleMeetLegacyRealtimeProvider(cfg) ?? { config: cfg, changes: [] };
}

View File

@@ -0,0 +1,57 @@
// Google Meet tests cover config plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import { resolveGoogleMeetConfig, resolveGoogleMeetGatewayOperationTimeoutMs } from "./config.js";
describe("google meet gateway operation timeout", () => {
it("caps timer config fields before runtime polling uses them", () => {
const config = resolveGoogleMeetConfig({
chrome: {
joinTimeoutMs: Number.MAX_VALUE,
waitForInCallMs: Number.MAX_VALUE,
bargeInCooldownMs: Number.MAX_VALUE,
},
voiceCall: {
requestTimeoutMs: Number.MAX_VALUE,
dtmfDelayMs: Number.MAX_VALUE,
postDtmfSpeechDelayMs: Number.MAX_VALUE,
},
});
expect(config.chrome.joinTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
expect(config.chrome.waitForInCallMs).toBe(MAX_TIMER_TIMEOUT_MS);
expect(config.chrome.bargeInCooldownMs).toBe(MAX_TIMER_TIMEOUT_MS);
expect(config.voiceCall.requestTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
expect(config.voiceCall.dtmfDelayMs).toBe(MAX_TIMER_TIMEOUT_MS);
expect(config.voiceCall.postDtmfSpeechDelayMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("adds operation grace to normal transport timeouts", () => {
expect(resolveGoogleMeetGatewayOperationTimeoutMs(resolveGoogleMeetConfig({}))).toBe(60_000);
expect(
resolveGoogleMeetGatewayOperationTimeoutMs(
resolveGoogleMeetConfig({
chrome: { joinTimeoutMs: 120_000 },
voiceCall: { requestTimeoutMs: 30_000 },
}),
),
).toBe(150_000);
});
it("caps overflowed transport timeout grace", () => {
expect(
resolveGoogleMeetGatewayOperationTimeoutMs(
resolveGoogleMeetConfig({
chrome: { joinTimeoutMs: Number.MAX_VALUE },
}),
),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(
resolveGoogleMeetGatewayOperationTimeoutMs(
resolveGoogleMeetConfig({
voiceCall: { requestTimeoutMs: Number.MAX_VALUE },
}),
),
).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,598 @@
// Google Meet helper module supports config behavior.
import {
addTimerTimeoutGraceMs,
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
resolveRealtimeVoiceAgentConsultToolPolicy,
type RealtimeVoiceAgentConsultToolPolicy,
} from "openclaw/plugin-sdk/realtime-voice";
import {
asRecord,
normalizeOptionalLowercaseString,
normalizeOptionalString,
normalizeOptionalTrimmedStringList,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export type GoogleMeetTransport = "chrome" | "chrome-node" | "twilio";
export type GoogleMeetMode = "agent" | "bidi" | "transcribe";
export type GoogleMeetModeInput = GoogleMeetMode | "realtime";
export type GoogleMeetRealtimeStrategy = "agent" | "bidi";
type GoogleMeetChromeAudioFormat = "pcm16-24khz" | "g711-ulaw-8khz";
export type GoogleMeetToolPolicy = RealtimeVoiceAgentConsultToolPolicy;
export type GoogleMeetConfig = {
enabled: boolean;
defaults: {
meeting?: string;
};
preview: {
enrollmentAcknowledged: boolean;
};
defaultTransport: GoogleMeetTransport;
defaultMode: GoogleMeetMode;
chrome: {
audioBackend: "blackhole-2ch";
audioFormat: GoogleMeetChromeAudioFormat;
audioBufferBytes: number;
launch: boolean;
browserProfile?: string;
guestName: string;
reuseExistingTab: boolean;
autoJoin: boolean;
joinTimeoutMs: number;
waitForInCallMs: number;
audioInputCommand?: string[];
audioOutputCommand?: string[];
bargeInInputCommand?: string[];
bargeInRmsThreshold: number;
bargeInPeakThreshold: number;
bargeInCooldownMs: number;
audioBridgeCommand?: string[];
audioBridgeHealthCommand?: string[];
};
chromeNode: {
node?: string;
};
twilio: {
defaultDialInNumber?: string;
defaultPin?: string;
defaultDtmfSequence?: string;
};
voiceCall: {
enabled: boolean;
gatewayUrl?: string;
token?: string;
requestTimeoutMs: number;
dtmfDelayMs: number;
postDtmfSpeechDelayMs: number;
introMessage?: string;
};
realtime: {
strategy: GoogleMeetRealtimeStrategy;
provider?: string;
transcriptionProvider?: string;
voiceProvider?: string;
model?: string;
instructions?: string;
introMessage?: string;
agentId?: 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 function resolveGoogleMeetGatewayOperationTimeoutMs(config: GoogleMeetConfig): number {
return Math.max(
60_000,
addTimerTimeoutGraceMs(config.chrome.joinTimeoutMs, 30_000) ?? 1,
addTimerTimeoutGraceMs(config.voiceCall.requestTimeoutMs, 10_000) ?? 1,
);
}
const SOX_DEFAULT_BUFFER_BYTES = 8192;
const SOX_MIN_BUFFER_BYTES = 17;
export const DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES = SOX_DEFAULT_BUFFER_BYTES / 2;
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
function withSoxBuffer(command: readonly string[], bufferBytes: number): string[] {
return [command[0] ?? "sox", "-q", "--buffer", String(bufferBytes), ...command.slice(2)];
}
const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND_BASE = [
"sox",
"-q",
"-t",
"coreaudio",
"BlackHole 2ch",
"-t",
"raw",
"-r",
"24000",
"-c",
"1",
"-e",
"signed-integer",
"-b",
"16",
"-L",
"-",
] as const;
const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND_BASE = [
"sox",
"-q",
"-t",
"raw",
"-r",
"24000",
"-c",
"1",
"-e",
"signed-integer",
"-b",
"16",
"-L",
"-",
"-t",
"coreaudio",
"BlackHole 2ch",
] as const;
const LEGACY_GOOGLE_MEET_AUDIO_INPUT_COMMAND_BASE = [
"rec",
"-q",
"-t",
"raw",
"-r",
"8000",
"-c",
"1",
"-e",
"mu-law",
"-b",
"8",
"-",
] as const;
const LEGACY_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND_BASE = [
"play",
"-q",
"-t",
"raw",
"-r",
"8000",
"-c",
"1",
"-e",
"mu-law",
"-b",
"8",
"-",
] as const;
export const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND = withSoxBuffer(
DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND_BASE,
DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES,
);
export const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND = withSoxBuffer(
DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND_BASE,
DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES,
);
const DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT: GoogleMeetChromeAudioFormat = "pcm16-24khz";
const DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD = 650;
const DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD = 2500;
const DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS = 900;
const DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS = `You are joining a private Google Meet as an OpenClaw voice transport. Keep spoken replies brief and natural. In agent mode, wait for OpenClaw consult results and speak them exactly. In bidi mode, answer directly and call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} for deeper reasoning, current information, or tools.`;
const DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE = "Say exactly: I'm here and listening.";
const DEFAULT_GOOGLE_MEET_CONFIG: GoogleMeetConfig = {
enabled: true,
defaults: {},
preview: {
enrollmentAcknowledged: false,
},
defaultTransport: "chrome",
defaultMode: "agent",
chrome: {
audioBackend: "blackhole-2ch",
audioFormat: DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT,
audioBufferBytes: DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES,
launch: true,
guestName: "OpenClaw Agent",
reuseExistingTab: true,
autoJoin: true,
joinTimeoutMs: 30_000,
waitForInCallMs: 20_000,
audioInputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND],
audioOutputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND],
bargeInRmsThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD,
bargeInPeakThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD,
bargeInCooldownMs: DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS,
},
chromeNode: {},
twilio: {},
voiceCall: {
enabled: true,
requestTimeoutMs: 30_000,
dtmfDelayMs: 12_000,
postDtmfSpeechDelayMs: 5_000,
},
realtime: {
strategy: "agent",
provider: "openai",
transcriptionProvider: "openai",
instructions: DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS,
introMessage: DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE,
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 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 resolveTimerConfigMs(value: unknown, fallback: number): number {
return resolvePositiveTimerTimeoutMs(resolveNumber(value, fallback), fallback);
}
function resolveOptionalNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const trimmed = value.trim();
const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN;
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 normalizeStringAllowEmpty(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : 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 {
return normalizeOptionalTrimmedStringList(value);
}
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 === "chrome-node" || normalized === "twilio"
? normalized
: fallback;
}
function resolveMode(value: unknown, fallback: GoogleMeetMode): GoogleMeetMode {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "realtime") {
return "agent";
}
return normalized === "agent" || normalized === "bidi" || normalized === "transcribe"
? normalized
: fallback;
}
function resolveRealtimeStrategy(
value: unknown,
fallback: GoogleMeetRealtimeStrategy,
): GoogleMeetRealtimeStrategy {
const normalized = normalizeOptionalLowercaseString(value);
return normalized === "agent" || normalized === "bidi" ? normalized : fallback;
}
function resolveChromeAudioFormat(value: unknown): GoogleMeetChromeAudioFormat | undefined {
const normalized = normalizeOptionalString(value)?.toLowerCase().replaceAll("_", "-");
switch (normalized) {
case "pcm16-24khz":
case "pcm16-24k":
case "pcm24":
case "pcm":
return "pcm16-24khz";
case "g711-ulaw-8khz":
case "g711-ulaw-8k":
case "g711-ulaw":
case "mulaw":
case "mu-law":
return "g711-ulaw-8khz";
default:
return undefined;
}
}
function resolveAudioBufferBytes(value: unknown, fallback: number): number {
const number = resolveNumber(value, fallback);
if (!Number.isFinite(number) || number <= 0) {
return fallback;
}
return Math.max(SOX_MIN_BUFFER_BYTES, Math.trunc(number));
}
function defaultAudioInputCommand(
format: GoogleMeetChromeAudioFormat,
bufferBytes: number,
): string[] {
return withSoxBuffer(
format === "g711-ulaw-8khz"
? LEGACY_GOOGLE_MEET_AUDIO_INPUT_COMMAND_BASE
: DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND_BASE,
bufferBytes,
);
}
function defaultAudioOutputCommand(
format: GoogleMeetChromeAudioFormat,
bufferBytes: number,
): string[] {
return withSoxBuffer(
format === "g711-ulaw-8khz"
? LEGACY_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND_BASE
: DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND_BASE,
bufferBytes,
);
}
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 configuredAudioInputCommand = resolveStringArray(chrome.audioInputCommand);
const configuredAudioOutputCommand = resolveStringArray(chrome.audioOutputCommand);
const hasCustomAudioCommand =
configuredAudioInputCommand !== undefined || configuredAudioOutputCommand !== undefined;
const audioFormat =
resolveChromeAudioFormat(chrome.audioFormat) ??
(hasCustomAudioCommand ? "g711-ulaw-8khz" : DEFAULT_GOOGLE_MEET_CONFIG.chrome.audioFormat);
const audioBufferBytes = resolveAudioBufferBytes(
chrome.audioBufferBytes,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.audioBufferBytes,
);
const chromeNode = asRecord(raw.chromeNode);
const twilio = asRecord(raw.twilio);
const voiceCall = asRecord(raw.voiceCall);
const realtime = asRecord(raw.realtime);
const realtimeProvider = normalizeOptionalString(realtime.provider);
const resolvedRealtimeProvider = realtimeProvider ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.provider;
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",
audioFormat,
audioBufferBytes,
launch: resolveBoolean(chrome.launch, DEFAULT_GOOGLE_MEET_CONFIG.chrome.launch),
browserProfile: normalizeOptionalString(chrome.browserProfile),
guestName:
normalizeOptionalString(chrome.guestName) ?? DEFAULT_GOOGLE_MEET_CONFIG.chrome.guestName,
reuseExistingTab: resolveBoolean(
chrome.reuseExistingTab,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.reuseExistingTab,
),
autoJoin: resolveBoolean(chrome.autoJoin, DEFAULT_GOOGLE_MEET_CONFIG.chrome.autoJoin),
joinTimeoutMs: resolveTimerConfigMs(
chrome.joinTimeoutMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.joinTimeoutMs,
),
waitForInCallMs: resolveTimerConfigMs(
chrome.waitForInCallMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.waitForInCallMs,
),
audioInputCommand:
configuredAudioInputCommand ?? defaultAudioInputCommand(audioFormat, audioBufferBytes),
audioOutputCommand:
configuredAudioOutputCommand ?? defaultAudioOutputCommand(audioFormat, audioBufferBytes),
bargeInInputCommand: resolveStringArray(chrome.bargeInInputCommand),
bargeInRmsThreshold: resolveNumber(
chrome.bargeInRmsThreshold,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInRmsThreshold,
),
bargeInPeakThreshold: resolveNumber(
chrome.bargeInPeakThreshold,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInPeakThreshold,
),
bargeInCooldownMs: resolveTimerConfigMs(
chrome.bargeInCooldownMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInCooldownMs,
),
audioBridgeCommand: resolveStringArray(chrome.audioBridgeCommand),
audioBridgeHealthCommand: resolveStringArray(chrome.audioBridgeHealthCommand),
},
chromeNode: {
node: normalizeOptionalString(chromeNode.node),
},
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: resolveTimerConfigMs(
voiceCall.requestTimeoutMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.requestTimeoutMs,
),
dtmfDelayMs: resolveTimerConfigMs(
voiceCall.dtmfDelayMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.dtmfDelayMs,
),
postDtmfSpeechDelayMs: resolveTimerConfigMs(
voiceCall.postDtmfSpeechDelayMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.postDtmfSpeechDelayMs,
),
introMessage: normalizeOptionalString(voiceCall.introMessage),
},
realtime: {
strategy: resolveRealtimeStrategy(
realtime.strategy,
DEFAULT_GOOGLE_MEET_CONFIG.realtime.strategy,
),
provider: resolvedRealtimeProvider,
transcriptionProvider:
normalizeOptionalString(realtime.transcriptionProvider) ??
(realtimeProvider && realtimeProvider !== "google"
? resolvedRealtimeProvider
: DEFAULT_GOOGLE_MEET_CONFIG.realtime.transcriptionProvider),
voiceProvider: normalizeOptionalString(realtime.voiceProvider),
model: normalizeOptionalString(realtime.model) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.model,
instructions:
normalizeOptionalString(realtime.instructions) ??
DEFAULT_GOOGLE_MEET_CONFIG.realtime.instructions,
introMessage:
normalizeStringAllowEmpty(realtime.introMessage) ??
DEFAULT_GOOGLE_MEET_CONFIG.realtime.introMessage,
agentId: normalizeOptionalString(realtime.agentId),
toolPolicy: resolveRealtimeVoiceAgentConsultToolPolicy(
realtime.toolPolicy,
DEFAULT_GOOGLE_MEET_CONFIG.realtime.toolPolicy,
),
providers: resolveProvidersConfig(realtime.providers),
},
oauth: {
clientId:
normalizeOptionalString(oauth.clientId) ??
normalizeOptionalString(auth.clientId) ??
readEnvString(env, GOOGLE_MEET_CLIENT_ID_KEYS),
clientSecret:
normalizeOptionalString(oauth.clientSecret) ??
normalizeOptionalString(auth.clientSecret) ??
readEnvString(env, GOOGLE_MEET_CLIENT_SECRET_KEYS),
refreshToken:
normalizeOptionalString(oauth.refreshToken) ??
readEnvString(env, GOOGLE_MEET_REFRESH_TOKEN_KEYS),
accessToken:
normalizeOptionalString(oauth.accessToken) ??
readEnvString(env, GOOGLE_MEET_ACCESS_TOKEN_KEYS),
expiresAt:
resolveOptionalNumber(oauth.expiresAt) ??
readEnvNumber(env, GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS),
},
auth: {
provider: "google-oauth",
clientId: normalizeOptionalString(auth.clientId),
clientSecret: normalizeOptionalString(auth.clientSecret),
tokenPath: normalizeOptionalString(auth.tokenPath),
},
};
}

View File

@@ -0,0 +1,158 @@
// Google Meet plugin module implements create behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import {
createGoogleMeetSpace,
type GoogleMeetAccessType,
type GoogleMeetEntryPointAccess,
type GoogleMeetSpaceConfig,
} from "./meet.js";
import { resolveGoogleMeetAccessToken } from "./oauth.js";
import type { GoogleMeetRuntime } from "./runtime.js";
import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js";
function normalizeTransport(value: unknown): GoogleMeetTransport | undefined {
return value === "chrome" || value === "chrome-node" || value === "twilio" ? value : undefined;
}
function normalizeMode(value: unknown): GoogleMeetMode | undefined {
if (value === "realtime") {
return "agent";
}
return value === "agent" || value === "bidi" || value === "transcribe" ? value : undefined;
}
function normalizeGoogleMeetAccessType(value: unknown): GoogleMeetAccessType | undefined {
const normalized = normalizeOptionalString(value)?.toUpperCase().replaceAll("-", "_");
return normalized === "OPEN" || normalized === "TRUSTED" || normalized === "RESTRICTED"
? normalized
: undefined;
}
function normalizeGoogleMeetEntryPointAccess(
value: unknown,
): GoogleMeetEntryPointAccess | undefined {
const normalized = normalizeOptionalString(value)?.toUpperCase().replaceAll("-", "_");
return normalized === "ALL" || normalized === "CREATOR_APP_ONLY" ? normalized : undefined;
}
export function resolveCreateSpaceConfig(
raw: Record<string, unknown>,
): GoogleMeetSpaceConfig | undefined {
const rawAccessType = normalizeOptionalString(raw.accessType);
const rawEntryPointAccess = normalizeOptionalString(raw.entryPointAccess);
const accessType = normalizeGoogleMeetAccessType(raw.accessType);
const entryPointAccess = normalizeGoogleMeetEntryPointAccess(raw.entryPointAccess);
if (rawAccessType !== undefined && !accessType) {
throw new Error("Invalid Google Meet accessType. Expected OPEN, TRUSTED, or RESTRICTED.");
}
if (rawEntryPointAccess !== undefined && !entryPointAccess) {
throw new Error("Invalid Google Meet entryPointAccess. Expected ALL or CREATOR_APP_ONLY.");
}
const config = {
...(accessType ? { accessType } : {}),
...(entryPointAccess ? { entryPointAccess } : {}),
};
return Object.keys(config).length > 0 ? config : undefined;
}
export function hasCreateSpaceConfigInput(raw: Record<string, unknown>): boolean {
return (
normalizeOptionalString(raw.accessType) !== undefined ||
normalizeOptionalString(raw.entryPointAccess) !== undefined
);
}
async function createSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
const token = await resolveGoogleMeetAccessToken({
clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId,
clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret,
refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken,
accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken,
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt,
});
const result = await createGoogleMeetSpace({
accessToken: token.accessToken,
config: resolveCreateSpaceConfig(raw),
});
return { source: "api" as const, token, ...result };
}
function hasGoogleMeetOAuth(config: GoogleMeetConfig, raw: Record<string, unknown>): boolean {
return Boolean(
normalizeOptionalString(raw.accessToken) ??
normalizeOptionalString(raw.refreshToken) ??
config.oauth.accessToken ??
config.oauth.refreshToken,
);
}
export async function createMeetFromParams(params: {
config: GoogleMeetConfig;
runtime: OpenClawPluginApi["runtime"];
raw: Record<string, unknown>;
}) {
if (hasGoogleMeetOAuth(params.config, params.raw)) {
const { token: _token, ...result } = await createSpaceFromParams(params.config, params.raw);
return {
...result,
joined: false,
nextAction:
"URL-only creation was requested. Call google_meet with action=join and url=meetingUri to enter the meeting.",
};
}
if (hasCreateSpaceConfigInput(params.raw)) {
throw new Error(
"Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove accessType/entryPointAccess.",
);
}
const browser = await createMeetWithBrowserProxyOnNode({
runtime: params.runtime,
config: params.config,
});
return {
source: browser.source,
meetingUri: browser.meetingUri,
joined: false,
nextAction:
"URL-only creation was requested. Call google_meet with action=join and url=meetingUri to enter the meeting.",
space: {
name: `browser/${browser.meetingUri.split("/").pop()}`,
meetingUri: browser.meetingUri,
},
browser: {
nodeId: browser.nodeId,
targetId: browser.targetId,
browserUrl: browser.browserUrl,
browserTitle: browser.browserTitle,
notes: browser.notes,
},
};
}
export async function createAndJoinMeetFromParams(params: {
config: GoogleMeetConfig;
runtime: OpenClawPluginApi["runtime"];
raw: Record<string, unknown>;
ensureRuntime: () => Promise<GoogleMeetRuntime>;
}) {
const created = await createMeetFromParams(params);
const rt = await params.ensureRuntime();
const join = await rt.join({
url: created.meetingUri,
transport: normalizeTransport(params.raw.transport),
mode: normalizeMode(params.raw.mode),
dialInNumber: normalizeOptionalString(params.raw.dialInNumber),
pin: normalizeOptionalString(params.raw.pin),
dtmfSequence: normalizeOptionalString(params.raw.dtmfSequence),
message: normalizeOptionalString(params.raw.message),
requesterSessionKey: normalizeOptionalString(params.raw.requesterSessionKey),
});
return {
...created,
joined: true,
nextAction: "Share meetingUri with participants; the OpenClaw agent has started the join flow.",
join,
};
}

View File

@@ -0,0 +1,77 @@
// Google Meet tests cover bounded Drive document export response reads.
import { describe, expect, it, vi } from "vitest";
import { exportGoogleDriveDocumentText } from "./drive.js";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
const mockFetch = vi.mocked(fetchWithSsrFGuard);
function makeStreamResponse(sizeBytes: number, status = 200): Response {
const chunk = new Uint8Array(Math.min(sizeBytes, 65536)).fill(0x78); // 'x'
let sent = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= sizeBytes) {
controller.close();
return;
}
const remaining = sizeBytes - sent;
const toSend = Math.min(chunk.length, remaining);
controller.enqueue(chunk.subarray(0, toSend));
sent += toSend;
},
});
return new Response(stream, {
status,
headers: { "Content-Type": "text/plain" },
});
}
describe("exportGoogleDriveDocumentText bound", () => {
it("returns document text when response is within the 16 MiB cap", async () => {
const UNDER_CAP = 256;
const response = makeStreamResponse(UNDER_CAP);
mockFetch.mockResolvedValueOnce({
response,
finalUrl: "https://www.googleapis.com/drive/v3/files/doc-id/export?mimeType=text%2Fplain",
release: vi.fn(async () => undefined),
});
const result = await exportGoogleDriveDocumentText({
accessToken: "tok",
documentId: "doc-id",
});
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("rejects with a size error when response exceeds 16 MiB cap (fail-closed)", async () => {
const OVER_CAP = 17 * 1024 * 1024; // 17 MiB
const response = makeStreamResponse(OVER_CAP);
const release = vi.fn(async () => undefined);
mockFetch.mockResolvedValueOnce({
response,
finalUrl: "https://www.googleapis.com/drive/v3/files/doc-id/export?mimeType=text%2Fplain",
release,
});
await expect(
exportGoogleDriveDocumentText({ accessToken: "tok", documentId: "doc-id" }),
).rejects.toThrow(/exceeds/i);
expect(release).toHaveBeenCalledTimes(1);
});
it("negative-control: bare response.text() buffers the full oversized body (no protection)", async () => {
const OVER_CAP = 17 * 1024 * 1024; // 17 MiB
const response = makeStreamResponse(OVER_CAP);
// Calling response.text() directly buffers everything without throwing.
const text = await response.text();
expect(text.length).toBeGreaterThan(16 * 1024 * 1024);
});
});

View File

@@ -0,0 +1,72 @@
// Google Meet plugin module implements drive behavior.
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
import { googleApiError } from "./google-api-errors.js";
const GOOGLE_DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3";
const GOOGLE_DRIVE_API_HOST = "www.googleapis.com";
const GOOGLE_DRIVE_MEET_SCOPE = "https://www.googleapis.com/auth/drive.meet.readonly";
const TEXT_PLAIN_MIME = "text/plain";
function appendQuery(url: string, query: Record<string, string | undefined>) {
const parsed = new URL(url);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
parsed.searchParams.set(key, value);
}
}
return parsed.toString();
}
export function extractGoogleDriveDocumentId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (/^https?:\/\//i.test(trimmed)) {
try {
const url = new URL(trimmed);
const documentMatch = url.pathname.match(/\/document\/d\/([^/]+)/);
return documentMatch?.[1];
} catch {
return undefined;
}
}
const segments = trimmed.split("/").filter(Boolean);
return segments.at(-1);
}
export async function exportGoogleDriveDocumentText(params: {
accessToken: string;
documentId: string;
}): Promise<string> {
const { response, release } = await fetchWithSsrFGuard({
url: appendQuery(
`${GOOGLE_DRIVE_API_BASE_URL}/files/${encodeURIComponent(params.documentId)}/export`,
{ mimeType: TEXT_PLAIN_MIME },
),
init: {
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: TEXT_PLAIN_MIME,
},
},
policy: { allowedHostnames: [GOOGLE_DRIVE_API_HOST] },
auditContext: "google-meet.drive.files.export",
});
try {
if (!response.ok) {
throw await googleApiError({
response,
prefix: "Google Drive files.export",
scopes: [GOOGLE_DRIVE_MEET_SCOPE],
});
}
return await readProviderTextResponse(response, "Google Drive files.export");
} finally {
await release();
}
}

View File

@@ -0,0 +1,47 @@
// Google Meet tests cover bounded Google API error handling.
import { describe, expect, it, vi } from "vitest";
import { googleApiError } from "./google-api-errors.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("googleApiError", () => {
it("bounds Google API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"access denied ".repeat(1024)}tail`, {
status: 403,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const error = await googleApiError({
response: tracked.response,
prefix: "Google Meet spaces.get",
scopes: ["https://www.googleapis.com/auth/meetings.space.readonly"],
});
expect(error.message).toContain("Google Meet spaces.get failed (403): access denied");
expect(error.message).not.toContain("tail");
expect(error.message.length).toBeLessThan(8_400);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
// Google Meet plugin module implements google api errors behavior.
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
const REAUTH_HINT = "Re-run `openclaw googlemeet auth login` and store the refreshed oauth block.";
const GOOGLE_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function scopeText(scopes: readonly string[]): string {
return scopes.map((scope) => `\`${scope}\``).join(", ");
}
export async function readGoogleApiErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, GOOGLE_API_ERROR_BODY_LIMIT_BYTES);
}
export async function googleApiError(params: {
response: Response;
prefix: string;
scopes?: readonly string[];
}): Promise<Error> {
const detail = await readGoogleApiErrorDetail(params.response);
const scopeHint =
params.scopes && params.scopes.length > 0
? ` Required OAuth scope: ${scopeText(params.scopes)}. ${REAUTH_HINT}`
: "";
return new Error(`${params.prefix} failed (${params.response.status}): ${detail}${scopeHint}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,523 @@
// Google Meet plugin module implements node host behavior.
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import {
DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND,
DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND,
} from "./config.js";
import {
GOOGLE_MEET_SYSTEM_PROFILER_COMMAND,
outputMentionsBlackHole2ch,
} from "./transports/chrome-audio-device.js";
type NodeBridgeSession = {
id: string;
url?: string;
mode?: string;
outputCommand: { command: string; args: string[] };
input?: ChildProcess;
output?: ChildProcess;
chunks: Buffer[];
waiters: Array<() => void>;
closed: boolean;
createdAt: string;
lastInputAt?: string;
lastOutputAt?: string;
lastClearAt?: string;
lastInputBytes: number;
lastOutputBytes: number;
closedAt?: string;
clearCount: number;
};
const sessions = new Map<string, NodeBridgeSession>();
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const result = value.filter(
(entry): entry is string => typeof entry === "string" && entry.length > 0,
);
return result.length > 0 ? result : undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function runCommandWithTimeout(argv: string[], timeoutMs: number) {
const [command, ...args] = argv;
if (!command) {
throw new Error("command must not be empty");
}
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: timeoutMs,
});
return {
code: typeof result.status === "number" ? result.status : result.error ? 1 : 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? (result.error ? formatErrorMessage(result.error) : ""),
};
}
function assertBlackHoleAvailable(timeoutMs: number) {
if (process.platform !== "darwin") {
throw new Error("Chrome Meet transport with blackhole-2ch audio is currently macOS-only");
}
const result = runCommandWithTimeout(
[GOOGLE_MEET_SYSTEM_PROFILER_COMMAND, "SPAudioDataType"],
timeoutMs,
);
const output = `${result.stdout}\n${result.stderr}`;
if (result.code !== 0 || !outputMentionsBlackHole2ch(output)) {
throw new Error("BlackHole 2ch audio device not found on the node.");
}
}
function splitCommand(argv: string[]): { command: string; args: string[] } {
const [command, ...args] = argv;
if (!command) {
throw new Error("audio command must not be empty");
}
return { command, args };
}
function wake(session: NodeBridgeSession) {
const waiters = session.waiters.splice(0);
for (const waiter of waiters) {
waiter();
}
}
function stopSession(session: NodeBridgeSession) {
const wasClosed = session.closed;
session.closed = true;
session.closedAt ??= new Date().toISOString();
terminateChild(session.input);
terminateChild(session.output);
if (!wasClosed) {
wake(session);
}
}
function attachOutputProcessHandlers(session: NodeBridgeSession, outputProcess: ChildProcess) {
outputProcess.on("exit", () => {
if (session.output === outputProcess) {
stopSession(session);
}
});
outputProcess.on("error", () => {
if (session.output === outputProcess) {
stopSession(session);
}
});
outputProcess.stdin?.on?.("error", () => {
if (session.output === outputProcess) {
stopSession(session);
}
});
}
function startOutputProcess(command: { command: string; args: string[] }) {
return spawn(command.command, command.args, {
stdio: ["pipe", "ignore", "pipe"],
});
}
function startCommandPair(params: {
inputCommand: string[];
outputCommand: string[];
url?: string;
mode?: string;
}): NodeBridgeSession {
const input = splitCommand(params.inputCommand);
const output = splitCommand(params.outputCommand);
const session: NodeBridgeSession = {
id: `meet_node_${randomUUID()}`,
url: params.url,
mode: params.mode,
outputCommand: output,
chunks: [],
waiters: [],
closed: false,
createdAt: new Date().toISOString(),
lastInputBytes: 0,
lastOutputBytes: 0,
clearCount: 0,
};
const outputProcess = startOutputProcess(output);
const inputProcess = spawn(input.command, input.args, {
stdio: ["ignore", "pipe", "pipe"],
});
session.input = inputProcess;
session.output = outputProcess;
inputProcess.stdout?.on("data", (chunk) => {
const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
session.lastInputAt = new Date().toISOString();
session.lastInputBytes += audio.byteLength;
session.chunks.push(audio);
if (session.chunks.length > 200) {
session.chunks.splice(0, session.chunks.length - 200);
}
wake(session);
});
inputProcess.on("exit", () => stopSession(session));
attachOutputProcessHandlers(session, outputProcess);
inputProcess.on("error", () => stopSession(session));
sessions.set(session.id, session);
return session;
}
function terminateChild(child?: ChildProcess) {
if (!child) {
return;
}
let exited = child.exitCode !== null || child.signalCode !== null;
child.once?.("exit", () => {
exited = true;
});
try {
child.kill("SIGTERM");
} catch {
// Best-effort cleanup for node-host child processes.
}
const timer = setTimeout(() => {
if (exited) {
return;
}
try {
child.kill("SIGKILL");
} catch {
// Process may have exited after the grace check.
}
}, 2_000);
timer.unref?.();
}
async function pullAudio(params: Record<string, unknown>) {
const bridgeId = readString(params.bridgeId);
if (!bridgeId) {
throw new Error("bridgeId required");
}
const session = sessions.get(bridgeId);
if (!session) {
throw new Error(`unknown bridgeId: ${bridgeId}`);
}
const timeoutMs = Math.min(readNumber(params.timeoutMs, 250), 2_000);
if (session.chunks.length === 0 && !session.closed) {
await Promise.race([
sleep(timeoutMs),
new Promise<void>((resolve) => {
session.waiters.push(resolve);
}),
]);
}
const chunk = session.chunks.shift();
return {
bridgeId,
closed: session.closed,
base64: chunk ? chunk.toString("base64") : undefined,
};
}
function pushAudio(params: Record<string, unknown>) {
const bridgeId = readString(params.bridgeId);
const base64 = readString(params.base64);
if (!bridgeId || !base64) {
throw new Error("bridgeId and base64 required");
}
const session = sessions.get(bridgeId);
if (!session || session.closed) {
throw new Error(`bridge is not open: ${bridgeId}`);
}
const audio = Buffer.from(base64, "base64");
session.lastOutputAt = new Date().toISOString();
session.lastOutputBytes += audio.byteLength;
try {
session.output?.stdin?.write(audio);
} catch {
stopSession(session);
throw new Error(`bridge is not open: ${bridgeId}`);
}
return { bridgeId, ok: true };
}
function clearAudio(params: Record<string, unknown>) {
const bridgeId = readString(params.bridgeId);
if (!bridgeId) {
throw new Error("bridgeId required");
}
const session = sessions.get(bridgeId);
if (!session || session.closed) {
throw new Error(`bridge is not open: ${bridgeId}`);
}
const previousOutput = session.output;
const outputProcess = startOutputProcess(session.outputCommand);
session.output = outputProcess;
attachOutputProcessHandlers(session, outputProcess);
session.clearCount += 1;
session.lastClearAt = new Date().toISOString();
terminateChild(previousOutput);
return { bridgeId, ok: true, clearCount: session.clearCount };
}
function startChrome(params: Record<string, unknown>) {
const url = readString(params.url);
if (!url) {
throw new Error("url required");
}
const timeoutMs = readNumber(params.joinTimeoutMs, 30_000);
const mode = readString(params.mode);
let bridgeId: string | undefined;
let audioBridge: { type: "external-command" | "node-command-pair" } | undefined;
if (mode === "agent" || mode === "bidi" || mode === "realtime") {
assertBlackHoleAvailable(Math.min(timeoutMs, 10_000));
const healthCommand = readStringArray(params.audioBridgeHealthCommand);
if (healthCommand) {
const health = runCommandWithTimeout(healthCommand, timeoutMs);
if (health.code !== 0) {
throw new Error(
`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`,
);
}
}
const bridgeCommand = readStringArray(params.audioBridgeCommand);
if (bridgeCommand) {
if (mode === "agent") {
throw new Error(
"Chrome agent mode requires audioInputCommand and audioOutputCommand so OpenClaw can run STT and regular TTS directly.",
);
}
const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs);
if (bridge.code !== 0) {
throw new Error(
`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`,
);
}
audioBridge = { type: "external-command" };
} else {
const session = startCommandPair({
inputCommand: readStringArray(params.audioInputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND,
],
outputCommand: readStringArray(params.audioOutputCommand) ?? [
...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND,
],
url,
mode,
});
bridgeId = session.id;
audioBridge = { type: "node-command-pair" };
}
}
if (params.launch !== false) {
const argv = ["open", "-a", "Google Chrome", url];
const browserProfile = readString(params.browserProfile);
if (browserProfile) {
argv.push("--args", `--profile-directory=${browserProfile}`);
}
const result = runCommandWithTimeout(argv, timeoutMs);
if (result.code !== 0) {
if (bridgeId) {
const session = sessions.get(bridgeId);
if (session) {
stopSession(session);
}
}
throw new Error(
`failed to launch Chrome for Meet: ${result.stderr || result.stdout || result.code}`,
);
}
}
return {
launched: params.launch !== false,
bridgeId,
audioBridge,
browser:
params.launch !== false
? {
status: "chrome-opened",
browserUrl: url,
notes: [
"Browser page control is handled by OpenClaw browser automation when using chrome-node.",
],
}
: undefined,
};
}
function bridgeStatus(params: Record<string, unknown>) {
const bridgeId = readString(params.bridgeId);
const session = bridgeId ? sessions.get(bridgeId) : undefined;
return {
bridge: session
? {
bridgeId,
closed: session.closed,
createdAt: session.createdAt,
lastInputAt: session.lastInputAt,
lastOutputAt: session.lastOutputAt,
lastClearAt: session.lastClearAt,
lastInputBytes: session.lastInputBytes,
lastOutputBytes: session.lastOutputBytes,
clearCount: session.clearCount,
queuedInputChunks: session.chunks.length,
}
: bridgeId
? { bridgeId, closed: true }
: undefined,
};
}
function normalizeMeetKey(value?: string): string | undefined {
if (!value) {
return undefined;
}
try {
const url = new URL(value);
if (url.hostname.toLowerCase() !== "meet.google.com") {
return value;
}
const match = /^\/([a-z]{3}-[a-z]{4}-[a-z]{3})(?:$|[/?#])/i.exec(url.pathname);
return match?.[1]?.toLowerCase() ?? value;
} catch {
return value;
}
}
function summarizeSession(session: NodeBridgeSession) {
return {
bridgeId: session.id,
url: session.url,
mode: session.mode,
closed: session.closed,
createdAt: session.createdAt,
closedAt: session.closedAt,
lastInputAt: session.lastInputAt,
lastOutputAt: session.lastOutputAt,
lastInputBytes: session.lastInputBytes,
lastOutputBytes: session.lastOutputBytes,
};
}
function listSessions(params: Record<string, unknown>) {
const urlKey = normalizeMeetKey(readString(params.url));
const mode = readString(params.mode);
const bridges = [...sessions.values()]
.filter((session) => !session.closed)
.filter((session) => !urlKey || normalizeMeetKey(session.url) === urlKey)
.filter((session) => !mode || session.mode === mode)
.map(summarizeSession);
return { bridges };
}
function stopSessionsByUrl(params: Record<string, unknown>) {
const urlKey = normalizeMeetKey(readString(params.url));
if (!urlKey) {
throw new Error("url required");
}
const mode = readString(params.mode);
const exceptBridgeId = readString(params.exceptBridgeId);
let stopped = 0;
for (const [bridgeId, session] of sessions) {
if (exceptBridgeId && bridgeId === exceptBridgeId) {
continue;
}
if (normalizeMeetKey(session.url) !== urlKey) {
continue;
}
if (mode && session.mode !== mode) {
continue;
}
const wasClosed = session.closed;
stopSession(session);
sessions.delete(bridgeId);
if (!wasClosed) {
stopped += 1;
}
}
return { ok: true, stopped };
}
function stopChrome(params: Record<string, unknown>) {
const bridgeId = readString(params.bridgeId);
if (!bridgeId) {
return { ok: true, stopped: false };
}
const session = sessions.get(bridgeId);
if (!session) {
return { ok: true, stopped: false };
}
stopSession(session);
sessions.delete(bridgeId);
return { ok: true, stopped: true };
}
export async function handleGoogleMeetNodeHostCommand(paramsJSON?: string | null): Promise<string> {
let raw: unknown = {};
if (paramsJSON) {
try {
raw = JSON.parse(paramsJSON) as unknown;
} catch {
throw new Error("Google Meet node host received malformed params JSON.");
}
}
const params = asRecord(raw);
const action = readString(params.action);
let result: unknown;
switch (action) {
case "setup":
assertBlackHoleAvailable(10_000);
result = { ok: true };
break;
case "start":
result = startChrome(params);
break;
case "status":
result = bridgeStatus(params);
break;
case "list":
result = listSessions(params);
break;
case "stopByUrl":
result = stopSessionsByUrl(params);
break;
case "pullAudio":
result = await pullAudio(params);
break;
case "pushAudio":
result = pushAudio(params);
break;
case "clearAudio":
result = clearAudio(params);
break;
case "stop":
result = stopChrome(params);
break;
default:
throw new Error("unsupported googlemeet.chrome action");
}
return JSON.stringify(result);
}

View File

@@ -0,0 +1,134 @@
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
createGoogleMeetChromeNodeInvokePolicy,
GOOGLE_MEET_CHROME_NODE_COMMAND,
} from "./node-invoke-policy.js";
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
ok: true,
payload: { ok: true },
}));
const ctx: OpenClawPluginNodeInvokePolicyContext = {
nodeId: "node-1",
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
params,
config: {} as never,
pluginConfig,
invokeNode,
};
return { ctx, invokeNode };
}
describe("Google Meet node invoke policy", () => {
it("rewrites start executable fields from trusted config", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: true,
browserProfile: "Attacker Profile",
joinTimeoutMs: 1,
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioInputCommand: ["malicious-capture"],
audioOutputCommand: ["malicious-play"],
});
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
expect(invokeNode).toHaveBeenCalledTimes(1);
expect(invokeNode).toHaveBeenCalledWith({
params: {
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
launch: false,
browserProfile: "Trusted Profile",
joinTimeoutMs: 45_000,
audioInputCommand: ["trusted-capture", "--raw"],
audioOutputCommand: ["trusted-play", "--raw"],
},
});
});
it("uses trusted configured external bridge commands for start", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(
resolveGoogleMeetConfig({
chrome: {
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
},
}),
);
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://meet.google.com/abc-defg-hij",
mode: "bidi",
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
const call = invokeNode.mock.calls[0]?.[0];
expect(call?.params).toMatchObject({
action: "start",
audioBridgeHealthCommand: ["trusted-bridge", "status"],
audioBridgeCommand: ["trusted-bridge", "start"],
});
});
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "start",
url: "https://example.com/private",
mode: "bidi",
});
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
message: "url must be an explicit https://meet.google.com/... URL",
});
expect(invokeNode).not.toHaveBeenCalled();
});
it("keeps direct setup diagnostics but strips extra fields", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({
action: "setup",
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
});
await policy.handle(ctx);
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
});
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
await expect(policy.handle(ctx)).resolves.toMatchObject({
ok: false,
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
});
expect(invokeNode).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,192 @@
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
} from "openclaw/plugin-sdk/plugin-entry";
import type { GoogleMeetConfig } from "./config.js";
import { normalizeMeetUrl } from "./runtime.js";
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
type PolicyDecision =
| { approved: true; params: Record<string, unknown> }
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function copyCommand(command: string[] | undefined): string[] | undefined {
return command && command.length > 0 ? [...command] : undefined;
}
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
return { ok: false as const, code, message };
}
function approved(params: Record<string, unknown>): PolicyDecision {
return { approved: true, params };
}
function buildStartParams(
params: Record<string, unknown>,
config: GoogleMeetConfig,
): PolicyDecision {
let url: string;
try {
url = normalizeMeetUrl(params.url);
} catch (error) {
return {
approved: false,
result: denied(
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
),
};
}
const mode = readString(params.mode);
if (mode && !START_MODES.has(mode)) {
return {
approved: false,
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
};
}
const startParams: Record<string, unknown> = {
action: "start",
url,
launch: params.launch === false ? false : config.chrome.launch,
browserProfile: config.chrome.browserProfile,
joinTimeoutMs: config.chrome.joinTimeoutMs,
};
if (mode) {
startParams.mode = mode;
}
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
if (audioInputCommand) {
startParams.audioInputCommand = audioInputCommand;
}
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
if (audioOutputCommand) {
startParams.audioOutputCommand = audioOutputCommand;
}
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
if (audioBridgeCommand) {
startParams.audioBridgeCommand = audioBridgeCommand;
}
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
if (audioBridgeHealthCommand) {
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
}
return approved(startParams);
}
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
const action = readString(params.action);
switch (action) {
case "setup":
return { action };
case "status": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
case "list": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
return forwarded;
}
case "stopByUrl": {
const forwarded: Record<string, unknown> = { action };
const url = readString(params.url);
const mode = readString(params.mode);
const exceptBridgeId = readString(params.exceptBridgeId);
if (url) {
forwarded.url = url;
}
if (mode) {
forwarded.mode = mode;
}
if (exceptBridgeId) {
forwarded.exceptBridgeId = exceptBridgeId;
}
return forwarded;
}
case "pullAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const timeoutMs = readPositiveNumber(params.timeoutMs);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (timeoutMs) {
forwarded.timeoutMs = timeoutMs;
}
return forwarded;
}
case "pushAudio": {
const forwarded: Record<string, unknown> = { action };
const bridgeId = readString(params.bridgeId);
const base64 = readString(params.base64);
if (bridgeId) {
forwarded.bridgeId = bridgeId;
}
if (base64) {
forwarded.base64 = base64;
}
return forwarded;
}
case "clearAudio":
case "stop": {
const bridgeId = readString(params.bridgeId);
return bridgeId ? { action, bridgeId } : { action };
}
default:
return null;
}
}
export function createGoogleMeetChromeNodeInvokePolicy(
config: GoogleMeetConfig,
): OpenClawPluginNodeInvokePolicy {
return {
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
dangerous: true,
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
return denied(`unsupported Google Meet node command: ${ctx.command}`);
}
const params = asRecord(ctx.params);
const action = readString(params.action);
let decision: PolicyDecision;
if (action === "start") {
decision = buildStartParams(params, config);
} else {
const forwardParams = buildForwardParams(params);
decision = forwardParams
? approved(forwardParams)
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
}
if (!decision.approved) {
return decision.result;
}
return await ctx.invokeNode({ params: decision.params });
},
};
}

View File

@@ -0,0 +1,250 @@
// Google Meet tests cover oauth plugin behavior.
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildGoogleMeetAuthUrl,
refreshGoogleMeetAccessToken,
resolveGoogleMeetAccessToken,
waitForGoogleMeetAuthCode,
} from "./oauth.js";
async function occupyPort(port: number): Promise<Server | null> {
const server = createServer();
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "localhost", () => {
resolve();
});
});
} catch (error) {
if (error instanceof Error && error.message.includes("EADDRINUSE")) {
return null;
}
throw error;
}
return server;
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => {
resolve();
});
});
}
describe("Google Meet OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("builds auth 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.space.created");
expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly");
expect(url.searchParams.get("scope")).toContain("calendar.events.readonly");
expect(url.searchParams.get("scope")).toContain("drive.meet.readonly");
const cachedExpiresAt = Date.now() + 120_000;
await expect(
resolveGoogleMeetAccessToken({
accessToken: "cached-token",
expiresAt: cachedExpiresAt,
}),
).resolves.toEqual({
accessToken: "cached-token",
expiresAt: cachedExpiresAt,
refreshed: false,
});
});
it("refreshes 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);
const tokens = await refreshGoogleMeetAccessToken({
clientId: "client-id",
clientSecret: "client-secret",
refreshToken: "refresh-token",
});
expect(tokens.accessToken).toBe("new-access-token");
expect(tokens.refreshToken).toBeUndefined();
expect(tokens.scope).toBeUndefined();
expect(tokens.tokenType).toBe("Bearer");
expect(Number.isFinite(tokens.expiresAt)).toBe(true);
expect(tokens.expiresAt).toBeGreaterThan(Date.now());
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("rejects oversized OAuth token responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(new Uint8Array(300 * 1024), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(
refreshGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
}),
).rejects.toThrow("Google OAuth token: JSON response exceeds 262144 bytes");
});
it("refreshes cached access tokens with Date-invalid expiries", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "refreshed-token",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const tokens = await resolveGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
accessToken: "cached-token",
expiresAt: 8_700_000_000_000_000,
});
expect(tokens.accessToken).toBe("refreshed-token");
expect(tokens.refreshed).toBe(true);
});
it("falls back when refreshed token lifetimes overflow safe milliseconds", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: Number.MAX_SAFE_INTEGER,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const tokens = await refreshGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
});
expect(tokens.expiresAt).toBe(Date.now() + 3600 * 1000);
});
it("bounds fallback token lifetimes when the process clock is invalid", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: Number.MAX_SAFE_INTEGER,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const tokens = await refreshGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
});
expect(tokens.expiresAt).toBe(3600 * 1000);
});
it("keeps explicit zero-second token lifetimes immediately stale", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
expires_in: 0,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
vi.stubGlobal("fetch", fetchMock);
const tokens = await refreshGoogleMeetAccessToken({
clientId: "client-id",
refreshToken: "refresh-token",
});
expect(tokens.expiresAt).toBe(Date.now());
});
it("falls back to manual paste when the local callback port is occupied", async () => {
const blocker = await occupyPort(8085);
try {
const state = "state-token";
const lines: string[] = [];
const code = await waitForGoogleMeetAuthCode({
state,
manual: false,
timeoutMs: 60_000,
authUrl: "https://accounts.google.com/o/oauth2/v2/auth?x=1",
promptInput: async () =>
`http://localhost:8085/oauth2callback?code=auth-code-123&state=${state}`,
writeLine: (message) => lines.push(message),
});
expect(code).toBe("auth-code-123");
expect(lines.some((line) => line.includes("Switching to manual mode"))).toBe(true);
} finally {
if (blocker) {
await closeServer(blocker);
}
}
});
it("propagates non-listener callback failures without manual fallback", async () => {
const promptInput = vi.fn(async () => "unused");
await expect(
waitForGoogleMeetAuthCode({
state: "state-token",
manual: false,
timeoutMs: 1,
authUrl: "https://accounts.google.com/o/oauth2/v2/auth?x=1",
promptInput,
writeLine: () => {},
}),
).rejects.toThrow(/timeout/i);
expect(promptInput).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,282 @@
// Google Meet plugin module implements oauth behavior.
import {
MAX_DATE_TIMESTAMP_MS,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
import {
generateOAuthState,
parseOAuthCallbackInput,
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { readGoogleApiErrorDetail } from "./google-api-errors.js";
const GOOGLE_MEET_REDIRECT_URI = "http://localhost:8085/oauth2callback";
const GOOGLE_MEET_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_MEET_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_MEET_TOKEN_HOST = "oauth2.googleapis.com";
const GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
const GOOGLE_OAUTH_TOKEN_JSON_MAX_BYTES = 256 * 1024;
const GOOGLE_MEET_SCOPES = [
"https://www.googleapis.com/auth/meetings.space.created",
"https://www.googleapis.com/auth/meetings.space.readonly",
"https://www.googleapis.com/auth/meetings.space.settings",
"https://www.googleapis.com/auth/meetings.conference.media.readonly",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/drive.meet.readonly",
] as const;
function resolveGoogleMeetTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
const now = resolveDateTimestampMs(nowMs);
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
return now;
}
return (
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
resolveExpiresAtMsFromDurationSeconds(GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS, {
nowMs: now,
}) ??
now
);
}
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, release } = await fetchWithSsrFGuard({
url: GOOGLE_MEET_TOKEN_URL,
init: {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Accept: "application/json",
},
body,
},
policy: { allowedHostnames: [GOOGLE_MEET_TOKEN_HOST] },
auditContext: "google-meet.oauth.token",
});
try {
if (!response.ok) {
const detail = await readGoogleApiErrorDetail(response);
throw new Error(`Google OAuth token request failed (${response.status}): ${detail}`);
}
const payload = await readProviderJsonResponse<{
access_token?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
token_type?: string;
}>(response, "Google OAuth token", { maxBytes: GOOGLE_OAUTH_TOKEN_JSON_MAX_BYTES });
const accessToken = payload.access_token?.trim();
if (!accessToken) {
throw new Error("Google OAuth token response was missing access_token");
}
return {
accessToken,
expiresAt: resolveGoogleMeetTokenExpiresAt(payload.expires_in),
refreshToken: payload.refresh_token?.trim() || undefined,
scope: payload.scope?.trim() || undefined,
tokenType: payload.token_type?.trim() || undefined,
};
} finally {
await release();
}
}
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,
}),
);
}
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 <= MAX_DATE_TIMESTAMP_MS &&
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();
}
function isLocalCallbackListenerError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return (
error.message.includes("EADDRINUSE") ||
error.message.includes("port") ||
error.message.includes("listen")
);
}
async function readManualGoogleMeetAuthCode(params: {
state: string;
promptInput: (message: string) => Promise<string>;
}): Promise<string> {
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;
}
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) {
return await readManualGoogleMeetAuthCode({
state: params.state,
promptInput: params.promptInput,
});
}
try {
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;
} catch (error) {
if (!isLocalCallbackListenerError(error)) {
throw error;
}
params.writeLine("Local callback server failed. Switching to manual mode...");
return await readManualGoogleMeetAuthCode({
state: params.state,
promptInput: params.promptInput,
});
}
}

View File

@@ -0,0 +1,771 @@
// Google Meet plugin module implements realtime node behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import type {
RealtimeTranscriptionProviderPlugin,
RealtimeTranscriptionSession,
} from "openclaw/plugin-sdk/realtime-transcription";
import {
createRealtimeVoiceAgentTalkbackQueue,
createTalkSessionController,
createRealtimeVoiceBridgeSession,
createRealtimeVoiceOutputActivityTracker,
recordTalkObservabilityEvent,
type RealtimeVoiceAgentTalkbackQueue,
type RealtimeVoiceBridgeSession,
type RealtimeVoiceProviderPlugin,
type TalkEvent,
type TalkEventInput,
type TalkSessionController,
} from "openclaw/plugin-sdk/realtime-voice";
import {
consultOpenClawAgentForGoogleMeet,
handleGoogleMeetRealtimeConsultToolCall,
resolveGoogleMeetRealtimeTools,
} from "./agent-consult.js";
import type { GoogleMeetConfig } from "./config.js";
import {
getGoogleMeetRealtimeTranscriptHealth,
buildGoogleMeetSpeakExactUserMessage,
GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS,
recordGoogleMeetOutputActivity,
getGoogleMeetRealtimeEventHealth,
recordGoogleMeetRealtimeTranscript,
recordGoogleMeetRealtimeEvent,
resolveGoogleMeetRealtimeAudioFormat,
resolveGoogleMeetRealtimeProvider,
resolveGoogleMeetRealtimeTranscriptionProvider,
isGoogleMeetLikelyAssistantEchoTranscript,
pushGoogleMeetTalkEvent,
summarizeGoogleMeetTalkEvents,
convertGoogleMeetBridgeAudioForStt,
convertGoogleMeetTtsAudioForBridge,
formatGoogleMeetAgentAudioModelLog,
formatGoogleMeetAgentTtsResultLog,
formatGoogleMeetTranscriptSummaryLog,
formatGoogleMeetRealtimeVoiceModelLog,
type GoogleMeetRealtimeEventEntry,
type GoogleMeetRealtimeTranscriptEntry,
} from "./realtime.js";
import type { GoogleMeetChromeHealth } from "./transports/types.js";
export type ChromeNodeRealtimeAudioBridgeHandle = {
type: "node-command-pair";
providerId: string;
nodeId: string;
bridgeId: string;
speak: (instructions?: string) => void;
getHealth: () => GoogleMeetChromeHealth;
stop: () => Promise<void>;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeGoogleMeetTtsPromptText(text: string | undefined): string | undefined {
const trimmed = text?.trim();
if (!trimmed) {
return undefined;
}
const sayExactly = trimmed.match(/^say exactly:\s*(?<text>.+)$/is)?.groups?.text?.trim();
if (sayExactly) {
return sayExactly.replace(/^["']|["']$/g, "").trim() || trimmed;
}
return trimmed;
}
function startGoogleMeetNodeAudioInputLoop(params: {
runtime: PluginRuntime;
nodeId: string;
bridgeId: string;
logger: RuntimeLogger;
logPrefix: string;
isStopped: () => boolean;
stop: () => Promise<void>;
isInputSuppressed: () => boolean;
onAudio: (audio: Buffer) => void;
}) {
let lastInputAt: string | undefined;
let lastInputBytes = 0;
let suppressedInputBytes = 0;
let lastSuppressedInputAt: string | undefined;
let consecutiveInputErrors = 0;
let lastInputError: string | undefined;
void (async () => {
for (;;) {
if (params.isStopped()) {
break;
}
try {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: { action: "pullAudio", bridgeId: params.bridgeId, timeoutMs: 250 },
timeoutMs: 2_000,
});
const result = asRecord(asRecord(raw).payload ?? raw);
consecutiveInputErrors = 0;
lastInputError = undefined;
const base64 = readString(result.base64);
if (base64) {
const audio = Buffer.from(base64, "base64");
if (params.isInputSuppressed()) {
lastSuppressedInputAt = new Date().toISOString();
suppressedInputBytes += audio.byteLength;
continue;
}
lastInputAt = new Date().toISOString();
lastInputBytes += audio.byteLength;
params.onAudio(audio);
}
if (result.closed === true) {
await params.stop();
}
} catch (error) {
if (!params.isStopped()) {
const message = formatErrorMessage(error);
consecutiveInputErrors += 1;
lastInputError = message;
params.logger.warn(
`[google-meet] ${params.logPrefix} audio input failed (${consecutiveInputErrors}/5): ${message}`,
);
if (consecutiveInputErrors >= 5 || /unknown bridgeId|bridge is not open/i.test(message)) {
await params.stop();
} else {
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
}
}
}
})();
return {
getHealth: () => ({
audioInputActive: lastInputBytes > 0,
lastInputAt,
lastSuppressedInputAt,
lastInputBytes,
suppressedInputBytes,
consecutiveInputErrors,
lastInputError,
}),
};
}
export async function startNodeAgentAudioBridge(params: {
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
runtime: PluginRuntime;
meetingSessionId: string;
requesterSessionKey?: string;
nodeId: string;
bridgeId: string;
logger: RuntimeLogger;
providers?: RealtimeTranscriptionProviderPlugin[];
}): Promise<ChromeNodeRealtimeAudioBridgeHandle> {
let stopped = false;
let sttSession: RealtimeTranscriptionSession | null = null;
let realtimeReady = false;
let lastOutputAt: string | undefined;
const outputActivity = createRealtimeVoiceOutputActivityTracker();
let suppressInputUntil = 0;
let lastOutputPlayableUntilMs = 0;
const resolved = resolveGoogleMeetRealtimeTranscriptionProvider({
config: params.config,
fullConfig: params.fullConfig,
providers: params.providers,
});
params.logger.info(
formatGoogleMeetAgentAudioModelLog({
provider: resolved.provider,
providerConfig: resolved.providerConfig,
audioFormat: params.config.chrome.audioFormat,
}),
);
const transcript: GoogleMeetRealtimeTranscriptEntry[] = [];
let ttsQueue = Promise.resolve();
const stop = async () => {
if (stopped) {
return;
}
stopped = true;
agentTalkback?.close();
try {
sttSession?.close();
} catch (error) {
params.logger.debug?.(
`[google-meet] node agent transcription bridge close ignored: ${formatErrorMessage(error)}`,
);
}
try {
await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: { action: "stop", bridgeId: params.bridgeId },
timeoutMs: 5_000,
});
} catch (error) {
params.logger.debug?.(
`[google-meet] node audio bridge stop ignored: ${formatErrorMessage(error)}`,
);
}
};
const pushOutputAudio = async (audio: Buffer) => {
const suppression = recordGoogleMeetOutputActivity({
tracker: outputActivity,
audio,
audioFormat: params.config.chrome.audioFormat,
nowMs: Date.now(),
lastOutputPlayableUntilMs,
suppressInputUntilMs: suppressInputUntil,
});
suppressInputUntil = suppression.suppressInputUntilMs;
lastOutputPlayableUntilMs = suppression.lastOutputPlayableUntilMs;
lastOutputAt = new Date().toISOString();
await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: {
action: "pushAudio",
bridgeId: params.bridgeId,
base64: Buffer.from(audio).toString("base64"),
},
timeoutMs: 5_000,
});
};
const enqueueSpeakText = (text: string | undefined) => {
const normalized = normalizeGoogleMeetTtsPromptText(text);
if (!normalized || stopped) {
return;
}
ttsQueue = ttsQueue
.then(async () => {
if (stopped) {
return;
}
recordGoogleMeetRealtimeTranscript(transcript, "assistant", normalized);
params.logger.info(
formatGoogleMeetTranscriptSummaryLog("node agent assistant", normalized),
);
const result = await params.runtime.tts.textToSpeechTelephony({
text: normalized,
cfg: params.fullConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
params.logger.info(formatGoogleMeetAgentTtsResultLog("node agent", result));
await pushOutputAudio(
convertGoogleMeetTtsAudioForBridge(
result.audioBuffer,
result.sampleRate,
params.config,
result.outputFormat,
),
);
})
.catch((error: unknown) => {
params.logger.warn(`[google-meet] node agent TTS failed: ${formatErrorMessage(error)}`);
});
};
const agentTalkback: RealtimeVoiceAgentTalkbackQueue | undefined =
createRealtimeVoiceAgentTalkbackQueue({
debounceMs: GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS,
isStopped: () => stopped,
logger: params.logger,
logPrefix: "[google-meet] node agent",
responseStyle: "Brief, natural spoken answer for a live meeting.",
fallbackText: "I hit an error while checking that. Please try again.",
consult: ({ question, responseStyle }) =>
consultOpenClawAgentForGoogleMeet({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: { question, responseStyle },
transcript,
}),
deliver: enqueueSpeakText,
});
sttSession = resolved.provider.createSession({
cfg: params.fullConfig,
providerConfig: resolved.providerConfig,
onTranscript: (text) => {
const trimmed = text.trim();
if (!trimmed || stopped) {
return;
}
recordGoogleMeetRealtimeTranscript(transcript, "user", trimmed);
params.logger.info(formatGoogleMeetTranscriptSummaryLog("node agent user", trimmed));
if (isGoogleMeetLikelyAssistantEchoTranscript({ transcript, text: trimmed })) {
params.logger.info(
formatGoogleMeetTranscriptSummaryLog(
"node agent ignored assistant echo transcript",
trimmed,
),
);
return;
}
agentTalkback?.enqueue(trimmed);
},
onError: (error) => {
params.logger.warn(
`[google-meet] node agent transcription bridge failed: ${formatErrorMessage(error)}`,
);
void stop();
},
});
await sttSession.connect();
realtimeReady = true;
const audioInputLoop = startGoogleMeetNodeAudioInputLoop({
runtime: params.runtime,
nodeId: params.nodeId,
bridgeId: params.bridgeId,
logger: params.logger,
logPrefix: "node agent",
isStopped: () => stopped,
stop,
isInputSuppressed: () => Date.now() < suppressInputUntil,
onAudio: (audio) => {
sttSession?.sendAudio(convertGoogleMeetBridgeAudioForStt(audio, params.config));
},
});
return {
type: "node-command-pair",
providerId: resolved.provider.id,
nodeId: params.nodeId,
bridgeId: params.bridgeId,
speak: enqueueSpeakText,
getHealth: () => ({
providerConnected: sttSession?.isConnected() ?? false,
realtimeReady,
...audioInputLoop.getHealth(),
audioOutputActive: outputActivity.isActive(),
lastOutputAt,
lastOutputBytes: outputActivity.snapshot().sinkAudioBytes,
...getGoogleMeetRealtimeTranscriptHealth(transcript),
bridgeClosed: stopped,
}),
stop,
};
}
export async function startNodeRealtimeAudioBridge(params: {
config: GoogleMeetConfig;
fullConfig: OpenClawConfig;
runtime: PluginRuntime;
meetingSessionId: string;
requesterSessionKey?: string;
nodeId: string;
bridgeId: string;
logger: RuntimeLogger;
providers?: RealtimeVoiceProviderPlugin[];
}): Promise<ChromeNodeRealtimeAudioBridgeHandle> {
let stopped = false;
let bridge: RealtimeVoiceBridgeSession | null = null;
let realtimeReady = false;
let lastOutputAt: string | undefined;
let lastClearAt: string | undefined;
const outputActivity = createRealtimeVoiceOutputActivityTracker();
let suppressInputUntil = 0;
let lastOutputPlayableUntilMs = 0;
let clearCount = 0;
const resolved = resolveGoogleMeetRealtimeProvider({
config: params.config,
fullConfig: params.fullConfig,
providers: params.providers,
});
const transcript: GoogleMeetRealtimeTranscriptEntry[] = [];
const realtimeEvents: GoogleMeetRealtimeEventEntry[] = [];
const strategy = params.config.realtime.strategy;
const talk: TalkSessionController = createTalkSessionController(
{
sessionId: `google-meet:${params.meetingSessionId}:${params.bridgeId}:node-realtime`,
mode: "realtime",
transport: "gateway-relay",
brain: strategy === "bidi" ? "direct-tools" : "agent-consult",
provider: resolved.provider.id,
},
{ onEvent: recordTalkObservabilityEvent },
);
const recentTalkEvents: TalkEvent[] = [];
const rememberTalkEvent = (event: TalkEvent | undefined): void => {
if (event) {
pushGoogleMeetTalkEvent(recentTalkEvents, event);
}
};
const emitTalkEvent = (input: TalkEventInput): void => {
rememberTalkEvent(talk.emit(input));
};
const ensureTalkTurn = (): string => {
const turn = talk.ensureTurn({
payload: { bridgeId: params.bridgeId, meetingSessionId: params.meetingSessionId },
});
if (turn.event) {
rememberTalkEvent(turn.event);
}
return turn.turnId;
};
const finishOutputAudio = (reason: string): void => {
rememberTalkEvent(
talk.finishOutputAudio({
payload: { bridgeId: params.bridgeId, reason },
}),
);
};
const endTalkTurn = (reason = "completed"): void => {
const ended = talk.endTurn({
payload: { bridgeId: params.bridgeId, reason },
});
if (ended.ok) {
rememberTalkEvent(ended.event);
}
};
emitTalkEvent({
type: "session.started",
payload: {
bridgeId: params.bridgeId,
meetingSessionId: params.meetingSessionId,
nodeId: params.nodeId,
},
});
params.logger.info(
formatGoogleMeetRealtimeVoiceModelLog({
strategy,
provider: resolved.provider,
providerConfig: resolved.providerConfig,
fallbackModel: params.config.realtime.model,
audioFormat: params.config.chrome.audioFormat,
}),
);
const agentTalkback: RealtimeVoiceAgentTalkbackQueue | undefined =
createRealtimeVoiceAgentTalkbackQueue({
debounceMs: GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS,
isStopped: () => stopped,
logger: params.logger,
logPrefix: "[google-meet] node realtime agent",
responseStyle: "Brief, natural spoken answer for a live meeting.",
fallbackText: "I hit an error while checking that. Please try again.",
consult: ({ question, responseStyle }) =>
consultOpenClawAgentForGoogleMeet({
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
args: { question, responseStyle },
transcript,
}),
deliver: (text) => {
bridge?.sendUserMessage(buildGoogleMeetSpeakExactUserMessage(text));
},
});
const stop = async () => {
if (stopped) {
return;
}
stopped = true;
agentTalkback?.close();
try {
bridge?.close();
} catch (error) {
params.logger.debug?.(
`[google-meet] node realtime bridge close ignored: ${formatErrorMessage(error)}`,
);
}
try {
await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: { action: "stop", bridgeId: params.bridgeId },
timeoutMs: 5_000,
});
} catch (error) {
params.logger.debug?.(
`[google-meet] node audio bridge stop ignored: ${formatErrorMessage(error)}`,
);
}
};
bridge = createRealtimeVoiceBridgeSession({
provider: resolved.provider,
cfg: params.fullConfig,
providerConfig: resolved.providerConfig,
audioFormat: resolveGoogleMeetRealtimeAudioFormat(params.config),
instructions: params.config.realtime.instructions,
initialGreetingInstructions: params.config.realtime.introMessage,
autoRespondToAudio: strategy === "bidi",
triggerGreetingOnReady: false,
markStrategy: "ack-immediately",
tools:
strategy === "bidi" ? resolveGoogleMeetRealtimeTools(params.config.realtime.toolPolicy) : [],
audioSink: {
isOpen: () => !stopped,
sendAudio: (audio) => {
const turnId = ensureTalkTurn();
rememberTalkEvent(
talk.startOutputAudio({
turnId,
payload: { bridgeId: params.bridgeId },
}).event,
);
emitTalkEvent({
type: "output.audio.delta",
turnId,
payload: { byteLength: audio.byteLength },
});
const suppression = recordGoogleMeetOutputActivity({
tracker: outputActivity,
audio,
audioFormat: params.config.chrome.audioFormat,
nowMs: Date.now(),
lastOutputPlayableUntilMs,
suppressInputUntilMs: suppressInputUntil,
});
suppressInputUntil = suppression.suppressInputUntilMs;
lastOutputPlayableUntilMs = suppression.lastOutputPlayableUntilMs;
lastOutputAt = new Date().toISOString();
void params.runtime.nodes
.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: {
action: "pushAudio",
bridgeId: params.bridgeId,
base64: Buffer.from(audio).toString("base64"),
},
timeoutMs: 5_000,
})
.catch((error: unknown) => {
params.logger.warn(
`[google-meet] node audio output failed: ${formatErrorMessage(error)}`,
);
void stop();
});
},
clearAudio: () => {
lastClearAt = new Date().toISOString();
clearCount += 1;
finishOutputAudio("clear");
suppressInputUntil = 0;
lastOutputPlayableUntilMs = 0;
void params.runtime.nodes
.invoke({
nodeId: params.nodeId,
command: "googlemeet.chrome",
params: {
action: "clearAudio",
bridgeId: params.bridgeId,
},
timeoutMs: 5_000,
})
.catch((error: unknown) => {
params.logger.warn(
`[google-meet] node audio clear failed: ${formatErrorMessage(error)}`,
);
void stop();
});
},
},
onTranscript: (role, text, isFinal) => {
const turnId = ensureTalkTurn();
const eventType =
role === "assistant"
? isFinal
? "output.text.done"
: "output.text.delta"
: isFinal
? "transcript.done"
: "transcript.delta";
const payload = role === "assistant" ? { text } : { role, text };
emitTalkEvent({
type: eventType,
turnId,
payload,
final: isFinal,
});
if (role === "user" && isFinal) {
emitTalkEvent({
type: "input.audio.committed",
turnId,
payload: { bridgeId: params.bridgeId },
final: true,
});
}
if (isFinal) {
recordGoogleMeetRealtimeTranscript(transcript, role, text);
params.logger.info(formatGoogleMeetTranscriptSummaryLog(`node realtime ${role}`, text));
if (role === "user" && strategy === "agent") {
if (isGoogleMeetLikelyAssistantEchoTranscript({ transcript, text })) {
params.logger.info(
formatGoogleMeetTranscriptSummaryLog(
"node realtime ignored assistant echo transcript",
text,
),
);
return;
}
agentTalkback?.enqueue(text);
}
}
},
onEvent: (event) => {
recordGoogleMeetRealtimeEvent(realtimeEvents, event);
if (event.type === "input_audio_buffer.speech_started") {
ensureTalkTurn();
} else if (event.type === "input_audio_buffer.speech_stopped") {
const turnId = talk.activeTurnId;
if (!turnId) {
return;
}
emitTalkEvent({
type: "input.audio.committed",
turnId,
payload: { bridgeId: params.bridgeId, source: event.type },
final: true,
});
} else if (event.type === "response.done") {
finishOutputAudio("response.done");
endTalkTurn("response.done");
} else if (event.type === "error") {
emitTalkEvent({
type: "session.error",
payload: { message: event.detail ?? "Realtime provider error" },
final: true,
});
}
if (
event.type === "error" ||
event.type === "response.done" ||
event.type === "input_audio_buffer.speech_started" ||
event.type === "input_audio_buffer.speech_stopped" ||
event.type === "conversation.item.input_audio_transcription.completed" ||
event.type === "conversation.item.input_audio_transcription.failed"
) {
const detail = event.detail ? ` ${event.detail}` : "";
params.logger.info(`[google-meet] node realtime ${event.direction}:${event.type}${detail}`);
}
},
onToolCall: (event, session) => {
emitTalkEvent({
type: "tool.call",
turnId: ensureTalkTurn(),
itemId: event.itemId,
callId: event.callId,
payload: { name: event.name, args: event.args },
});
const turnId = ensureTalkTurn();
handleGoogleMeetRealtimeConsultToolCall({
strategy,
session,
event,
config: params.config,
fullConfig: params.fullConfig,
runtime: params.runtime,
logger: params.logger,
meetingSessionId: params.meetingSessionId,
requesterSessionKey: params.requesterSessionKey,
transcript,
onTalkEvent: (input) => emitTalkEvent({ ...input, turnId: input.turnId ?? turnId }),
});
},
onError: (error) => {
params.logger.warn(
`[google-meet] node realtime voice bridge failed: ${formatErrorMessage(error)}`,
);
emitTalkEvent({
type: "session.error",
payload: { message: formatErrorMessage(error) },
final: true,
});
void stop();
},
onClose: (reason) => {
realtimeReady = false;
finishOutputAudio(reason);
emitTalkEvent({
type: "session.closed",
payload: { reason },
final: true,
});
if (reason === "error") {
void stop();
}
},
onReady: () => {
realtimeReady = true;
emitTalkEvent({
type: "session.ready",
payload: { bridgeId: params.bridgeId },
});
},
});
await bridge.connect();
const audioInputLoop = startGoogleMeetNodeAudioInputLoop({
runtime: params.runtime,
nodeId: params.nodeId,
bridgeId: params.bridgeId,
logger: params.logger,
logPrefix: "node",
isStopped: () => stopped,
stop,
isInputSuppressed: () => Date.now() < suppressInputUntil,
onAudio: (audio) => {
emitTalkEvent({
type: "input.audio.delta",
turnId: ensureTalkTurn(),
payload: { byteLength: audio.byteLength },
});
bridge?.sendAudio(audio);
},
});
return {
type: "node-command-pair",
providerId: resolved.provider.id,
nodeId: params.nodeId,
bridgeId: params.bridgeId,
speak: (instructions) => {
bridge?.triggerGreeting(instructions);
},
getHealth: () => ({
providerConnected: bridge?.bridge.isConnected() ?? false,
realtimeReady,
...audioInputLoop.getHealth(),
audioOutputActive: outputActivity.isActive(),
lastOutputAt,
lastClearAt,
lastOutputBytes: outputActivity.snapshot().sinkAudioBytes,
...getGoogleMeetRealtimeTranscriptHealth(transcript),
...getGoogleMeetRealtimeEventHealth(realtimeEvents),
recentTalkEvents: summarizeGoogleMeetTalkEvents(recentTalkEvents),
clearCount,
bridgeClosed: stopped,
}),
stop,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
"openclaw/plugin-sdk/ssrf-runtime",
);
return {
...actual,
fetchWithSsrFGuard: async (params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
}) => ({
response: await fetch(params.url, { ...params.init, signal: params.signal }),
finalUrl: params.url,
release: async () => {},
}),
};
});
const SEVENTEEN_MIB = 17 * 1024 * 1024;
describe("google-meet response body boundary", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("rejects an oversized spaces.get success response", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(new Uint8Array(SEVENTEEN_MIB), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const { fetchGoogleMeetSpace } = await import("./meet.js");
await expect(
fetchGoogleMeetSpace({
accessToken: "fake-token",
meeting: "abc-defg-hij",
}),
).rejects.toThrow("Google Meet spaces.get: JSON response exceeds");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
// Google Meet setup module handles plugin onboarding behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
import { asRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
type SetupCheck = {
id: string;
ok: boolean;
message: string;
};
type GoogleMeetSetupStatus = {
ok: boolean;
checks: SetupCheck[];
};
function resolveUserPath(input: string): string {
if (input === "~") {
return os.homedir();
}
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
try {
const parsed = new URL(webhookUrl);
return isBlockedHostnameOrIp(parsed.hostname);
} catch {
return false;
}
}
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
const tunnel = asRecord(voiceCallConfig.tunnel);
const tailscale = asRecord(voiceCallConfig.tailscale);
const tunnelProvider = normalizeOptionalString(tunnel.provider);
const tailscaleMode = normalizeOptionalString(tailscale.mode);
if (publicUrl) {
const ok = !isProviderUnreachableWebhookUrl(publicUrl);
return {
id: "twilio-voice-call-webhook",
ok,
message: ok
? `Voice-call public webhook URL configured: ${publicUrl}`
: `Voice-call publicUrl is local/private and cannot be reached by Twilio: ${publicUrl}`,
};
}
if (tunnelProvider && tunnelProvider !== "none") {
return {
id: "twilio-voice-call-webhook",
ok: true,
message: "Voice-call webhook exposure configured through tunnel",
};
}
if (tailscaleMode && tailscaleMode !== "off") {
return {
id: "twilio-voice-call-webhook",
ok: true,
message: "Voice-call webhook exposure configured through Tailscale",
};
}
return {
id: "twilio-voice-call-webhook",
ok: false,
message:
"Set plugins.entries.voice-call.config.publicUrl or configure voice-call tunnel/tailscale exposure for Twilio dialing",
};
}
export function getGoogleMeetSetupStatus(config: GoogleMeetConfig): {
ok: boolean;
checks: SetupCheck[];
};
export function getGoogleMeetSetupStatus(
config: GoogleMeetConfig,
options?: {
env?: NodeJS.ProcessEnv;
fullConfig?: unknown;
mode?: GoogleMeetMode;
transport?: GoogleMeetTransport;
twilioDialInNumber?: string;
},
): {
ok: boolean;
checks: SetupCheck[];
};
export function getGoogleMeetSetupStatus(
config: GoogleMeetConfig,
options?: {
env?: NodeJS.ProcessEnv;
fullConfig?: unknown;
mode?: GoogleMeetMode;
transport?: GoogleMeetTransport;
twilioDialInNumber?: string;
},
) {
const checks: SetupCheck[] = [];
const env = options?.env ?? process.env;
const fullConfig = asRecord(options?.fullConfig);
const mode = options?.mode ?? config.defaultMode;
const transport = options?.transport ?? config.defaultTransport;
const needsChromeRealtimeAudio =
(mode === "agent" || mode === "bidi") &&
(transport === "chrome" || transport === "chrome-node");
const pluginEntries = asRecord(asRecord(fullConfig.plugins).entries);
const pluginAllow = asRecord(fullConfig.plugins).allow;
const voiceCallEntry = asRecord(pluginEntries["voice-call"]);
const voiceCallConfig = asRecord(voiceCallEntry.config);
const voiceCallTwilioConfig = asRecord(voiceCallConfig.twilio);
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",
});
}
checks.push({
id: "chrome-profile",
ok: true,
message: config.chrome.browserProfile
? "Local Chrome uses the OpenClaw browser profile; chrome.browserProfile is passed to chrome-node hosts"
: "Local Chrome uses the OpenClaw browser profile; configure browser.defaultProfile to choose another profile",
});
if (needsChromeRealtimeAudio) {
const hasCommandPair = Boolean(
config.chrome.audioInputCommand && config.chrome.audioOutputCommand,
);
const hasExternalBridge = Boolean(config.chrome.audioBridgeCommand);
const agentModeExternalBridgeInvalid = mode === "agent" && hasExternalBridge;
checks.push({
id: "audio-bridge",
ok:
mode === "agent"
? hasCommandPair && !agentModeExternalBridgeInvalid
: hasExternalBridge || hasCommandPair,
message: agentModeExternalBridgeInvalid
? "Chrome agent mode requires chrome.audioInputCommand and chrome.audioOutputCommand; chrome.audioBridgeCommand is bidi-only"
: hasExternalBridge
? "Chrome audio bridge command configured"
: hasCommandPair
? `Chrome command-pair talk-back audio bridge configured (${config.chrome.audioFormat})`
: "Chrome talk-back audio bridge not configured",
});
} else if (transport === "chrome" || transport === "chrome-node") {
checks.push({
id: "audio-bridge",
ok: true,
message: "Chrome observe-only mode does not require a realtime audio bridge",
});
}
checks.push({
id: "guest-join-defaults",
ok: Boolean(
config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab,
),
message:
config.chrome.guestName && config.chrome.autoJoin && config.chrome.reuseExistingTab
? "Guest auto-join and tab reuse defaults are enabled"
: "Set chrome.guestName, chrome.autoJoin, and chrome.reuseExistingTab for unattended guest joins",
});
checks.push({
id: "chrome-node-target",
ok: config.defaultTransport !== "chrome-node" || Boolean(config.chromeNode.node),
message:
config.defaultTransport === "chrome-node" && !config.chromeNode.node
? "chrome-node default should pin chromeNode.node when multiple nodes may be connected"
: config.chromeNode.node
? `Chrome node pinned to ${config.chromeNode.node}`
: "Chrome node not pinned; automatic selection works when exactly one capable node is connected",
});
if (needsChromeRealtimeAudio) {
checks.push({
id: "intro-after-in-call",
ok: config.chrome.waitForInCallMs > 0,
message:
config.chrome.waitForInCallMs > 0
? `Realtime intro waits up to ${config.chrome.waitForInCallMs}ms for the Meet tab to be in-call`
: "Set chrome.waitForInCallMs to delay realtime intro until the Meet tab is in-call",
});
}
if (transport === "twilio") {
const hasRequestDialPlan = Boolean(options?.twilioDialInNumber);
const hasDefaultDialPlan = Boolean(config.twilio.defaultDialInNumber);
const hasDialPlan = hasRequestDialPlan || hasDefaultDialPlan;
checks.push({
id: "twilio-dial-plan",
ok: hasDialPlan,
message: hasRequestDialPlan
? "Twilio request includes a Meet dial-in number"
: hasDefaultDialPlan
? "Twilio default Meet dial-in number is configured"
: "Twilio joins require a Meet dial-in phone number; pass dialInNumber with optional pin/dtmfSequence or configure twilio.defaultDialInNumber",
});
}
const shouldCheckTwilioDelegation =
config.voiceCall.enabled &&
(transport === "twilio" ||
Boolean(config.twilio.defaultDialInNumber) ||
Object.hasOwn(pluginEntries, "voice-call"));
if (shouldCheckTwilioDelegation) {
const voiceCallAllowed = !Array.isArray(pluginAllow) || pluginAllow.includes("voice-call");
const hasVoiceCallEntry = Object.hasOwn(pluginEntries, "voice-call");
const voiceCallEnabled = hasVoiceCallEntry && voiceCallEntry.enabled !== false;
checks.push({
id: "twilio-voice-call-plugin",
ok: voiceCallAllowed && voiceCallEnabled,
message:
voiceCallAllowed && voiceCallEnabled
? "Twilio transport can delegate dialing to the voice-call plugin"
: "Enable plugins.entries.voice-call and include voice-call in plugins.allow for Twilio dialing",
});
const provider = normalizeOptionalString(voiceCallConfig.provider) ?? "twilio";
if (provider === "twilio") {
const accountSid = normalizeOptionalString(voiceCallTwilioConfig.accountSid);
const authToken = normalizeOptionalString(voiceCallTwilioConfig.authToken);
const fromNumber = normalizeOptionalString(voiceCallConfig.fromNumber);
const twilioReady = Boolean(
(accountSid || env.TWILIO_ACCOUNT_SID) &&
(authToken || env.TWILIO_AUTH_TOKEN) &&
(fromNumber || env.TWILIO_FROM_NUMBER),
);
checks.push({
id: "twilio-voice-call-credentials",
ok: twilioReady,
message: twilioReady
? "Twilio voice-call credentials are configured"
: "Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER or configure voice-call Twilio credentials",
});
checks.push(getVoiceCallWebhookExposureCheck(voiceCallConfig));
}
}
return {
ok: checks.every((check) => check.ok),
checks,
};
}
export function addGoogleMeetSetupCheck(
status: GoogleMeetSetupStatus,
check: SetupCheck,
): GoogleMeetSetupStatus {
const checks = [...status.checks, check];
return {
ok: checks.every((item) => item.ok),
checks,
};
}

View File

@@ -0,0 +1,236 @@
// Google Meet plugin module implements plugin harness behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { vi } from "vitest";
type GoogleMeetTestPluginEntry = {
register(api: OpenClawPluginApi): void;
};
export const noopLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
type GoogleMeetTestNodeListResult = {
nodes: Array<{
nodeId: string;
displayName?: string;
connected?: boolean;
commands?: string[];
caps?: string[];
remoteIp?: string;
}>;
};
type CommandResult = {
code: number;
stdout?: string;
stderr?: string;
};
export function captureStdout() {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write);
return {
output: () => output,
restore: () => writeSpy.mockRestore(),
};
}
export function setupGoogleMeetPlugin(
plugin: GoogleMeetTestPluginEntry,
config: Record<string, unknown> = {},
options: {
fullConfig?: Record<string, unknown>;
nodesListResult?: GoogleMeetTestNodeListResult;
nodesInvokeResult?: unknown;
browserActResult?: Record<string, unknown>;
nodesInvokeHandler?: (params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
}) => Promise<unknown>;
runCommandWithTimeoutHandler?: (
argv: string[],
options?: { timeoutMs?: number },
) => Promise<CommandResult>;
registerPlatform?: NodeJS.Platform;
toolContext?: Record<string, unknown>;
} = {},
) {
const methods = new Map<string, unknown>();
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodeInvokePolicies: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
nodes: [
{
nodeId: "node-1",
displayName: "parallels-macos",
connected: true,
caps: ["browser"],
commands: ["browser.proxy", "googlemeet.chrome"],
},
],
},
);
const nodesInvoke = vi.fn(async (params) => {
if (options.nodesInvokeHandler) {
return options.nodesInvokeHandler(params);
}
if (params.command === "browser.proxy") {
const proxy = params.params as { path?: string; body?: { url?: string; targetId?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { running: true, tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "tab-1",
title: "Meet",
url: proxy.body?.url ?? "https://meet.google.com/abc-defg-hij",
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: proxy.body?.targetId ?? "tab-1",
result: JSON.stringify(
options.browserActResult ?? {
inCall: true,
micMuted: false,
title: "Meet call",
url: "https://meet.google.com/abc-defg-hij",
},
),
},
},
};
}
return { payload: { result: { ok: true } } };
}
return options.nodesInvokeResult ?? { launched: true };
});
const runCommandWithTimeout = vi.fn(
async (argv: string[], runOptions?: { timeoutMs?: number }) => {
if (options.runCommandWithTimeoutHandler) {
return options.runCommandWithTimeoutHandler(argv, runOptions);
}
if (argv[0] === "/usr/sbin/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",
config: options.fullConfig ?? {},
pluginConfig: config,
runtime: {
system: {
runCommandWithTimeout,
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
},
nodes: {
list: nodesList,
invoke: nodesInvoke,
},
} as unknown as OpenClawPluginApi["runtime"],
logger: noopLogger,
registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
registerTool: (tool: unknown) => {
tools.push(
typeof tool === "function"
? (tool as (ctx: Record<string, unknown>) => unknown)(options.toolContext ?? {})
: tool,
);
},
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
});
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
value: options.registerPlatform ?? "darwin",
});
try {
plugin.register(api);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
return {
cliRegistrations,
methods,
tools,
runCommandWithTimeout,
nodesList,
nodesInvoke,
nodeHostCommands,
nodeInvokePolicies,
};
}
export async function invokeGoogleMeetGatewayMethodForTest(
methods: Map<string, unknown>,
method: string,
params?: unknown,
): Promise<unknown> {
const handler = methods.get(method) as
| ((opts: {
params: Record<string, unknown>;
respond: (
ok: boolean,
payload?: unknown,
error?: { message?: string; details?: unknown },
) => void;
}) => Promise<void> | void)
| undefined;
if (!handler) {
throw new Error(`gateway method not registered: ${method}`);
}
return await new Promise((resolve, reject) => {
const respond = (
ok: boolean,
payload?: unknown,
error?: { message?: string; details?: unknown },
) => {
if (ok) {
resolve(payload);
return;
}
const err = new Error(error?.message ?? "gateway request failed") as Error & {
details?: unknown;
};
err.details = error?.details ?? payload;
reject(err);
};
void Promise.resolve(
handler({
params: (params && typeof params === "object" && !Array.isArray(params)
? params
: {}) as Record<string, unknown>,
respond,
}),
).catch(reject);
});
}

View File

@@ -0,0 +1,6 @@
// Google Meet plugin module implements chrome audio device behavior.
export const GOOGLE_MEET_SYSTEM_PROFILER_COMMAND = "/usr/sbin/system_profiler";
export function outputMentionsBlackHole2ch(output: string): boolean {
return /\bBlackHole\s+2ch\b/i.test(output);
}

View File

@@ -0,0 +1,85 @@
// Google Meet tests cover chrome browser proxy plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { describe, expect, it, vi } from "vitest";
import { callBrowserProxyOnNode, forceMeetEnglishUi } from "./chrome-browser-proxy.js";
describe("forceMeetEnglishUi", () => {
it("pins hl=en on Meet URLs", () => {
expect(forceMeetEnglishUi("https://meet.google.com/abc-defg-hij")).toBe(
"https://meet.google.com/abc-defg-hij?hl=en",
);
expect(forceMeetEnglishUi("https://meet.google.com/new")).toBe(
"https://meet.google.com/new?hl=en",
);
});
it("overrides an existing hl and keeps other params", () => {
expect(forceMeetEnglishUi("https://meet.google.com/abc-defg-hij?hl=zh-TW&authuser=1")).toBe(
"https://meet.google.com/abc-defg-hij?hl=en&authuser=1",
);
});
});
describe("Google Meet Chrome browser proxy", () => {
it("reports malformed node proxy payloadJSON with an owned error", async () => {
const invoke = vi.fn(async () => ({
ok: true,
payloadJSON: "{not json",
}));
const runtime = {
nodes: {
invoke,
},
} as unknown as PluginRuntime;
await expect(
callBrowserProxyOnNode({
runtime,
nodeId: "node-1",
method: "GET",
path: "/tabs",
timeoutMs: 100,
}),
).rejects.toThrow("Google Meet browser proxy returned malformed payloadJSON.");
expect(invoke).toHaveBeenCalledWith({
nodeId: "node-1",
command: "browser.proxy",
params: {
method: "GET",
path: "/tabs",
body: undefined,
timeoutMs: 100,
},
timeoutMs: 5_100,
scopes: ["operator.admin"],
});
});
it("caps oversized node proxy gateway timeouts", async () => {
const invoke = vi.fn(async () => ({
ok: true,
payloadJSON: JSON.stringify({ result: { ok: true } }),
}));
const runtime = {
nodes: {
invoke,
},
} as unknown as PluginRuntime;
await callBrowserProxyOnNode({
runtime,
nodeId: "node-1",
method: "GET",
path: "/tabs",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: MAX_TIMER_TIMEOUT_MS,
}),
);
});
});

View File

@@ -0,0 +1,220 @@
// Google Meet plugin module implements chrome browser proxy behavior.
import { addTimerTimeoutGraceMs } from "openclaw/plugin-sdk/number-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
type BrowserProxyResult = {
result?: unknown;
};
export type BrowserTab = {
targetId?: string;
title?: string;
url?: string;
};
// Meet automation scripts match English UI labels ("Join now", "Turn off microphone").
// hl=en pins the Meet page language regardless of account/browser locale; without it,
// non-English profiles render localized labels and every DOM matcher goes blind.
export function forceMeetEnglishUi(url: string): string {
try {
const parsed = new URL(url);
parsed.searchParams.set("hl", "en");
return parsed.toString();
} catch {
return url;
}
}
export function normalizeMeetUrlForReuse(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "meet.google.com") {
return undefined;
}
const match = parsed.pathname.match(/^\/(new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:\/)?$/i);
if (!match?.[1]) {
return undefined;
}
return `https://meet.google.com/${match[1].toLowerCase()}`;
} catch {
return undefined;
}
}
export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefined): boolean {
const normalizedA = normalizeMeetUrlForReuse(a);
const normalizedB = normalizeMeetUrlForReuse(b);
return Boolean(normalizedA && normalizedB && normalizedA === normalizedB);
}
type GoogleMeetNodeInfo = {
caps?: string[];
commands?: string[];
connected?: boolean;
nodeId?: string;
displayName?: string;
remoteIp?: string;
};
function isGoogleMeetNode(node: GoogleMeetNodeInfo) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const caps = Array.isArray(node.caps) ? node.caps : [];
return (
node.connected === true &&
commands.includes("googlemeet.chrome") &&
(commands.includes("browser.proxy") || caps.includes("browser"))
);
}
function matchesRequestedNode(node: GoogleMeetNodeInfo, requested: string): boolean {
return [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested);
}
function formatNodeLabel(node: GoogleMeetNodeInfo): string {
const parts = [node.displayName, node.nodeId, node.remoteIp].filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "unknown node";
}
function describeNodeUsabilityIssues(node: GoogleMeetNodeInfo): string[] {
const commands = Array.isArray(node.commands) ? node.commands : [];
const caps = Array.isArray(node.caps) ? node.caps : [];
const issues: string[] = [];
if (node.connected !== true) {
issues.push("offline");
}
if (!commands.includes("googlemeet.chrome")) {
issues.push("missing googlemeet.chrome");
}
if (!commands.includes("browser.proxy") && !caps.includes("browser")) {
issues.push("missing browser.proxy/browser capability");
}
return issues;
}
async function listGoogleMeetNodes(
runtime: PluginRuntime,
params?: { connected?: boolean },
): Promise<{ nodes: GoogleMeetNodeInfo[] }> {
try {
return params ? await runtime.nodes.list(params) : await runtime.nodes.list();
} catch (error) {
throw new Error("Google Meet node inventory unavailable", {
cause: error,
});
}
}
export async function resolveChromeNodeInfo(params: {
runtime: PluginRuntime;
requestedNode?: string;
}): Promise<GoogleMeetNodeInfo> {
const requested = params.requestedNode?.trim();
if (requested) {
const list = await listGoogleMeetNodes(params.runtime);
const matches = list.nodes.filter((node) => matchesRequestedNode(node, requested));
if (matches.length === 1) {
const [node] = matches;
if (isGoogleMeetNode(node)) {
return node;
}
throw new Error(
`Configured Google Meet node ${requested} is not usable (${formatNodeLabel(node)}): ${describeNodeUsabilityIssues(node).join("; ")}. Start or reinstall \`openclaw node run\` on that Chrome host, approve pairing, and allow googlemeet.chrome plus browser.proxy.`,
);
}
if (matches.length > 1) {
throw new Error(
`Configured Google Meet node ${requested} is ambiguous (${matches.length} matches). Pin chromeNode.node to a unique node id, display name, or remote IP.`,
);
}
throw new Error(
`Configured Google Meet node ${requested} was not found. Run \`openclaw nodes status\` and start or approve the Chrome node.`,
);
}
const list = await listGoogleMeetNodes(params.runtime, { connected: true });
const nodes = list.nodes.filter(isGoogleMeetNode);
if (nodes.length === 0) {
throw new Error(
"No connected Google Meet-capable node with browser proxy. Run `openclaw node run` on the Chrome host with browser proxy enabled, approve pairing, and allow googlemeet.chrome plus browser.proxy.",
);
}
if (nodes.length === 1) {
return nodes[0];
}
throw new Error(
"Multiple Google Meet-capable nodes connected. Set plugins.entries.google-meet.config.chromeNode.node.",
);
}
export async function resolveChromeNode(params: {
runtime: PluginRuntime;
requestedNode?: string;
}): Promise<string> {
const node = await resolveChromeNodeInfo(params);
if (!node.nodeId) {
throw new Error("Google Meet node did not include a node id.");
}
return node.nodeId;
}
function unwrapNodeInvokePayload(raw: unknown): unknown {
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) {
try {
return JSON.parse(record.payloadJSON);
} catch (error) {
throw new Error("Google Meet browser proxy returned malformed payloadJSON.", {
cause: error,
});
}
}
if ("payload" in record) {
return record.payload;
}
return raw;
}
function parseBrowserProxyResult(raw: unknown): unknown {
const payload = unwrapNodeInvokePayload(raw);
const proxy =
payload && typeof payload === "object" ? (payload as BrowserProxyResult) : undefined;
if (!proxy || !("result" in proxy)) {
throw new Error("Google Meet browser proxy returned an invalid result.");
}
return proxy.result;
}
export async function callBrowserProxyOnNode(params: {
runtime: PluginRuntime;
nodeId: string;
method: "GET" | "POST" | "DELETE";
path: string;
body?: unknown;
timeoutMs: number;
}) {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: "browser.proxy",
params: {
method: params.method,
path: params.path,
body: params.body,
timeoutMs: params.timeoutMs,
},
timeoutMs: addTimerTimeoutGraceMs(params.timeoutMs) ?? 1,
scopes: ["operator.admin"],
});
return parseBrowserProxyResult(raw);
}
export function asBrowserTabs(result: unknown): BrowserTab[] {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
return Array.isArray(record.tabs) ? (record.tabs as BrowserTab[]) : [];
}
export function readBrowserTab(result: unknown): BrowserTab | undefined {
return result && typeof result === "object" ? (result as BrowserTab) : undefined;
}

View File

@@ -0,0 +1,368 @@
// Google Meet plugin module implements chrome create behavior.
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "../config.js";
import {
asBrowserTabs,
callBrowserProxyOnNode,
forceMeetEnglishUi,
readBrowserTab,
resolveChromeNode,
type BrowserTab,
} from "./chrome-browser-proxy.js";
import type { GoogleMeetChromeHealth } from "./types.js";
const GOOGLE_MEET_NEW_URL = "https://meet.google.com/new";
const GOOGLE_MEET_BROWSER_CREATE_TIMEOUT_MS = 60_000;
const GOOGLE_MEET_BROWSER_STEP_TIMEOUT_MS = 10_000;
const GOOGLE_MEET_BROWSER_NAVIGATION_RETRY_MS = 1_000;
const GOOGLE_MEET_BROWSER_POLL_MS = 500;
type BrowserCreateStepResult = {
meetingUri?: string;
browserUrl?: string;
browserTitle?: string;
manualAction?: string;
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
notes?: string[];
retryAfterMs?: number;
};
type GoogleMeetBrowserCreateResult = {
meetingUri: string;
nodeId: string;
targetId?: string;
browserUrl?: string;
browserTitle?: string;
notes?: string[];
source: "browser";
};
type GoogleMeetBrowserManualAction = {
source: "browser";
error: string;
manualActionRequired: true;
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
manualActionMessage: string;
browser: {
nodeId: string;
targetId?: string;
browserUrl?: string;
browserTitle?: string;
notes?: string[];
};
};
class GoogleMeetBrowserManualActionError extends Error {
readonly payload: GoogleMeetBrowserManualAction;
constructor(payload: Omit<GoogleMeetBrowserManualAction, "source" | "error">) {
const prefix = payload.manualActionReason ? `${payload.manualActionReason}: ` : "";
super(`${prefix}${payload.manualActionMessage}`);
this.name = "GoogleMeetBrowserManualActionError";
this.payload = {
source: "browser",
error: this.message,
...payload,
};
}
}
export function isGoogleMeetBrowserManualActionError(
error: unknown,
): error is GoogleMeetBrowserManualActionError {
return error instanceof GoogleMeetBrowserManualActionError;
}
function formatBrowserAutomationError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}
function isBrowserNavigationInterruption(error: unknown): boolean {
return /execution context was destroyed|navigation|target closed/i.test(
formatBrowserAutomationError(error),
);
}
function isGoogleMeetCreateTab(tab: BrowserTab): boolean {
const url = tab.url ?? "";
if (/^https:\/\/meet\.google\.com\/(?:new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:$|[/?#])/i.test(url)) {
return true;
}
return (
url.startsWith("https://accounts.google.com/") &&
/sign in|google accounts|meet/i.test(tab.title ?? "")
);
}
async function findGoogleMeetCreateTab(params: {
runtime: PluginRuntime;
nodeId: string;
timeoutMs: number;
}): Promise<BrowserTab | undefined> {
const tabs = asBrowserTabs(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "GET",
path: "/tabs",
timeoutMs: params.timeoutMs,
}),
);
return tabs.find(isGoogleMeetCreateTab);
}
async function focusBrowserTab(params: {
runtime: PluginRuntime;
nodeId: string;
targetId: string;
timeoutMs: number;
}): Promise<void> {
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "POST",
path: "/tabs/focus",
body: { targetId: params.targetId },
timeoutMs: params.timeoutMs,
});
}
function readStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: undefined;
}
function readBrowserCreateResult(result: unknown): BrowserCreateStepResult {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
const nested =
record.result && typeof record.result === "object"
? (record.result as Record<string, unknown>)
: record;
return {
meetingUri: typeof nested.meetingUri === "string" ? nested.meetingUri : undefined,
browserUrl: typeof nested.browserUrl === "string" ? nested.browserUrl : undefined,
browserTitle: typeof nested.browserTitle === "string" ? nested.browserTitle : undefined,
manualAction: typeof nested.manualAction === "string" ? nested.manualAction : undefined,
manualActionReason:
typeof nested.manualActionReason === "string"
? (nested.manualActionReason as GoogleMeetChromeHealth["manualActionReason"])
: undefined,
notes: readStringArray(nested.notes),
retryAfterMs:
typeof nested.retryAfterMs === "number" && Number.isFinite(nested.retryAfterMs)
? nested.retryAfterMs
: undefined,
};
}
export const CREATE_MEET_FROM_BROWSER_SCRIPT = `async () => {
const meetUrlPattern = /^https:\\/\\/meet\\.google\\.com\\/[a-z]{3}-[a-z]{4}-[a-z]{3}(?:$|[/?#])/i;
const text = (node) => (node?.innerText || node?.textContent || "").trim();
const current = () => location.href;
const notes = [];
const findButton = (pattern) =>
[...document.querySelectorAll("button")].find((button) => {
const label = [
button.getAttribute("aria-label"),
button.getAttribute("data-tooltip"),
text(button),
]
.filter(Boolean)
.join(" ");
return pattern.test(label) && !button.disabled;
});
const clickButton = (pattern, note) => {
const button = findButton(pattern);
if (!button) {
return false;
}
button.click();
notes.push(note);
return true;
};
if (!current().startsWith("https://meet.google.com/")) {
return {
manualActionReason: "google-login-required",
manualAction: "Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: current(),
browserTitle: document.title,
notes,
};
}
const href = current();
if (meetUrlPattern.test(href)) {
// The /new redirect keeps the hl=en param we open with; strip query/hash so the
// meeting link handed to users stays canonical instead of forcing English on them.
return { meetingUri: href.split(/[?#]/)[0], browserUrl: href, browserTitle: document.title, notes };
}
const pageText = text(document.body);
if (clickButton(/\\buse microphone\\b/i, "Accepted Meet microphone prompt with browser automation.")) {
return { browserUrl: href, browserTitle: document.title, notes, retryAfterMs: 1000 };
}
if (
clickButton(
/continue without microphone/i,
"Continued through Meet microphone prompt with browser automation.",
)
) {
return { browserUrl: href, browserTitle: document.title, notes, retryAfterMs: 1000 };
}
if (/do you want people to hear you in the meeting/i.test(pageText)) {
return {
manualActionReason: "meet-audio-choice-required",
manualAction: "Meet is showing the microphone choice. Click Use microphone in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: href,
browserTitle: document.title,
notes,
};
}
if (/allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera)/i.test(pageText)) {
return {
manualActionReason: "meet-permission-required",
manualAction: "Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: href,
browserTitle: document.title,
notes,
};
}
if (/couldn't create|unable to create/i.test(pageText)) {
return {
manualAction: "Resolve the Google Meet page prompt in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: href,
browserTitle: document.title,
notes,
};
}
if (location.hostname.toLowerCase() === "accounts.google.com" || /use your google account|to continue to google meet|choose an account|sign in to (join|continue)/i.test(pageText)) {
return {
manualActionReason: "google-login-required",
manualAction: "Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: href,
browserTitle: document.title,
notes,
};
}
return {
retryAfterMs: 500,
browserUrl: current(),
browserTitle: document.title,
notes,
};
}`;
export async function createMeetWithBrowserProxyOnNode(params: {
runtime: PluginRuntime;
config: GoogleMeetConfig;
}): Promise<GoogleMeetBrowserCreateResult> {
const nodeId = await resolveChromeNode({
runtime: params.runtime,
requestedNode: params.config.chromeNode.node,
});
const timeoutMs = Math.max(
GOOGLE_MEET_BROWSER_CREATE_TIMEOUT_MS,
params.config.chrome.joinTimeoutMs,
);
const stepTimeoutMs = Math.min(timeoutMs, GOOGLE_MEET_BROWSER_STEP_TIMEOUT_MS);
let tab = await findGoogleMeetCreateTab({
runtime: params.runtime,
nodeId,
timeoutMs: stepTimeoutMs,
});
if (tab?.targetId) {
await focusBrowserTab({
runtime: params.runtime,
nodeId,
targetId: tab.targetId,
timeoutMs: stepTimeoutMs,
});
} else {
tab = readBrowserTab(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/tabs/open",
body: { url: forceMeetEnglishUi(GOOGLE_MEET_NEW_URL) },
timeoutMs: stepTimeoutMs,
}),
);
}
const targetId = tab?.targetId;
if (!targetId) {
throw new Error("Browser fallback opened Google Meet but did not return a targetId.");
}
const notes = new Set<string>();
let lastResult: BrowserCreateStepResult | undefined;
let lastError: unknown;
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
try {
const evaluated = await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/act",
body: {
kind: "evaluate",
targetId,
fn: CREATE_MEET_FROM_BROWSER_SCRIPT,
},
timeoutMs: stepTimeoutMs,
});
const result = readBrowserCreateResult(evaluated);
lastResult = result;
for (const note of result.notes ?? []) {
notes.add(note);
}
if (result.meetingUri) {
return {
source: "browser",
nodeId,
targetId,
meetingUri: result.meetingUri,
browserUrl: result.browserUrl,
browserTitle: result.browserTitle,
notes: [...notes],
};
}
if (result.manualAction) {
throw new GoogleMeetBrowserManualActionError({
manualActionRequired: true,
manualActionReason: result.manualActionReason,
manualActionMessage: result.manualAction,
browser: {
nodeId,
targetId,
browserUrl: result.browserUrl,
browserTitle: result.browserTitle,
notes: [...notes],
},
});
}
await sleep(result.retryAfterMs ?? GOOGLE_MEET_BROWSER_POLL_MS);
} catch (error) {
lastError = error;
if (!isBrowserNavigationInterruption(error)) {
throw error;
}
await sleep(GOOGLE_MEET_BROWSER_NAVIGATION_RETRY_MS);
}
}
throw new Error(
lastResult?.manualAction ??
`Google Meet did not return a meeting URL from the browser create flow before timeout.${
lastError
? ` Last browser automation error: ${formatBrowserAutomationError(lastError)}`
: ""
}`,
);
}

View File

@@ -0,0 +1,21 @@
// Google Meet tests cover chrome plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import { testing } from "./chrome.js";
describe("google meet chrome transport", () => {
it("wraps malformed browser status JSON", () => {
expect(() =>
testing.parseMeetBrowserStatusForTest({
result: "{not json",
}),
).toThrow("Google Meet browser status JSON is malformed.");
});
it("caps browser gateway timeout padding", () => {
expect(testing.resolveBrowserGatewayTimeoutMsForTest(10_000)).toBe(15_000);
expect(testing.resolveBrowserGatewayTimeoutMsForTest(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
// Google Meet plugin module implements twilio behavior.
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-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;
}
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}#`;
}
export function prefixDtmfWait(sequence: string | undefined, delayMs: number): string | undefined {
if (!sequence || delayMs <= 0) {
return sequence;
}
const waitCount = Math.ceil(delayMs / 500);
if (waitCount <= 0) {
return sequence;
}
return `${"w".repeat(waitCount)}${sequence}`;
}

View File

@@ -0,0 +1,148 @@
// Google Meet type declarations define plugin contracts.
import type { GoogleMeetMode, GoogleMeetModeInput, GoogleMeetTransport } from "../config.js";
type GoogleMeetSessionState = "active" | "ended";
export type GoogleMeetJoinRequest = {
url: string;
transport?: GoogleMeetTransport;
mode?: GoogleMeetModeInput;
message?: string;
requesterSessionKey?: string;
timeoutMs?: number;
dialInNumber?: string;
pin?: string;
dtmfSequence?: string;
};
type GoogleMeetManualActionReason =
| "google-login-required"
| "meet-admission-required"
| "meet-permission-required"
| "meet-audio-choice-required"
| "browser-control-unavailable";
type GoogleMeetSpeechBlockedReason =
| GoogleMeetManualActionReason
| "not-in-call"
| "browser-unverified"
| "audio-bridge-unavailable"
| "meet-microphone-muted";
export type GoogleMeetChromeHealth = {
inCall?: boolean;
micMuted?: boolean;
lobbyWaiting?: boolean;
leaveReason?: string;
captioning?: boolean;
captionsEnabledAttempted?: boolean;
transcriptLines?: number;
lastCaptionAt?: string;
lastCaptionSpeaker?: string;
lastCaptionText?: string;
recentTranscript?: Array<{
at?: string;
speaker?: string;
text: string;
}>;
realtimeTranscriptLines?: number;
lastRealtimeTranscriptAt?: string;
lastRealtimeTranscriptRole?: "user" | "assistant";
lastRealtimeTranscriptText?: string;
recentRealtimeTranscript?: Array<{
at: string;
role: "user" | "assistant";
text: string;
}>;
lastRealtimeEventAt?: string;
lastRealtimeEventType?: string;
lastRealtimeEventDetail?: string;
recentRealtimeEvents?: Array<{
at: string;
direction: "client" | "server";
type: string;
detail?: string;
}>;
recentTalkEvents?: Array<{
id: string;
type: string;
sessionId: string;
turnId?: string;
seq: number;
timestamp: string;
final?: boolean;
}>;
manualActionRequired?: boolean;
manualActionReason?: GoogleMeetManualActionReason;
manualActionMessage?: string;
speechReady?: boolean;
speechBlockedReason?: GoogleMeetSpeechBlockedReason;
speechBlockedMessage?: string;
providerConnected?: boolean;
realtimeReady?: boolean;
audioInputActive?: boolean;
audioOutputActive?: boolean;
audioOutputRouted?: boolean;
audioOutputDeviceLabel?: string;
audioOutputRouteError?: string;
lastInputAt?: string;
lastOutputAt?: string;
lastSuppressedInputAt?: string;
lastClearAt?: string;
lastInputBytes?: number;
lastOutputBytes?: number;
suppressedInputBytes?: number;
consecutiveInputErrors?: number;
lastInputError?: string;
clearCount?: number;
queuedInputChunks?: number;
browserUrl?: string;
browserTitle?: string;
bridgeClosed?: boolean;
status?: string;
notes?: string[];
};
export type GoogleMeetSession = {
id: string;
url: string;
transport: GoogleMeetTransport;
mode: GoogleMeetMode;
state: GoogleMeetSessionState;
createdAt: string;
updatedAt: string;
participantIdentity: string;
realtime: {
enabled: boolean;
strategy?: string;
provider?: string;
model?: string;
transcriptionProvider?: string;
toolPolicy: string;
};
chrome?: {
audioBackend: "blackhole-2ch";
launched: boolean;
nodeId?: string;
browserProfile?: string;
audioBridge?: {
type: "command-pair" | "node-command-pair" | "external-command";
provider?: string;
};
health?: GoogleMeetChromeHealth;
};
twilio?: {
dialInNumber: string;
pinProvided: boolean;
dtmfSequence?: string;
voiceCallId?: string;
dtmfSent?: boolean;
introSent?: boolean;
};
notes: string[];
};
export type GoogleMeetJoinResult = {
session: GoogleMeetSession;
spoken?: boolean;
};

View File

@@ -0,0 +1,153 @@
// Google Meet tests cover voice call gateway plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveGoogleMeetConfig } from "./config.js";
import {
endMeetVoiceCallGatewayCall,
getMeetVoiceCallGatewayCall,
joinMeetViaVoiceCallGateway,
} from "./voice-call-gateway.js";
const gatewayMocks = vi.hoisted(() => ({
request: vi.fn(),
stopAndWait: vi.fn(async () => {}),
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
GatewayClient: vi.fn(function MockGatewayClient(params: { onHelloOk?: () => void }) {
queueMicrotask(() => params.onHelloOk?.());
return {
request: gatewayMocks.request,
stopAndWait: gatewayMocks.stopAndWait,
};
}),
startGatewayClientWhenEventLoopReady: gatewayMocks.startGatewayClientWhenEventLoopReady,
}));
describe("Google Meet voice-call gateway", () => {
beforeEach(() => {
vi.useRealTimers();
gatewayMocks.request.mockReset();
gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
gatewayMocks.stopAndWait.mockClear();
gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/gateway-runtime");
vi.resetModules();
});
it("starts Twilio Meet calls with pre-connect DTMF, then speaks the intro without TwiML fallback", async () => {
const config = resolveGoogleMeetConfig({
voiceCall: {
gatewayUrl: "ws://127.0.0.1:18789",
dtmfDelayMs: 1,
postDtmfSpeechDelayMs: 2,
},
realtime: { introMessage: "Say exactly: I'm here and listening." },
});
const join = joinMeetViaVoiceCallGateway({
config,
dialInNumber: "+15551234567",
dtmfSequence: "123456#",
message: "Say exactly: I'm here and listening.",
requesterSessionKey: "agent:main:discord:channel:general",
sessionKey: "voice:google-meet:meet-1",
});
await join;
expect(gatewayMocks.request).toHaveBeenNthCalledWith(
1,
"voicecall.start",
{
to: "+15551234567",
mode: "conversation",
dtmfSequence: "123456#",
requesterSessionKey: "agent:main:discord:channel:general",
sessionKey: "voice:google-meet:meet-1",
},
{ timeoutMs: 30_000 },
);
expect(gatewayMocks.request).toHaveBeenNthCalledWith(
2,
"voicecall.speak",
{
callId: "call-1",
allowTwimlFallback: false,
message: "Say exactly: I'm here and listening.",
},
{ timeoutMs: 30_000 },
);
expect(gatewayMocks.request).toHaveBeenCalledTimes(2);
});
it("skips the intro without failing when the realtime bridge is not ready", async () => {
gatewayMocks.request
.mockResolvedValueOnce({ callId: "call-1" })
.mockResolvedValueOnce({ success: false, error: "No active realtime bridge for call" });
const config = resolveGoogleMeetConfig({
voiceCall: {
gatewayUrl: "ws://127.0.0.1:18789",
dtmfDelayMs: 1,
postDtmfSpeechDelayMs: 1,
},
});
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const result = await joinMeetViaVoiceCallGateway({
config,
dialInNumber: "+15551234567",
dtmfSequence: "123456#",
logger,
message: "Say exactly: I'm here and listening.",
});
expect(result.callId).toBe("call-1");
expect(result.dtmfSent).toBe(true);
expect(result.introSent).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
"[google-meet] Skipped intro speech because realtime bridge was not ready: No active realtime bridge for call",
);
});
it("treats missing delegated calls as already ended", async () => {
gatewayMocks.request.mockRejectedValueOnce(new Error("Call not found"));
const config = resolveGoogleMeetConfig({
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
});
await expect(
endMeetVoiceCallGatewayCall({ config, callId: "call-1" }),
).resolves.toBeUndefined();
expect(gatewayMocks.request).toHaveBeenCalledWith(
"voicecall.end",
{ callId: "call-1" },
{ timeoutMs: 30_000 },
);
});
it("reads delegated call status from the gateway", async () => {
gatewayMocks.request.mockResolvedValueOnce({ found: false });
const config = resolveGoogleMeetConfig({
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
});
await expect(getMeetVoiceCallGatewayCall({ config, callId: "call-1" })).resolves.toEqual({
found: false,
});
expect(gatewayMocks.request).toHaveBeenCalledWith(
"voicecall.status",
{ callId: "call-1" },
{ timeoutMs: 30_000 },
);
});
});

View File

@@ -0,0 +1,242 @@
// Google Meet plugin module implements voice call gateway behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
GatewayClient,
startGatewayClientWhenEventLoopReady,
} from "openclaw/plugin-sdk/gateway-runtime";
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import type { GoogleMeetConfig } from "./config.js";
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
type VoiceCallStartResult = {
callId?: string;
initiated?: boolean;
error?: string;
};
type VoiceCallSpeakResult = {
success?: boolean;
error?: string;
};
type VoiceCallStatusResult = {
found?: boolean;
call?: unknown;
};
type VoiceCallMeetJoinResult = {
callId: string;
dtmfSent: boolean;
introSent: boolean;
};
async function createConnectedGatewayClient(
config: GoogleMeetConfig,
): Promise<VoiceCallGatewayClient> {
let client: VoiceCallGatewayClient;
await new Promise<void>((resolve, reject) => {
const abortStart = new AbortController();
const timer = setTimeout(() => {
abortStart.abort();
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);
abortStart.abort();
reject(err);
},
});
void startGatewayClientWhenEventLoopReady(client, {
timeoutMs: config.voiceCall.requestTimeoutMs,
signal: abortStart.signal,
})
.then((readiness) => {
if (!readiness.ready && !readiness.aborted) {
clearTimeout(timer);
reject(new Error("gateway event loop readiness timeout"));
}
})
.catch((err: unknown) => {
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
return client!;
}
export function isVoiceCallMissingError(error: unknown): boolean {
const message = formatErrorMessage(error).toLowerCase();
return message.includes("call not found") || message.includes("call is not active");
}
export async function joinMeetViaVoiceCallGateway(params: {
config: GoogleMeetConfig;
dialInNumber: string;
dtmfSequence?: string;
logger?: RuntimeLogger;
message?: string;
requesterSessionKey?: string;
sessionKey?: string;
}): Promise<VoiceCallMeetJoinResult> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
params.logger?.info(
`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
);
const start = (await client.request(
"voicecall.start",
{
to: params.dialInNumber,
mode: "conversation",
...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallStartResult;
if (!start.callId) {
throw new Error(start.error || "voicecall.start did not return callId");
}
params.logger?.info(
`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`,
);
const dtmfSent = Boolean(params.dtmfSequence);
if (dtmfSent) {
params.logger?.info(
`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
);
}
let introSent = false;
if (params.message) {
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
if (delayMs > 0) {
params.logger?.info(
`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
);
await sleep(delayMs);
}
let spoken: VoiceCallSpeakResult;
try {
spoken = (await client.request(
"voicecall.speak",
{
callId: start.callId,
allowTwimlFallback: false,
message: params.message,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallSpeakResult;
} catch (err) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
);
spoken = { success: false };
}
if (spoken.success === false) {
params.logger?.warn?.(
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${
spoken.error || "voicecall.speak failed"
}`,
);
} else {
introSent = true;
params.logger?.info(
`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
);
}
}
return {
callId: start.callId,
dtmfSent,
introSent,
};
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
}
export async function endMeetVoiceCallGatewayCall(params: {
config: GoogleMeetConfig;
callId: string;
}): Promise<void> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
try {
await client.request(
"voicecall.end",
{
callId: params.callId,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
);
} catch (err) {
if (!isVoiceCallMissingError(err)) {
throw err;
}
}
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
}
export async function getMeetVoiceCallGatewayCall(params: {
config: GoogleMeetConfig;
callId: string;
}): Promise<VoiceCallStatusResult> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
return (await client.request(
"voicecall.status",
{
callId: params.callId,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallStatusResult;
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
}
export async function speakMeetViaVoiceCallGateway(params: {
config: GoogleMeetConfig;
callId: string;
message: string;
}): Promise<void> {
let client: VoiceCallGatewayClient | undefined;
try {
client = await createConnectedGatewayClient(params.config);
const spoken = (await client.request(
"voicecall.speak",
{
callId: params.callId,
message: params.message,
},
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
)) as VoiceCallSpeakResult;
if (spoken.success === false) {
throw new Error(spoken.error || "voicecall.speak failed");
}
} finally {
await client?.stopAndWait({ timeoutMs: 1_000 });
}
}