feat: add gmail hooks wizard
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- Custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
|
||||
|
||||
### Breaking
|
||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||
|
||||
@@ -318,13 +318,27 @@ Enable a simple HTTP webhook surface on the Gateway HTTP server.
|
||||
Defaults:
|
||||
- enabled: `false`
|
||||
- path: `/hooks`
|
||||
- maxBodyBytes: `262144` (256 KB)
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "shared-secret",
|
||||
path: "/hooks"
|
||||
path: "/hooks",
|
||||
presets: ["gmail"],
|
||||
transformsDir: "~/.clawdis/hooks",
|
||||
mappings: [
|
||||
{
|
||||
match: { path: "gmail" },
|
||||
action: "agent",
|
||||
wakeMode: "now",
|
||||
name: "Gmail",
|
||||
sessionKey: "hook:gmail:{{messages[0].id}}",
|
||||
messageTemplate:
|
||||
"From: {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -337,9 +351,37 @@ Requests must include the hook token:
|
||||
Endpoints:
|
||||
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
|
||||
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
|
||||
- `POST /hooks/<name>` → resolved via `hooks.mappings`
|
||||
|
||||
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
|
||||
|
||||
Mapping notes:
|
||||
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
|
||||
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
|
||||
- Templates like `{{messages[0].subject}}` read from the payload.
|
||||
- `transform` can point to a JS/TS module that returns a hook action.
|
||||
|
||||
Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
account: "clawdbot@gmail.com",
|
||||
topic: "projects/<project-id>/topics/gog-gmail-watch",
|
||||
subscription: "gog-gmail-watch-push",
|
||||
pushToken: "shared-push-token",
|
||||
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
|
||||
includeBody: true,
|
||||
maxBytes: 20000,
|
||||
renewEveryMinutes: 720,
|
||||
serve: { bind: "127.0.0.1", port: 8788, path: "/gmail-pubsub" },
|
||||
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
|
||||
|
||||
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.
|
||||
|
||||
179
docs/gmail-pubsub.md
Normal file
179
docs/gmail-pubsub.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
summary: "Gmail Pub/Sub push wired into Clawdis webhooks via gogcli"
|
||||
read_when:
|
||||
- Wiring Gmail inbox triggers to Clawdis
|
||||
- Setting up Pub/Sub push for agent wake
|
||||
---
|
||||
|
||||
# Gmail Pub/Sub -> Clawdis
|
||||
|
||||
Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdis webhook.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- `gcloud` installed and logged in.
|
||||
- `gog` (gogcli) installed and authorized for the Gmail account.
|
||||
- Clawdis hooks enabled (see `docs/webhook.md`).
|
||||
- `tailscale` logged in if you want a public HTTPS endpoint via Funnel.
|
||||
|
||||
Example hook config (enable Gmail preset mapping):
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "CLAWDIS_HOOK_TOKEN",
|
||||
path: "/hooks",
|
||||
presets: ["gmail"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To customize payload handling, add `hooks.mappings` or a JS/TS transform module
|
||||
under `hooks.transformsDir` (see `docs/webhook.md`).
|
||||
|
||||
## Wizard (recommended)
|
||||
|
||||
Use the Clawdis helper to wire everything together (installs deps on macOS via brew):
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail setup \
|
||||
--account clawdbot@gmail.com
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- Uses Tailscale Funnel for the public push endpoint.
|
||||
- Writes `hooks.gmail` config for `clawdis hooks gmail run`.
|
||||
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
|
||||
|
||||
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
|
||||
|
||||
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
|
||||
via Homebrew; on Linux install them manually first.
|
||||
|
||||
Run the daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail run
|
||||
```
|
||||
|
||||
## One-time setup
|
||||
|
||||
1) Select the GCP project **that owns the OAuth client** used by `gog`.
|
||||
|
||||
```bash
|
||||
gcloud auth login
|
||||
gcloud config set project <project-id>
|
||||
```
|
||||
|
||||
Note: Gmail watch requires the Pub/Sub topic to live in the same project as the OAuth client.
|
||||
|
||||
2) Enable APIs:
|
||||
|
||||
```bash
|
||||
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
|
||||
```
|
||||
|
||||
3) Create a topic:
|
||||
|
||||
```bash
|
||||
gcloud pubsub topics create gog-gmail-watch
|
||||
```
|
||||
|
||||
4) Allow Gmail push to publish:
|
||||
|
||||
```bash
|
||||
gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \
|
||||
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
|
||||
--role=roles/pubsub.publisher
|
||||
```
|
||||
|
||||
## Start the watch
|
||||
|
||||
```bash
|
||||
gog gmail watch start \
|
||||
--account clawdbot@gmail.com \
|
||||
--label INBOX \
|
||||
--topic projects/<project-id>/topics/gog-gmail-watch
|
||||
```
|
||||
|
||||
Save the `history_id` from the output (for debugging).
|
||||
|
||||
## Run the push handler
|
||||
|
||||
Local example (shared token auth):
|
||||
|
||||
```bash
|
||||
gog gmail watch serve \
|
||||
--account clawdbot@gmail.com \
|
||||
--bind 127.0.0.1 \
|
||||
--port 8788 \
|
||||
--path /gmail-pubsub \
|
||||
--token <shared> \
|
||||
--hook-url http://127.0.0.1:18789/hooks/gmail \
|
||||
--hook-token CLAWDIS_HOOK_TOKEN \
|
||||
--include-body \
|
||||
--max-bytes 20000
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--token` protects the push endpoint (`x-gog-token` or `?token=`).
|
||||
- `--hook-url` points to Clawdis `/hooks/gmail` (mapped; isolated run + summary to main).
|
||||
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdis.
|
||||
|
||||
Recommended: `clawdis hooks gmail run` wraps the same flow and auto-renews the watch.
|
||||
|
||||
## Expose the handler (dev)
|
||||
|
||||
For local testing, tunnel the handler and use the public URL in the push subscription:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel --url http://127.0.0.1:8788 --no-autoupdate
|
||||
```
|
||||
|
||||
Use the generated URL as the push endpoint:
|
||||
|
||||
```bash
|
||||
gcloud pubsub subscriptions create gog-gmail-watch-push \
|
||||
--topic gog-gmail-watch \
|
||||
--push-endpoint "https://<public-url>/gmail-pubsub?token=<shared>"
|
||||
```
|
||||
|
||||
Production: use a stable HTTPS endpoint and configure Pub/Sub OIDC JWT, then run:
|
||||
|
||||
```bash
|
||||
gog gmail watch serve --verify-oidc --oidc-email <svc@...>
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
Send a message to the watched inbox:
|
||||
|
||||
```bash
|
||||
gog gmail send \
|
||||
--account clawdbot@gmail.com \
|
||||
--to clawdbot@gmail.com \
|
||||
--subject "watch test" \
|
||||
--body "ping"
|
||||
```
|
||||
|
||||
Check watch state and history:
|
||||
|
||||
```bash
|
||||
gog gmail watch status --account clawdbot@gmail.com
|
||||
gog gmail history --account clawdbot@gmail.com --since <historyId>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Invalid topicName`: project mismatch (topic not in the OAuth client project).
|
||||
- `User not authorized`: missing `roles/pubsub.publisher` on the topic.
|
||||
- Empty messages: Gmail push only provides `historyId`; fetch via `gog gmail history`.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
gog gmail watch stop --account clawdbot@gmail.com
|
||||
gcloud pubsub subscriptions delete gog-gmail-watch-push
|
||||
gcloud pubsub topics delete gog-gmail-watch
|
||||
```
|
||||
@@ -80,6 +80,20 @@ Effect:
|
||||
- Always posts a summary into the **main** session
|
||||
- If `wakeMode=now`, triggers an immediate heartbeat
|
||||
|
||||
### `POST /hooks/<name>` (mapped)
|
||||
|
||||
Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can
|
||||
turn arbitrary payloads into `wake` or `agent` actions, with optional templates or
|
||||
code transforms.
|
||||
|
||||
Mapping options (summary):
|
||||
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
|
||||
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
|
||||
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
|
||||
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
||||
- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
|
||||
- `clawdis hooks gmail setup` writes `hooks.gmail` config for `clawdis hooks gmail run`.
|
||||
|
||||
## Responses
|
||||
|
||||
- `200` for `/hooks/wake`
|
||||
@@ -104,6 +118,13 @@ curl -X POST http://127.0.0.1:18789/hooks/agent \
|
||||
-d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/hooks/gmail \
|
||||
-H 'Authorization: Bearer SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
|
||||
182
src/cli/hooks-cli.ts
Normal file
182
src/cli/hooks-cli.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { danger } from "../globals.js";
|
||||
import {
|
||||
DEFAULT_GMAIL_LABEL,
|
||||
DEFAULT_GMAIL_MAX_BYTES,
|
||||
DEFAULT_GMAIL_RENEW_MINUTES,
|
||||
DEFAULT_GMAIL_SERVE_BIND,
|
||||
DEFAULT_GMAIL_SERVE_PATH,
|
||||
DEFAULT_GMAIL_SERVE_PORT,
|
||||
DEFAULT_GMAIL_SUBSCRIPTION,
|
||||
DEFAULT_GMAIL_TOPIC,
|
||||
} from "../hooks/gmail.js";
|
||||
import {
|
||||
type GmailRunOptions,
|
||||
type GmailSetupOptions,
|
||||
runGmailService,
|
||||
runGmailSetup,
|
||||
} from "../hooks/gmail-ops.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
export function registerHooksCli(program: Command) {
|
||||
const hooks = program
|
||||
.command("hooks")
|
||||
.description("Webhook helpers and hook-based integrations");
|
||||
|
||||
const gmail = hooks
|
||||
.command("gmail")
|
||||
.description("Gmail Pub/Sub hooks (via gogcli)");
|
||||
|
||||
gmail
|
||||
.command("setup")
|
||||
.description("Configure Gmail watch + Pub/Sub + Clawdis hooks")
|
||||
.requiredOption("--account <email>", "Gmail account to watch")
|
||||
.option("--project <id>", "GCP project id (OAuth client owner)")
|
||||
.option("--topic <name>", "Pub/Sub topic name", DEFAULT_GMAIL_TOPIC)
|
||||
.option(
|
||||
"--subscription <name>",
|
||||
"Pub/Sub subscription name",
|
||||
DEFAULT_GMAIL_SUBSCRIPTION,
|
||||
)
|
||||
.option("--label <label>", "Gmail label to watch", DEFAULT_GMAIL_LABEL)
|
||||
.option("--hook-url <url>", "Clawdis hook URL")
|
||||
.option("--hook-token <token>", "Clawdis hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option(
|
||||
"--bind <host>",
|
||||
"gog watch serve bind host",
|
||||
DEFAULT_GMAIL_SERVE_BIND,
|
||||
)
|
||||
.option(
|
||||
"--port <port>",
|
||||
"gog watch serve port",
|
||||
String(DEFAULT_GMAIL_SERVE_PORT),
|
||||
)
|
||||
.option("--path <path>", "gog watch serve path", DEFAULT_GMAIL_SERVE_PATH)
|
||||
.option("--include-body", "Include email body snippets", true)
|
||||
.option(
|
||||
"--max-bytes <n>",
|
||||
"Max bytes for body snippets",
|
||||
String(DEFAULT_GMAIL_MAX_BYTES),
|
||||
)
|
||||
.option(
|
||||
"--renew-minutes <n>",
|
||||
"Renew watch every N minutes",
|
||||
String(DEFAULT_GMAIL_RENEW_MINUTES),
|
||||
)
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
"Expose push endpoint via tailscale (funnel|serve|off)",
|
||||
"funnel",
|
||||
)
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.option("--push-endpoint <url>", "Explicit Pub/Sub push endpoint")
|
||||
.option("--json", "Output JSON summary", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const parsed = parseGmailSetupOptions(opts);
|
||||
await runGmailSetup(parsed);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
gmail
|
||||
.command("run")
|
||||
.description("Run gog watch serve + auto-renew loop")
|
||||
.option("--account <email>", "Gmail account to watch")
|
||||
.option("--topic <topic>", "Pub/Sub topic path (projects/.../topics/..)")
|
||||
.option("--subscription <name>", "Pub/Sub subscription name")
|
||||
.option("--label <label>", "Gmail label to watch")
|
||||
.option("--hook-url <url>", "Clawdis hook URL")
|
||||
.option("--hook-token <token>", "Clawdis hook token")
|
||||
.option("--push-token <token>", "Push token for gog watch serve")
|
||||
.option("--bind <host>", "gog watch serve bind host")
|
||||
.option("--port <port>", "gog watch serve port")
|
||||
.option("--path <path>", "gog watch serve path")
|
||||
.option("--include-body", "Include email body snippets")
|
||||
.option("--max-bytes <n>", "Max bytes for body snippets")
|
||||
.option("--renew-minutes <n>", "Renew watch every N minutes")
|
||||
.option(
|
||||
"--tailscale <mode>",
|
||||
"Expose push endpoint via tailscale (funnel|serve|off)",
|
||||
)
|
||||
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const parsed = parseGmailRunOptions(opts);
|
||||
await runGmailService(parsed);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseGmailSetupOptions(
|
||||
raw: Record<string, unknown>,
|
||||
): GmailSetupOptions {
|
||||
const accountRaw = raw.account;
|
||||
const account = typeof accountRaw === "string" ? accountRaw.trim() : "";
|
||||
if (!account) throw new Error("--account is required");
|
||||
return {
|
||||
account,
|
||||
project: stringOption(raw.project),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
pushEndpoint: stringOption(raw.pushEndpoint),
|
||||
json: Boolean(raw.json),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
|
||||
return {
|
||||
account: stringOption(raw.account),
|
||||
topic: stringOption(raw.topic),
|
||||
subscription: stringOption(raw.subscription),
|
||||
label: stringOption(raw.label),
|
||||
hookUrl: stringOption(raw.hookUrl),
|
||||
hookToken: stringOption(raw.hookToken),
|
||||
pushToken: stringOption(raw.pushToken),
|
||||
bind: stringOption(raw.bind),
|
||||
port: numberOption(raw.port),
|
||||
path: stringOption(raw.path),
|
||||
includeBody: booleanOption(raw.includeBody),
|
||||
maxBytes: numberOption(raw.maxBytes),
|
||||
renewEveryMinutes: numberOption(raw.renewMinutes),
|
||||
tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"],
|
||||
tailscalePath: stringOption(raw.tailscalePath),
|
||||
};
|
||||
}
|
||||
|
||||
function stringOption(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function numberOption(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function booleanOption(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return Boolean(value);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { registerCronCli } from "./cron-cli.js";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
import { registerDnsCli } from "./dns-cli.js";
|
||||
import { registerGatewayCli } from "./gateway-cli.js";
|
||||
import { registerHooksCli } from "./hooks-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
|
||||
@@ -240,6 +241,7 @@ Examples:
|
||||
registerNodesCli(program);
|
||||
registerCronCli(program);
|
||||
registerDnsCli(program);
|
||||
registerHooksCli(program);
|
||||
|
||||
program
|
||||
.command("status")
|
||||
|
||||
351
src/hooks/gmail-ops.ts
Normal file
351
src/hooks/gmail-ops.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import {
|
||||
type ClawdisConfig,
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
buildDefaultHookUrl,
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
buildTopicPath,
|
||||
DEFAULT_GMAIL_LABEL,
|
||||
DEFAULT_GMAIL_MAX_BYTES,
|
||||
DEFAULT_GMAIL_RENEW_MINUTES,
|
||||
DEFAULT_GMAIL_SERVE_BIND,
|
||||
DEFAULT_GMAIL_SERVE_PATH,
|
||||
DEFAULT_GMAIL_SERVE_PORT,
|
||||
DEFAULT_GMAIL_SUBSCRIPTION,
|
||||
DEFAULT_GMAIL_TOPIC,
|
||||
type GmailHookOverrides,
|
||||
type GmailHookRuntimeConfig,
|
||||
generateHookToken,
|
||||
mergeHookPresets,
|
||||
normalizeHooksPath,
|
||||
normalizeServePath,
|
||||
parseTopicPath,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
import {
|
||||
ensureDependency,
|
||||
ensureGcloudAuth,
|
||||
ensureSubscription,
|
||||
ensureTailscaleEndpoint,
|
||||
ensureTopic,
|
||||
resolveProjectIdFromGogCredentials,
|
||||
runGcloud,
|
||||
} from "./gmail-setup-utils.js";
|
||||
|
||||
export type GmailSetupOptions = {
|
||||
account: string;
|
||||
project?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
label?: string;
|
||||
hookToken?: string;
|
||||
pushToken?: string;
|
||||
hookUrl?: string;
|
||||
bind?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
includeBody?: boolean;
|
||||
maxBytes?: number;
|
||||
renewEveryMinutes?: number;
|
||||
tailscale?: "off" | "serve" | "funnel";
|
||||
tailscalePath?: string;
|
||||
pushEndpoint?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type GmailRunOptions = {
|
||||
account?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
label?: string;
|
||||
hookToken?: string;
|
||||
pushToken?: string;
|
||||
hookUrl?: string;
|
||||
bind?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
includeBody?: boolean;
|
||||
maxBytes?: number;
|
||||
renewEveryMinutes?: number;
|
||||
tailscale?: "off" | "serve" | "funnel";
|
||||
tailscalePath?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_GMAIL_TOPIC_IAM_MEMBER =
|
||||
"serviceAccount:gmail-api-push@system.gserviceaccount.com";
|
||||
|
||||
export async function runGmailSetup(opts: GmailSetupOptions) {
|
||||
await ensureDependency("gcloud", ["--cask", "gcloud-cli"]);
|
||||
await ensureDependency("gog", ["gogcli"]);
|
||||
if (opts.tailscale !== "off" && !opts.pushEndpoint) {
|
||||
await ensureDependency("tailscale", ["tailscale"]);
|
||||
}
|
||||
|
||||
await ensureGcloudAuth();
|
||||
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
if (!configSnapshot.valid) {
|
||||
throw new Error(`Config invalid: ${CONFIG_PATH_CLAWDIS}`);
|
||||
}
|
||||
|
||||
const baseConfig = configSnapshot.config;
|
||||
const hooksPath = normalizeHooksPath(baseConfig.hooks?.path);
|
||||
const hookToken =
|
||||
opts.hookToken ?? baseConfig.hooks?.token ?? generateHookToken();
|
||||
const pushToken =
|
||||
opts.pushToken ?? baseConfig.hooks?.gmail?.pushToken ?? generateHookToken();
|
||||
|
||||
const topicInput =
|
||||
opts.topic ?? baseConfig.hooks?.gmail?.topic ?? DEFAULT_GMAIL_TOPIC;
|
||||
const parsedTopic = parseTopicPath(topicInput);
|
||||
const topicName = parsedTopic?.topicName ?? topicInput;
|
||||
|
||||
const projectId =
|
||||
opts.project ??
|
||||
parsedTopic?.projectId ??
|
||||
(await resolveProjectIdFromGogCredentials());
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"GCP project id required (use --project or ensure gog credentials are available)",
|
||||
);
|
||||
}
|
||||
|
||||
const topicPath = buildTopicPath(projectId, topicName);
|
||||
|
||||
const subscription = opts.subscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
|
||||
const label = opts.label ?? DEFAULT_GMAIL_LABEL;
|
||||
const hookUrl =
|
||||
opts.hookUrl ??
|
||||
baseConfig.hooks?.gmail?.hookUrl ??
|
||||
buildDefaultHookUrl(hooksPath);
|
||||
|
||||
const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND;
|
||||
const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT;
|
||||
const servePath = normalizeServePath(opts.path ?? DEFAULT_GMAIL_SERVE_PATH);
|
||||
|
||||
const includeBody = opts.includeBody ?? true;
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_GMAIL_MAX_BYTES;
|
||||
const renewEveryMinutes =
|
||||
opts.renewEveryMinutes ?? DEFAULT_GMAIL_RENEW_MINUTES;
|
||||
|
||||
const tailscaleMode = opts.tailscale ?? "funnel";
|
||||
const tailscalePath = normalizeServePath(opts.tailscalePath ?? servePath);
|
||||
|
||||
await runGcloud(["config", "set", "project", projectId, "--quiet"]);
|
||||
await runGcloud([
|
||||
"services",
|
||||
"enable",
|
||||
"gmail.googleapis.com",
|
||||
"pubsub.googleapis.com",
|
||||
"--project",
|
||||
projectId,
|
||||
"--quiet",
|
||||
]);
|
||||
|
||||
await ensureTopic(projectId, topicName);
|
||||
await runGcloud([
|
||||
"pubsub",
|
||||
"topics",
|
||||
"add-iam-policy-binding",
|
||||
topicName,
|
||||
"--project",
|
||||
projectId,
|
||||
"--member",
|
||||
DEFAULT_GMAIL_TOPIC_IAM_MEMBER,
|
||||
"--role",
|
||||
"roles/pubsub.publisher",
|
||||
"--quiet",
|
||||
]);
|
||||
|
||||
const pushEndpoint = opts.pushEndpoint
|
||||
? opts.pushEndpoint
|
||||
: await ensureTailscaleEndpoint({
|
||||
mode: tailscaleMode,
|
||||
path: tailscalePath,
|
||||
port: servePort,
|
||||
token: pushToken,
|
||||
});
|
||||
|
||||
if (!pushEndpoint) {
|
||||
throw new Error("push endpoint required (set --push-endpoint)");
|
||||
}
|
||||
|
||||
await ensureSubscription(projectId, subscription, topicName, pushEndpoint);
|
||||
|
||||
await startGmailWatch(
|
||||
{
|
||||
account: opts.account,
|
||||
label,
|
||||
topic: topicPath,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const nextConfig: ClawdisConfig = {
|
||||
...baseConfig,
|
||||
hooks: {
|
||||
...baseConfig.hooks,
|
||||
enabled: true,
|
||||
path: hooksPath,
|
||||
token: hookToken,
|
||||
presets: mergeHookPresets(baseConfig.hooks?.presets, "gmail"),
|
||||
gmail: {
|
||||
...baseConfig.hooks?.gmail,
|
||||
account: opts.account,
|
||||
label,
|
||||
topic: topicPath,
|
||||
subscription,
|
||||
pushToken,
|
||||
hookUrl,
|
||||
includeBody,
|
||||
maxBytes,
|
||||
renewEveryMinutes,
|
||||
serve: {
|
||||
...baseConfig.hooks?.gmail?.serve,
|
||||
bind: serveBind,
|
||||
port: servePort,
|
||||
path: servePath,
|
||||
},
|
||||
tailscale: {
|
||||
...baseConfig.hooks?.gmail?.tailscale,
|
||||
mode: tailscaleMode,
|
||||
path: tailscalePath,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfigObject(nextConfig);
|
||||
if (!validated.ok) {
|
||||
throw new Error(
|
||||
`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`,
|
||||
);
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
|
||||
const summary = {
|
||||
projectId,
|
||||
topic: topicPath,
|
||||
subscription,
|
||||
pushEndpoint,
|
||||
hookUrl,
|
||||
hookToken,
|
||||
pushToken,
|
||||
serve: {
|
||||
bind: serveBind,
|
||||
port: servePort,
|
||||
path: servePath,
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
defaultRuntime.log("Gmail hooks configured:");
|
||||
defaultRuntime.log(`- project: ${projectId}`);
|
||||
defaultRuntime.log(`- topic: ${topicPath}`);
|
||||
defaultRuntime.log(`- subscription: ${subscription}`);
|
||||
defaultRuntime.log(`- push endpoint: ${pushEndpoint}`);
|
||||
defaultRuntime.log(`- hook url: ${hookUrl}`);
|
||||
defaultRuntime.log(`- config: ${CONFIG_PATH_CLAWDIS}`);
|
||||
defaultRuntime.log("Next: clawdis hooks gmail run");
|
||||
}
|
||||
|
||||
export async function runGmailService(opts: GmailRunOptions) {
|
||||
await ensureDependency("gog", ["gogcli"]);
|
||||
const config = loadConfig();
|
||||
|
||||
const overrides: GmailHookOverrides = {
|
||||
account: opts.account,
|
||||
topic: opts.topic,
|
||||
subscription: opts.subscription,
|
||||
label: opts.label,
|
||||
hookToken: opts.hookToken,
|
||||
pushToken: opts.pushToken,
|
||||
hookUrl: opts.hookUrl,
|
||||
serveBind: opts.bind,
|
||||
servePort: opts.port,
|
||||
servePath: opts.path,
|
||||
includeBody: opts.includeBody,
|
||||
maxBytes: opts.maxBytes,
|
||||
renewEveryMinutes: opts.renewEveryMinutes,
|
||||
tailscaleMode: opts.tailscale,
|
||||
tailscalePath: opts.tailscalePath,
|
||||
};
|
||||
|
||||
const resolved = resolveGmailHookRuntimeConfig(config, overrides);
|
||||
if (!resolved.ok) {
|
||||
throw new Error(resolved.error);
|
||||
}
|
||||
|
||||
const runtimeConfig = resolved.value;
|
||||
|
||||
if (runtimeConfig.tailscale.mode !== "off") {
|
||||
await ensureDependency("tailscale", ["tailscale"]);
|
||||
await ensureTailscaleEndpoint({
|
||||
mode: runtimeConfig.tailscale.mode,
|
||||
path: runtimeConfig.tailscale.path,
|
||||
port: runtimeConfig.serve.port,
|
||||
});
|
||||
}
|
||||
|
||||
await startGmailWatch(runtimeConfig);
|
||||
|
||||
let shuttingDown = false;
|
||||
let child = spawnGogServe(runtimeConfig);
|
||||
|
||||
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
|
||||
const renewTimer = setInterval(() => {
|
||||
void startGmailWatch(runtimeConfig);
|
||||
}, renewMs);
|
||||
|
||||
const shutdown = () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearInterval(renewTimer);
|
||||
child.kill("SIGTERM");
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
child.on("exit", () => {
|
||||
if (shuttingDown) return;
|
||||
defaultRuntime.log("gog watch serve exited; restarting in 2s");
|
||||
setTimeout(() => {
|
||||
if (shuttingDown) return;
|
||||
child = spawnGogServe(runtimeConfig);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
defaultRuntime.log(`Starting gog ${args.join(" ")}`);
|
||||
return spawn("gog", args, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
async function startGmailWatch(
|
||||
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
||||
fatal = false,
|
||||
) {
|
||||
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
|
||||
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
|
||||
if (result.code !== 0) {
|
||||
const message = result.stderr || result.stdout || "gog watch start failed";
|
||||
if (fatal) throw new Error(message);
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
}
|
||||
239
src/hooks/gmail-setup-utils.ts
Normal file
239
src/hooks/gmail-setup-utils.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { hasBinary } from "../agents/skills.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { normalizeServePath } from "./gmail.js";
|
||||
|
||||
export async function ensureDependency(bin: string, brewArgs: string[]) {
|
||||
if (hasBinary(bin)) return;
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error(`${bin} not installed; install it and retry`);
|
||||
}
|
||||
if (!hasBinary("brew")) {
|
||||
throw new Error("Homebrew not installed (install brew and retry)");
|
||||
}
|
||||
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`brew install failed for ${bin}: ${result.stderr || result.stdout}`,
|
||||
);
|
||||
}
|
||||
if (!hasBinary(bin)) {
|
||||
throw new Error(`${bin} still not available after brew install`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureGcloudAuth() {
|
||||
const res = await runCommandWithTimeout(
|
||||
[
|
||||
"gcloud",
|
||||
"auth",
|
||||
"list",
|
||||
"--filter",
|
||||
"status:ACTIVE",
|
||||
"--format",
|
||||
"value(account)",
|
||||
],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
if (res.code === 0 && res.stdout.trim()) return;
|
||||
const login = await runCommandWithTimeout(["gcloud", "auth", "login"], {
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
if (login.code !== 0) {
|
||||
throw new Error(login.stderr || "gcloud auth login failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGcloud(args: string[]) {
|
||||
const result = await runCommandWithTimeout(["gcloud", ...args], {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "gcloud command failed");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ensureTopic(projectId: string, topicName: string) {
|
||||
const describe = await runCommandWithTimeout(
|
||||
[
|
||||
"gcloud",
|
||||
"pubsub",
|
||||
"topics",
|
||||
"describe",
|
||||
topicName,
|
||||
"--project",
|
||||
projectId,
|
||||
],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
if (describe.code === 0) return;
|
||||
await runGcloud([
|
||||
"pubsub",
|
||||
"topics",
|
||||
"create",
|
||||
topicName,
|
||||
"--project",
|
||||
projectId,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function ensureSubscription(
|
||||
projectId: string,
|
||||
subscription: string,
|
||||
topicName: string,
|
||||
pushEndpoint: string,
|
||||
) {
|
||||
const describe = await runCommandWithTimeout(
|
||||
[
|
||||
"gcloud",
|
||||
"pubsub",
|
||||
"subscriptions",
|
||||
"describe",
|
||||
subscription,
|
||||
"--project",
|
||||
projectId,
|
||||
],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
if (describe.code === 0) {
|
||||
await runGcloud([
|
||||
"pubsub",
|
||||
"subscriptions",
|
||||
"update",
|
||||
subscription,
|
||||
"--project",
|
||||
projectId,
|
||||
"--push-endpoint",
|
||||
pushEndpoint,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await runGcloud([
|
||||
"pubsub",
|
||||
"subscriptions",
|
||||
"create",
|
||||
subscription,
|
||||
"--project",
|
||||
projectId,
|
||||
"--topic",
|
||||
topicName,
|
||||
"--push-endpoint",
|
||||
pushEndpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function ensureTailscaleEndpoint(params: {
|
||||
mode: "off" | "serve" | "funnel";
|
||||
path: string;
|
||||
port: number;
|
||||
token?: string;
|
||||
}): Promise<string> {
|
||||
if (params.mode === "off") return "";
|
||||
|
||||
const status = await runCommandWithTimeout(
|
||||
["tailscale", "status", "--json"],
|
||||
{
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
throw new Error(status.stderr || "tailscale status failed");
|
||||
}
|
||||
const parsed = JSON.parse(status.stdout) as {
|
||||
Self?: { DNSName?: string };
|
||||
};
|
||||
const dnsName = parsed.Self?.DNSName?.replace(/\.$/, "");
|
||||
if (!dnsName) {
|
||||
throw new Error("tailscale DNS name missing; run tailscale up");
|
||||
}
|
||||
|
||||
const target = String(params.port);
|
||||
const pathArg = normalizeServePath(params.path);
|
||||
const funnelArgs = [
|
||||
"tailscale",
|
||||
params.mode,
|
||||
"--bg",
|
||||
"--set-path",
|
||||
pathArg,
|
||||
"--yes",
|
||||
target,
|
||||
];
|
||||
const funnelResult = await runCommandWithTimeout(funnelArgs, {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (funnelResult.code !== 0) {
|
||||
throw new Error(funnelResult.stderr || "tailscale funnel failed");
|
||||
}
|
||||
|
||||
const baseUrl = `https://${dnsName}${pathArg}`;
|
||||
return params.token ? `${baseUrl}?token=${params.token}` : baseUrl;
|
||||
}
|
||||
|
||||
export async function resolveProjectIdFromGogCredentials(): Promise<
|
||||
string | null
|
||||
> {
|
||||
const candidates = gogCredentialsPaths();
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const clientId = extractGogClientId(parsed);
|
||||
const projectNumber = extractProjectNumber(clientId);
|
||||
if (!projectNumber) continue;
|
||||
const res = await runCommandWithTimeout(
|
||||
[
|
||||
"gcloud",
|
||||
"projects",
|
||||
"list",
|
||||
"--filter",
|
||||
`projectNumber=${projectNumber}`,
|
||||
"--format",
|
||||
"value(projectId)",
|
||||
],
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
if (res.code !== 0) continue;
|
||||
const projectId = res.stdout.trim().split(/\s+/)[0];
|
||||
if (projectId) return projectId;
|
||||
} catch {
|
||||
// keep scanning
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function gogCredentialsPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const xdg = process.env.XDG_CONFIG_HOME;
|
||||
if (xdg) {
|
||||
paths.push(path.join(xdg, "gogcli", "credentials.json"));
|
||||
}
|
||||
paths.push(resolveUserPath("~/.config/gogcli/credentials.json"));
|
||||
if (process.platform === "darwin") {
|
||||
paths.push(
|
||||
resolveUserPath("~/Library/Application Support/gogcli/credentials.json"),
|
||||
);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function extractGogClientId(parsed: Record<string, unknown>): string | null {
|
||||
const installed = parsed.installed as Record<string, unknown> | undefined;
|
||||
const web = parsed.web as Record<string, unknown> | undefined;
|
||||
const candidate =
|
||||
installed?.client_id || web?.client_id || parsed.client_id || "";
|
||||
return typeof candidate === "string" ? candidate : null;
|
||||
}
|
||||
|
||||
function extractProjectNumber(clientId: string | null): string | null {
|
||||
if (!clientId) return null;
|
||||
const match = clientId.match(/^(\d+)-/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
63
src/hooks/gmail.test.ts
Normal file
63
src/hooks/gmail.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
buildDefaultHookUrl,
|
||||
buildTopicPath,
|
||||
parseTopicPath,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
|
||||
const baseConfig = {
|
||||
hooks: {
|
||||
token: "hook-token",
|
||||
gmail: {
|
||||
account: "clawdbot@gmail.com",
|
||||
topic: "projects/demo/topics/gog-gmail-watch",
|
||||
pushToken: "push-token",
|
||||
},
|
||||
},
|
||||
} satisfies ClawdisConfig;
|
||||
|
||||
describe("gmail hook config", () => {
|
||||
it("builds default hook url", () => {
|
||||
expect(buildDefaultHookUrl("/hooks")).toBe(
|
||||
"http://127.0.0.1:18789/hooks/gmail",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses topic path", () => {
|
||||
const topic = buildTopicPath("proj", "topic");
|
||||
expect(parseTopicPath(topic)).toEqual({
|
||||
projectId: "proj",
|
||||
topicName: "topic",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves runtime config with defaults", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.account).toBe("clawdbot@gmail.com");
|
||||
expect(result.value.label).toBe("INBOX");
|
||||
expect(result.value.includeBody).toBe(true);
|
||||
expect(result.value.serve.port).toBe(8788);
|
||||
expect(result.value.hookUrl).toBe("http://127.0.0.1:18789/hooks/gmail");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails without hook token", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
account: "clawdbot@gmail.com",
|
||||
topic: "projects/demo/topics/gog-gmail-watch",
|
||||
pushToken: "push-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
251
src/hooks/gmail.ts
Normal file
251
src/hooks/gmail.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import type {
|
||||
ClawdisConfig,
|
||||
HooksGmailTailscaleMode,
|
||||
} 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 const DEFAULT_HOOKS_BASE_URL = "http://127.0.0.1:18789";
|
||||
|
||||
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;
|
||||
const withSlash = base.startsWith("/") ? base : `/${base}`;
|
||||
return withSlash.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function buildDefaultHookUrl(hooksPath?: string): string {
|
||||
const basePath = normalizeHooksPath(hooksPath);
|
||||
return joinUrl(DEFAULT_HOOKS_BASE_URL, `${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);
|
||||
|
||||
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 servePath = normalizeServePath(
|
||||
overrides.servePath ?? gmail?.serve?.path,
|
||||
);
|
||||
|
||||
const tailscaleMode =
|
||||
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
|
||||
const tailscalePath = normalizeServePath(
|
||||
overrides.tailscalePath ?? gmail?.tailscale?.path ?? 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();
|
||||
}
|
||||
Reference in New Issue
Block a user