Files
openclaw/extensions/google-meet/src/setup.ts
Peter Steinberger 77d9ac30bb refactor: reuse shared coercion helpers (#86419)
* refactor: share talk event metric extraction

* refactor: reuse shared coercion helpers

* refactor: reuse shared primitive guards

* refactor: reuse shared record guard

* refactor: reuse shared primitive helpers

* refactor: reuse shared string guards

* refactor: reuse shared non-empty string guard

* refactor: share plugin primitive coercion helpers

* refactor: reuse plugin coercion helpers

* refactor: reuse plugin coercion helpers in more plugins

* refactor: reuse channel coercion helpers

* refactor: reuse monitor coercion helpers

* refactor: reuse provider coercion helpers

* refactor: reuse core coercion helpers

* refactor: reuse runtime coercion helpers

* refactor: reuse helper coercion in codex paths

* refactor: reuse helper coercion in runtime paths

* refactor: reuse codex app-server coercion helpers

* refactor: reuse codex record helpers

* refactor: reuse migration and qa record helpers

* refactor: reuse feishu and core helper guards

* refactor: reuse browser and policy coercion helpers

* refactor: reuse memory wiki record helper

* refactor: share boolean coercion helpers

* refactor: reuse finite number coercion

* refactor: reuse trimmed string list helpers

* refactor: reuse string list normalization

* refactor: reuse remaining string list helpers

* refactor: reuse string entry normalizer

* refactor: share sorted string helpers

* refactor: share string list normalization

* test: preserve command registry browser imports

* refactor: reuse trimmed list helpers

* refactor: reuse string dedupe helpers

* refactor: reuse local dedupe helpers

* refactor: reuse more string dedupe helpers

* refactor: reuse command string dedupe helpers

* refactor: dedupe memory path lists with helper

* refactor: expose string dedupe helpers to plugins

* refactor: reuse core string dedupe helpers

* refactor: reuse shared unique value helpers

* refactor: reuse unique helpers in agent utilities

* refactor: reuse unique helpers in config plumbing

* refactor: reuse unique helpers in extensions

* refactor: reuse unique helpers in core utilities

* refactor: reuse unique helpers in qa plugins

* refactor: reuse unique helpers in memory plugins

* refactor: reuse unique helpers in channel plugins

* refactor: reuse unique helpers in core tails

* refactor: reuse unique helper in comfy workflow

* refactor: reuse unique helpers in test utilities

* refactor: expose unique value helper to plugins

* refactor: reuse unique helpers for numeric lists

* refactor: replace index dedupe filters

* refactor: reuse string entry normalization

* refactor: reuse string normalization in plugin helpers

* refactor: reuse string normalization in extension helpers

* refactor: reuse string normalization in channel parsers

* refactor: reuse string normalization in memory search

* refactor: reuse string normalization in provider parsers

* refactor: reuse string normalization in qa helpers

* refactor: reuse string normalization in infra parsers

* refactor: reuse string normalization in messaging parsers

* refactor: reuse string normalization in core parsers

* refactor: reuse string normalization in extension parsers

* refactor: reuse string normalization in remaining parsers

* refactor: reuse string normalization in final parser spots

* refactor: reuse string normalization in qa media helpers

* refactor: reuse normalization in provider and media lists

* refactor: reuse normalization for remaining set filters

* refactor: reuse normalization in policy allowlists

* refactor: reuse normalization in session and owner lists

* refactor: centralize primitive string lists

* refactor: reuse lowercase entry helpers

* refactor: reuse sorted string helpers

* refactor: reuse unique trimmed helpers

* refactor: reuse string normalization helpers

* refactor: reuse catalog string helpers

* refactor: reuse remaining string helpers

* refactor: simplify remaining list normalization

* refactor: reuse codex auth order normalization

* chore: refresh plugin sdk api baseline

* fix: make shared string sorting deterministic

* chore: refresh plugin sdk api baseline

* fix: align host env security ordering
2026-05-25 21:20:41 +01:00

277 lines
9.6 KiB
TypeScript

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