Files
clawdbot/src/hooks/gmail.ts
2026-01-03 12:35:23 +00:00

273 lines
7.4 KiB
TypeScript

import { randomBytes } from "node:crypto";
import {
type ClawdisConfig,
DEFAULT_GATEWAY_PORT,
type HooksGmailTailscaleMode,
resolveGatewayPort,
} from "../config/config.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;
export const DEFAULT_HOOKS_PATH = "/hooks";
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;
};
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;
};
};
export function generateHookToken(bytes = 24): string {
return randomBytes(bytes).toString("hex");
}
export function mergeHookPresets(
existing: string[] | undefined,
preset: string,
): string[] {
const next = new Set(
(existing ?? []).map((item) => item.trim()).filter(Boolean),
);
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: ClawdisConfig,
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 hasExplicitServePath =
typeof servePathRaw === "string" && servePathRaw.trim().length > 0;
const tailscaleMode =
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
// When exposing the push endpoint via Tailscale, the public path is stripped
// before proxying; use "/" internally unless the user set a path explicitly.
const servePath = normalizeServePath(
tailscaleMode !== "off" && !hasExplicitServePath ? "/" : servePathRaw,
);
const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path;
const tailscalePath = normalizeServePath(
tailscaleMode !== "off" && !tailscalePathRaw
? hasExplicitServePath
? servePathRaw
: DEFAULT_GMAIL_SERVE_PATH
: (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,
},
},
};
}
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 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();
}