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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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:
|
Defaults:
|
||||||
- enabled: `false`
|
- enabled: `false`
|
||||||
- path: `/hooks`
|
- path: `/hooks`
|
||||||
|
- maxBodyBytes: `262144` (256 KB)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "shared-secret",
|
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:
|
Endpoints:
|
||||||
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
|
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
|
||||||
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
|
- `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"`).
|
`/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)
|
### `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.
|
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
|
- Always posts a summary into the **main** session
|
||||||
- If `wakeMode=now`, triggers an immediate heartbeat
|
- 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
|
## Responses
|
||||||
|
|
||||||
- `200` for `/hooks/wake`
|
- `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"}'
|
-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
|
## Security
|
||||||
|
|
||||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
- 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 { createDefaultDeps } from "./deps.js";
|
||||||
import { registerDnsCli } from "./dns-cli.js";
|
import { registerDnsCli } from "./dns-cli.js";
|
||||||
import { registerGatewayCli } from "./gateway-cli.js";
|
import { registerGatewayCli } from "./gateway-cli.js";
|
||||||
|
import { registerHooksCli } from "./hooks-cli.js";
|
||||||
import { registerNodesCli } from "./nodes-cli.js";
|
import { registerNodesCli } from "./nodes-cli.js";
|
||||||
import { forceFreePort } from "./ports.js";
|
import { forceFreePort } from "./ports.js";
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ Examples:
|
|||||||
registerNodesCli(program);
|
registerNodesCli(program);
|
||||||
registerCronCli(program);
|
registerCronCli(program);
|
||||||
registerDnsCli(program);
|
registerDnsCli(program);
|
||||||
|
registerHooksCli(program);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.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