mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 07:54:55 +00:00
* 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
322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import path from "node:path";
|
|
import {
|
|
type OpenClawConfig,
|
|
DEFAULT_GATEWAY_PORT,
|
|
type HooksGmailTailscaleMode,
|
|
resolveGatewayPort,
|
|
} from "../config/config.js";
|
|
import { resolveExecutable } from "../infra/executable-path.js";
|
|
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { normalizeUniqueStringEntries } from "../shared/string-normalization.js";
|
|
|
|
export const DEFAULT_GMAIL_LABEL = "INBOX";
|
|
export const DEFAULT_GMAIL_TOPIC = "gog-gmail-watch";
|
|
export const DEFAULT_GMAIL_SUBSCRIPTION = "gog-gmail-watch-push";
|
|
export const DEFAULT_GMAIL_SERVE_BIND = "127.0.0.1";
|
|
export const DEFAULT_GMAIL_SERVE_PORT = 8788;
|
|
export const DEFAULT_GMAIL_SERVE_PATH = "/gmail-pubsub";
|
|
export const DEFAULT_GMAIL_MAX_BYTES = 20_000;
|
|
export const DEFAULT_GMAIL_RENEW_MINUTES = 12 * 60;
|
|
const DEFAULT_HOOKS_PATH = "/hooks";
|
|
const GMAIL_WATCH_SENSITIVE_FLAGS = new Set(["--token", "--hook-url", "--hook-token"]);
|
|
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
|
|
let gogBin: string | undefined;
|
|
|
|
export type GmailHookOverrides = {
|
|
account?: string;
|
|
label?: string;
|
|
topic?: string;
|
|
subscription?: string;
|
|
pushToken?: string;
|
|
hookToken?: string;
|
|
hookUrl?: string;
|
|
includeBody?: boolean;
|
|
maxBytes?: number;
|
|
renewEveryMinutes?: number;
|
|
serveBind?: string;
|
|
servePort?: number;
|
|
servePath?: string;
|
|
tailscaleMode?: HooksGmailTailscaleMode;
|
|
tailscalePath?: string;
|
|
tailscaleTarget?: string;
|
|
};
|
|
|
|
export type GmailHookRuntimeConfig = {
|
|
account: string;
|
|
label: string;
|
|
topic: string;
|
|
subscription: string;
|
|
pushToken: string;
|
|
hookToken: string;
|
|
hookUrl: string;
|
|
includeBody: boolean;
|
|
maxBytes: number;
|
|
renewEveryMinutes: number;
|
|
serve: {
|
|
bind: string;
|
|
port: number;
|
|
path: string;
|
|
};
|
|
tailscale: {
|
|
mode: HooksGmailTailscaleMode;
|
|
path: string;
|
|
target?: string;
|
|
};
|
|
};
|
|
|
|
export function generateHookToken(bytes = 24): string {
|
|
return randomBytes(bytes).toString("hex");
|
|
}
|
|
|
|
export function mergeHookPresets(existing: string[] | undefined, preset: string): string[] {
|
|
const next = new Set(normalizeUniqueStringEntries(existing));
|
|
next.add(preset);
|
|
return Array.from(next);
|
|
}
|
|
|
|
export function normalizeHooksPath(raw?: string): string {
|
|
const base = raw?.trim() || DEFAULT_HOOKS_PATH;
|
|
if (base === "/") {
|
|
return DEFAULT_HOOKS_PATH;
|
|
}
|
|
const withSlash = base.startsWith("/") ? base : `/${base}`;
|
|
return withSlash.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function normalizeServePath(raw?: string): string {
|
|
const base = raw?.trim() || DEFAULT_GMAIL_SERVE_PATH;
|
|
// Tailscale funnel/serve strips the set-path prefix before proxying.
|
|
// To accept requests at /<path> externally, gog must listen on "/".
|
|
if (base === "/") {
|
|
return "/";
|
|
}
|
|
const withSlash = base.startsWith("/") ? base : `/${base}`;
|
|
return withSlash.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function buildDefaultHookUrl(
|
|
hooksPath?: string,
|
|
port: number = DEFAULT_GATEWAY_PORT,
|
|
): string {
|
|
const basePath = normalizeHooksPath(hooksPath);
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
return joinUrl(baseUrl, `${basePath}/gmail`);
|
|
}
|
|
|
|
export function resolveGmailHookRuntimeConfig(
|
|
cfg: OpenClawConfig,
|
|
overrides: GmailHookOverrides,
|
|
): { ok: true; value: GmailHookRuntimeConfig } | { ok: false; error: string } {
|
|
const hooks = cfg.hooks;
|
|
const gmail = hooks?.gmail;
|
|
const hookToken = overrides.hookToken ?? hooks?.token ?? "";
|
|
if (!hookToken) {
|
|
return { ok: false, error: "hooks.token missing (needed for gmail hook)" };
|
|
}
|
|
|
|
const account = overrides.account ?? gmail?.account ?? "";
|
|
if (!account) {
|
|
return { ok: false, error: "gmail account required" };
|
|
}
|
|
|
|
const topic = overrides.topic ?? gmail?.topic ?? "";
|
|
if (!topic) {
|
|
return { ok: false, error: "gmail topic required" };
|
|
}
|
|
|
|
const subscription = overrides.subscription ?? gmail?.subscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
|
|
|
|
const pushToken = overrides.pushToken ?? gmail?.pushToken ?? "";
|
|
if (!pushToken) {
|
|
return { ok: false, error: "gmail push token required" };
|
|
}
|
|
|
|
const hookUrl =
|
|
overrides.hookUrl ??
|
|
gmail?.hookUrl ??
|
|
buildDefaultHookUrl(hooks?.path, resolveGatewayPort(cfg));
|
|
|
|
const includeBody = overrides.includeBody ?? gmail?.includeBody ?? true;
|
|
|
|
const maxBytesRaw = overrides.maxBytes ?? gmail?.maxBytes;
|
|
const maxBytes =
|
|
typeof maxBytesRaw === "number" && Number.isFinite(maxBytesRaw) && maxBytesRaw > 0
|
|
? Math.floor(maxBytesRaw)
|
|
: DEFAULT_GMAIL_MAX_BYTES;
|
|
|
|
const renewEveryMinutesRaw = overrides.renewEveryMinutes ?? gmail?.renewEveryMinutes;
|
|
const renewEveryMinutes =
|
|
typeof renewEveryMinutesRaw === "number" &&
|
|
Number.isFinite(renewEveryMinutesRaw) &&
|
|
renewEveryMinutesRaw > 0
|
|
? Math.floor(renewEveryMinutesRaw)
|
|
: DEFAULT_GMAIL_RENEW_MINUTES;
|
|
|
|
const serveBind = overrides.serveBind ?? gmail?.serve?.bind ?? DEFAULT_GMAIL_SERVE_BIND;
|
|
const servePortRaw = overrides.servePort ?? gmail?.serve?.port;
|
|
const servePort =
|
|
typeof servePortRaw === "number" && Number.isFinite(servePortRaw) && servePortRaw > 0
|
|
? Math.floor(servePortRaw)
|
|
: DEFAULT_GMAIL_SERVE_PORT;
|
|
const servePathRaw = overrides.servePath ?? gmail?.serve?.path;
|
|
const normalizedServePathRaw =
|
|
typeof servePathRaw === "string" && servePathRaw.trim().length > 0
|
|
? normalizeServePath(servePathRaw)
|
|
: DEFAULT_GMAIL_SERVE_PATH;
|
|
const tailscaleTargetRaw = overrides.tailscaleTarget ?? gmail?.tailscale?.target;
|
|
|
|
const tailscaleMode = overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
|
|
const tailscaleTarget =
|
|
tailscaleMode !== "off" &&
|
|
typeof tailscaleTargetRaw === "string" &&
|
|
tailscaleTargetRaw.trim().length > 0
|
|
? tailscaleTargetRaw.trim()
|
|
: undefined;
|
|
// Tailscale strips the public path before proxying, so listen on "/" when on.
|
|
const servePath = normalizeServePath(
|
|
tailscaleMode !== "off" && !tailscaleTarget ? "/" : normalizedServePathRaw,
|
|
);
|
|
|
|
const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path;
|
|
const tailscalePath = normalizeServePath(
|
|
tailscaleMode !== "off"
|
|
? (tailscalePathRaw ?? normalizedServePathRaw)
|
|
: (tailscalePathRaw ?? servePath),
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
account,
|
|
label: overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL,
|
|
topic,
|
|
subscription,
|
|
pushToken,
|
|
hookToken,
|
|
hookUrl,
|
|
includeBody,
|
|
maxBytes,
|
|
renewEveryMinutes,
|
|
serve: {
|
|
bind: serveBind,
|
|
port: servePort,
|
|
path: servePath,
|
|
},
|
|
tailscale: {
|
|
mode: tailscaleMode,
|
|
path: tailscalePath,
|
|
target: tailscaleTarget,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildGogWatchStartArgs(
|
|
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
|
): string[] {
|
|
return [
|
|
"gmail",
|
|
"watch",
|
|
"start",
|
|
"--account",
|
|
cfg.account,
|
|
"--label",
|
|
cfg.label,
|
|
"--topic",
|
|
cfg.topic,
|
|
];
|
|
}
|
|
|
|
export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
|
|
const args = [
|
|
"gmail",
|
|
"watch",
|
|
"serve",
|
|
"--account",
|
|
cfg.account,
|
|
"--bind",
|
|
cfg.serve.bind,
|
|
"--port",
|
|
String(cfg.serve.port),
|
|
"--path",
|
|
cfg.serve.path,
|
|
"--token",
|
|
cfg.pushToken,
|
|
"--hook-url",
|
|
cfg.hookUrl,
|
|
"--hook-token",
|
|
cfg.hookToken,
|
|
];
|
|
if (cfg.includeBody) {
|
|
args.push("--include-body");
|
|
}
|
|
if (cfg.maxBytes > 0) {
|
|
args.push("--max-bytes", String(cfg.maxBytes));
|
|
}
|
|
return args;
|
|
}
|
|
|
|
export function buildGogWatchServeLogArgs(cfg: GmailHookRuntimeConfig): string[] {
|
|
return buildGogWatchServeArgs(cfg).filter(
|
|
(arg, index, args) =>
|
|
!GMAIL_WATCH_SENSITIVE_FLAGS.has(arg) &&
|
|
!GMAIL_WATCH_SENSITIVE_FLAGS.has(args[index - 1] ?? ""),
|
|
);
|
|
}
|
|
|
|
export function resolveGogExecutable(): string {
|
|
return (gogBin ??= resolveExecutable("gog"));
|
|
}
|
|
|
|
function escapeForCmdExe(arg: string): string {
|
|
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
|
|
throw new Error(`Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
|
|
}
|
|
if (!arg.includes(" ") && !arg.includes('"')) {
|
|
return arg;
|
|
}
|
|
return `"${arg.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
export function resolveGogServeInvocation(args: string[]): {
|
|
args: string[];
|
|
command: string;
|
|
windowsHide?: true;
|
|
windowsVerbatimArguments?: true;
|
|
} {
|
|
const command = resolveGogExecutable();
|
|
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
|
|
if (process.platform !== "win32" || (ext !== ".cmd" && ext !== ".bat")) {
|
|
return { command, args, windowsHide: process.platform === "win32" ? true : undefined };
|
|
}
|
|
const cmdExe = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
|
|
return {
|
|
command: cmdExe,
|
|
args: ["/d", "/s", "/c", [command, ...args].map(escapeForCmdExe).join(" ")],
|
|
windowsHide: true,
|
|
windowsVerbatimArguments: true,
|
|
};
|
|
}
|
|
|
|
export function buildTopicPath(projectId: string, topicName: string): string {
|
|
return `projects/${projectId}/topics/${topicName}`;
|
|
}
|
|
|
|
export function parseTopicPath(topic: string): { projectId: string; topicName: string } | null {
|
|
const match = topic.trim().match(/^projects\/([^/]+)\/topics\/([^/]+)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return { projectId: match[1] ?? "", topicName: match[2] ?? "" };
|
|
}
|
|
|
|
function joinUrl(base: string, path: string): string {
|
|
const url = new URL(base);
|
|
const basePath = url.pathname.replace(/\/+$/, "");
|
|
const extra = path.startsWith("/") ? path : `/${path}`;
|
|
url.pathname = `${basePath}${extra}`;
|
|
return url.toString();
|
|
}
|