mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(hooks): add Gmail Pub/Sub pull delivery mode
This commit is contained in:
@@ -325,7 +325,7 @@ Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
Wire Gmail inbox triggers to OpenClaw via Google PubSub.
|
||||
|
||||
<Note>
|
||||
**Prerequisites:** `gcloud` CLI, `gog` (gogcli), OpenClaw hooks enabled, Tailscale for the public HTTPS endpoint.
|
||||
**Prerequisites:** `gcloud` CLI, `gog` (gogcli), and OpenClaw hooks enabled. The `openclaw webhooks gmail setup` helper still configures the Pub/Sub push flow, which also needs Tailscale or another HTTPS push endpoint.
|
||||
</Note>
|
||||
|
||||
### Wizard setup (recommended)
|
||||
@@ -338,7 +338,7 @@ This writes `hooks.gmail` config, enables the Gmail preset, and uses Tailscale F
|
||||
|
||||
### Gateway auto-start
|
||||
|
||||
When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `gog gmail watch serve` on boot and auto-renews the watch. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out.
|
||||
When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts the configured `gog gmail watch` delivery runner on boot and auto-renews the watch. Push configs start `gog gmail watch serve`; pull configs start `gog gmail watch pull`. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out.
|
||||
|
||||
### Manual one-time setup
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ openclaw webhooks gmail setup --account <email> [...]
|
||||
openclaw webhooks gmail run [--account <email>] [...]
|
||||
```
|
||||
|
||||
| Subcommand | Description |
|
||||
| ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `gmail setup` | Configure Gmail watch, Pub/Sub topic/subscription, and the OpenClaw webhook delivery target. |
|
||||
| `gmail run` | Run `gog watch serve` plus the watch auto-renew loop. |
|
||||
| Subcommand | Description |
|
||||
| ------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `gmail setup` | Configure the existing Gmail Pub/Sub push flow and the OpenClaw webhook delivery target. |
|
||||
| `gmail run` | Run the configured `gog gmail watch` delivery process plus the watch auto-renew loop. |
|
||||
|
||||
## `webhooks gmail setup`
|
||||
|
||||
Configure Gmail watch, Pub/Sub, and OpenClaw webhook delivery.
|
||||
Configure Gmail watch, Pub/Sub push, and OpenClaw webhook delivery.
|
||||
|
||||
```bash
|
||||
openclaw webhooks gmail setup --account you@example.com
|
||||
@@ -83,17 +83,18 @@ openclaw webhooks gmail setup --account you@example.com --hook-url https://gatew
|
||||
|
||||
## `webhooks gmail run`
|
||||
|
||||
Run `gog watch serve` plus the watch auto-renew loop in the foreground.
|
||||
Run the configured `gog gmail watch` delivery process plus the watch auto-renew loop in the foreground. Push configs start `gog gmail watch serve`; pull configs start `gog gmail watch pull`.
|
||||
|
||||
```bash
|
||||
openclaw webhooks gmail run --account you@example.com
|
||||
```
|
||||
|
||||
`run` accepts the same `gog watch serve`, OpenClaw delivery, Pub/Sub, and Tailscale flags as `setup`, except:
|
||||
`run` accepts the same push-oriented `gog watch serve`, OpenClaw delivery, Pub/Sub, and Tailscale flags as `setup`, except:
|
||||
|
||||
- `--account` is **optional** on `run` (it falls back to the configured account).
|
||||
- `run` does **not** accept `--project`, `--push-endpoint`, or `--json`.
|
||||
- `run` flags have no built-in defaults; missing values fall back to the values written by `setup`.
|
||||
- Pull mode is selected from `hooks.gmail.delivery.mode` in config. Use `hooks.gmail.delivery.subscription` for the full pull subscription path.
|
||||
|
||||
| Category | Flags |
|
||||
| ----------------- | -------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -771,14 +771,14 @@ Validation and safety notes:
|
||||
gmail: {
|
||||
account: "openclaw@gmail.com",
|
||||
topic: "projects/<project-id>/topics/gog-gmail-watch",
|
||||
subscription: "gog-gmail-watch-push",
|
||||
pushToken: "shared-push-token",
|
||||
delivery: {
|
||||
mode: "pull",
|
||||
subscription: "projects/<project-id>/subscriptions/gog-gmail-watch",
|
||||
},
|
||||
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
|
||||
includeBody: true,
|
||||
maxBytes: 20000,
|
||||
renewEveryMinutes: 720,
|
||||
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
|
||||
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
thinking: "off",
|
||||
},
|
||||
@@ -786,8 +786,34 @@ Validation and safety notes:
|
||||
}
|
||||
```
|
||||
|
||||
- Gateway auto-starts `gog gmail watch serve` on boot when configured. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to disable.
|
||||
- Don't run a separate `gog gmail watch serve` alongside the Gateway.
|
||||
- Pull delivery starts `gog gmail watch pull` on boot. It consumes a Pub/Sub pull subscription from the gateway host and posts processed Gmail notifications into the local OpenClaw hook endpoint. It does not need `pushToken`, `serve`, `tailscale`, or a public HTTP callback URL.
|
||||
- Pull delivery requires `delivery.subscription` to be a full Pub/Sub subscription resource path: `projects/<project-id>/subscriptions/<subscription>`.
|
||||
- Do not run a separate `gog gmail watch pull` against the same subscription alongside the Gateway.
|
||||
- Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to disable the Gateway-managed Gmail watcher.
|
||||
|
||||
Push delivery is still supported for existing deployments and explicit HTTP ingress setups. Omit `delivery.mode` to preserve existing push behavior, or set it explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
account: "openclaw@gmail.com",
|
||||
topic: "projects/<project-id>/topics/gog-gmail-watch",
|
||||
delivery: {
|
||||
mode: "push",
|
||||
subscription: "gog-gmail-watch-push",
|
||||
},
|
||||
pushToken: "shared-push-token",
|
||||
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
|
||||
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
|
||||
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Push delivery starts `gog gmail watch serve` and requires a Pub/Sub push subscription to reach the callback endpoint.
|
||||
- `openclaw webhooks gmail setup` remains push-oriented in this release. Pull-mode cloud setup is a later slice after pull delivery is dogfooded.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function registerWebhooksCli(program: Command) {
|
||||
|
||||
gmail
|
||||
.command("setup")
|
||||
.description("Configure Gmail watch + Pub/Sub + OpenClaw hooks")
|
||||
.description("Configure Gmail watch + Pub/Sub push + OpenClaw 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)
|
||||
@@ -78,10 +78,10 @@ export function registerWebhooksCli(program: Command) {
|
||||
|
||||
gmail
|
||||
.command("run")
|
||||
.description("Run gog watch serve + auto-renew loop")
|
||||
.description("Run configured gog gmail watch delivery + 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("--subscription <name>", "Pub/Sub subscription name or full path for pull configs")
|
||||
.option("--label <label>", "Gmail label to watch")
|
||||
.option("--hook-url <url>", "OpenClaw hook URL")
|
||||
.option("--hook-token <token>", "OpenClaw hook token")
|
||||
|
||||
@@ -242,6 +242,7 @@ const TARGET_KEYS = [
|
||||
"hooks.mappings[].channel",
|
||||
"hooks.mappings[].transform.module",
|
||||
"hooks.gmail",
|
||||
"hooks.gmail.delivery.mode",
|
||||
"hooks.gmail.pushToken",
|
||||
"hooks.gmail.tailscale.mode",
|
||||
"hooks.gmail.thinking",
|
||||
@@ -443,6 +444,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
||||
"broadcast.strategy": ['"parallel"', '"sequential"'],
|
||||
"hooks.mappings[].action": ['"wake"', '"agent"'],
|
||||
"hooks.mappings[].wakeMode": ['"now"', '"next-heartbeat"'],
|
||||
"hooks.gmail.delivery.mode": ['"push"', '"pull"'],
|
||||
"hooks.gmail.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
|
||||
"hooks.gmail.thinking": ['"off"', '"minimal"', '"low"', '"medium"', '"high"'],
|
||||
"messages.queue.mode": ['"steer"', '"followup"', '"collect"', '"interrupt"'],
|
||||
@@ -478,6 +480,9 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
||||
const TOOLS_HOOKS_TARGET_KEYS = [
|
||||
"hooks.gmail.account",
|
||||
"hooks.gmail.allowUnsafeExternalContent",
|
||||
"hooks.gmail.delivery",
|
||||
"hooks.gmail.delivery.mode",
|
||||
"hooks.gmail.delivery.subscription",
|
||||
"hooks.gmail.hookUrl",
|
||||
"hooks.gmail.includeBody",
|
||||
"hooks.gmail.label",
|
||||
|
||||
@@ -1805,7 +1805,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"hooks.mappings[].transform.export":
|
||||
"Named export to invoke from the transform module; defaults to module default export when omitted. Set this when one file hosts multiple transform handlers.",
|
||||
"hooks.gmail":
|
||||
"Gmail push integration settings used for Pub/Sub notifications and optional local callback serving. Keep this scoped to dedicated Gmail automation accounts where possible.",
|
||||
"Gmail integration settings used for Pub/Sub change notifications and local hook delivery. Keep this scoped to dedicated Gmail automation accounts where possible.",
|
||||
"hooks.gmail.account":
|
||||
"Google account identifier used for Gmail watch/subscription operations in this hook integration. Use a dedicated automation mailbox account to isolate operational permissions.",
|
||||
"hooks.gmail.label":
|
||||
@@ -1813,9 +1813,15 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"hooks.gmail.topic":
|
||||
"Google Pub/Sub topic name used by Gmail watch to publish change notifications for this account. Ensure the topic IAM grants Gmail publish access before enabling watches.",
|
||||
"hooks.gmail.subscription":
|
||||
"Pub/Sub subscription consumed by the gateway to receive Gmail change notifications from the configured topic. Keep subscription ownership clear so multiple consumers do not race unexpectedly.",
|
||||
"Pub/Sub subscription name used by the Gmail integration. Push mode may use a short subscription name; pull mode should use delivery.subscription with a full Pub/Sub path.",
|
||||
"hooks.gmail.delivery":
|
||||
"Gmail Pub/Sub delivery mode configuration. Use pull for no-inbound local consumption, or push only when operating an intentional HTTP ingress path.",
|
||||
"hooks.gmail.delivery.mode":
|
||||
'Gmail Pub/Sub delivery mode. Use "pull" to start a local Pub/Sub consumer with no inbound HTTP endpoint, or "push" to start the existing HTTP callback server. Omitted mode preserves existing push behavior.',
|
||||
"hooks.gmail.delivery.subscription":
|
||||
"Full Pub/Sub subscription path used by pull mode, for example projects/<project>/subscriptions/<subscription>. Do not share one pull subscription between independent consumers.",
|
||||
"hooks.gmail.hookUrl":
|
||||
"Public callback URL Gmail or intermediaries invoke to deliver notifications into this hook pipeline. Keep this URL protected with token validation and restricted network exposure.",
|
||||
"OpenClaw hook URL invoked by the Gmail watcher after notification processing. Pull mode normally uses a loopback URL; push mode may use the same local hook behind the callback server.",
|
||||
"hooks.gmail.includeBody":
|
||||
"When true, fetch and include email body content for downstream mapping/agent processing. Keep false unless body text is required, because this increases payload size and sensitivity.",
|
||||
"hooks.gmail.allowUnsafeExternalContent":
|
||||
|
||||
@@ -913,6 +913,9 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"hooks.gmail.label": "Gmail Hook Label",
|
||||
"hooks.gmail.topic": "Gmail Hook Pub/Sub Topic",
|
||||
"hooks.gmail.subscription": "Gmail Hook Subscription",
|
||||
"hooks.gmail.delivery": "Gmail Hook Delivery",
|
||||
"hooks.gmail.delivery.mode": "Gmail Hook Delivery Mode",
|
||||
"hooks.gmail.delivery.subscription": "Gmail Hook Pull Subscription",
|
||||
"hooks.gmail.pushToken": "Gmail Hook Push Token",
|
||||
"hooks.gmail.hookUrl": "Gmail Hook Callback URL",
|
||||
"hooks.gmail.includeBody": "Gmail Hook Include Body",
|
||||
|
||||
@@ -37,12 +37,21 @@ export type HookMappingConfig = {
|
||||
};
|
||||
|
||||
export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";
|
||||
export type HooksGmailDeliveryMode = "push" | "pull";
|
||||
|
||||
export type HooksGmailConfig = {
|
||||
account?: string;
|
||||
label?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
delivery?: {
|
||||
mode?: HooksGmailDeliveryMode;
|
||||
/**
|
||||
* Pull mode requires a full Pub/Sub resource path:
|
||||
* projects/<project>/subscriptions/<subscription>.
|
||||
*/
|
||||
subscription?: string;
|
||||
};
|
||||
pushToken?: string;
|
||||
hookUrl?: string;
|
||||
includeBody?: boolean;
|
||||
|
||||
@@ -116,6 +116,13 @@ export const HooksGmailSchema = z
|
||||
label: z.string().optional(),
|
||||
topic: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
delivery: z
|
||||
.object({
|
||||
mode: z.union([z.literal("push"), z.literal("pull")]).optional(),
|
||||
subscription: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
pushToken: z.string().optional().register(sensitive),
|
||||
hookUrl: z.string().optional(),
|
||||
includeBody: z.boolean().optional(),
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from "./gmail-setup-utils.js";
|
||||
import {
|
||||
buildDefaultHookUrl,
|
||||
buildGogWatchPullArgs,
|
||||
buildGogWatchPullLogArgs,
|
||||
buildGogWatchServeLogArgs,
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
type GmailHookOverrides,
|
||||
type GmailHookRuntimeConfig,
|
||||
generateHookToken,
|
||||
isGmailHookPushRuntimeConfig,
|
||||
mergeHookPresets,
|
||||
normalizeHooksPath,
|
||||
normalizeServePath,
|
||||
@@ -305,7 +308,7 @@ export async function runGmailService(opts: GmailRunOptions) {
|
||||
|
||||
const runtimeConfig = resolved.value;
|
||||
|
||||
if (runtimeConfig.tailscale.mode !== "off") {
|
||||
if (isGmailHookPushRuntimeConfig(runtimeConfig) && runtimeConfig.tailscale.mode !== "off") {
|
||||
await ensureDependency("tailscale", ["tailscale"]);
|
||||
await ensureTailscaleEndpoint({
|
||||
mode: runtimeConfig.tailscale.mode,
|
||||
@@ -318,7 +321,7 @@ export async function runGmailService(opts: GmailRunOptions) {
|
||||
await startGmailWatch(runtimeConfig);
|
||||
|
||||
let shuttingDown = false;
|
||||
let child = spawnGogServe(runtimeConfig);
|
||||
let child = spawnGogRunner(runtimeConfig);
|
||||
|
||||
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
|
||||
const renewTimer = setInterval(() => {
|
||||
@@ -348,19 +351,21 @@ export async function runGmailService(opts: GmailRunOptions) {
|
||||
detachSignals();
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log("gog watch serve exited; restarting in 2s");
|
||||
defaultRuntime.log(`gog watch ${runtimeConfig.delivery.mode} exited; restarting in 2s`);
|
||||
setTimeout(() => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
child = spawnGogServe(runtimeConfig);
|
||||
child = spawnGogRunner(runtimeConfig);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
defaultRuntime.log(`Starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
|
||||
function spawnGogRunner(cfg: GmailHookRuntimeConfig) {
|
||||
const pushMode = isGmailHookPushRuntimeConfig(cfg);
|
||||
const args = pushMode ? buildGogWatchServeArgs(cfg) : buildGogWatchPullArgs(cfg);
|
||||
const logArgs = pushMode ? buildGogWatchServeLogArgs(cfg) : buildGogWatchPullLogArgs(cfg);
|
||||
defaultRuntime.log(`Starting gog ${logArgs.join(" ")}`);
|
||||
const invocation = resolveGogServeInvocation(args);
|
||||
return spawn(invocation.command, invocation.args, {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -45,6 +45,23 @@ function createGmailConfig(account = "me@example.com") {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createGmailPullConfig(account = "me@example.com") {
|
||||
return {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "hook-token",
|
||||
gmail: {
|
||||
account,
|
||||
topic: "projects/demo/topics/gmail",
|
||||
delivery: {
|
||||
mode: "pull",
|
||||
subscription: "projects/demo/subscriptions/gmail-pull",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function deferredCommandResult() {
|
||||
let resolve!: (result: { code: number; stdout: string; stderr: string }) => void;
|
||||
const promise = new Promise<{ code: number; stdout: string; stderr: string }>((settle) => {
|
||||
@@ -72,6 +89,35 @@ describe("startGmailWatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts gog pull delivery without Tailscale setup", async () => {
|
||||
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
|
||||
|
||||
await expect(startGmailWatcher(createGmailPullConfig())).resolves.toEqual({
|
||||
started: true,
|
||||
});
|
||||
|
||||
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.spawn).toHaveBeenCalledTimes(1);
|
||||
const spawnArgs = mocks.spawn.mock.calls[0]?.[1] as string[];
|
||||
expect(spawnArgs).toContain("pull");
|
||||
expect(spawnArgs).not.toContain("serve");
|
||||
expect(spawnArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
"gmail",
|
||||
"watch",
|
||||
"pull",
|
||||
"--account",
|
||||
"me@example.com",
|
||||
"--subscription",
|
||||
"projects/demo/subscriptions/gmail-pull",
|
||||
"--hook-url",
|
||||
"http://127.0.0.1:18789/hooks/gmail",
|
||||
"--hook-token",
|
||||
"hook-token",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a stale cancelled startup clear newer watcher config", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Gmail Watcher Service
|
||||
*
|
||||
* Automatically starts `gog gmail watch serve` when the gateway starts,
|
||||
* Automatically starts the configured `gog gmail watch` runner when the gateway starts,
|
||||
* if hooks.gmail is configured with an account.
|
||||
*/
|
||||
|
||||
@@ -13,10 +13,13 @@ import { hasBinary } from "../skills/loading/config.js";
|
||||
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
|
||||
import { isAddressInUseError } from "./gmail-watcher-errors.js";
|
||||
import {
|
||||
buildGogWatchPullArgs,
|
||||
buildGogWatchPullLogArgs,
|
||||
buildGogWatchServeLogArgs,
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
type GmailHookRuntimeConfig,
|
||||
isGmailHookPushRuntimeConfig,
|
||||
resolveGogExecutable,
|
||||
resolveGogServeInvocation,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
@@ -64,11 +67,13 @@ async function startGmailWatch(
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the gog gmail watch serve process
|
||||
* Spawn the gog gmail watch serve/pull process
|
||||
*/
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
log.info(`starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
|
||||
function spawnGogWatcherProcess(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
const pushMode = isGmailHookPushRuntimeConfig(cfg);
|
||||
const args = pushMode ? buildGogWatchServeArgs(cfg) : buildGogWatchPullArgs(cfg);
|
||||
const logArgs = pushMode ? buildGogWatchServeLogArgs(cfg) : buildGogWatchPullLogArgs(cfg);
|
||||
log.info(`starting gog ${logArgs.join(" ")}`);
|
||||
let addressInUse = false;
|
||||
const invocation = resolveGogServeInvocation(args);
|
||||
|
||||
@@ -91,7 +96,7 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
if (isAddressInUseError(line)) {
|
||||
if (pushMode && isAddressInUseError(line)) {
|
||||
addressInUse = true;
|
||||
}
|
||||
log.warn(`[gog] ${line}`);
|
||||
@@ -124,7 +129,7 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
if (shuttingDown || !currentConfig) {
|
||||
return;
|
||||
}
|
||||
watcherProcess = spawnGogServe(currentConfig);
|
||||
watcherProcess = spawnGogWatcherProcess(currentConfig);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
@@ -299,7 +304,7 @@ export async function startGmailWatcher(
|
||||
const oldProcess = watcherProcess;
|
||||
watcherProcess = null;
|
||||
await settleProcess(oldProcess);
|
||||
// Remove lingering spawnGogServe listeners so a late exit (after the
|
||||
// Remove lingering watcher listeners so a late exit (after the
|
||||
// settleProcess timeout) cannot trigger a duplicate respawn while
|
||||
// watcherProcess is null and shuttingDown is false.
|
||||
oldProcess.removeAllListeners();
|
||||
@@ -308,7 +313,7 @@ export async function startGmailWatcher(
|
||||
}
|
||||
|
||||
// Set up Tailscale endpoint if needed
|
||||
if (runtimeConfig.tailscale.mode !== "off") {
|
||||
if (isGmailHookPushRuntimeConfig(runtimeConfig) && runtimeConfig.tailscale.mode !== "off") {
|
||||
const cancellation = createGmailWatcherCancellation(options);
|
||||
try {
|
||||
await ensureTailscaleEndpoint({
|
||||
@@ -354,7 +359,7 @@ export async function startGmailWatcher(
|
||||
return cancelledGmailWatcherStart(runtimeConfig);
|
||||
}
|
||||
shuttingDown = false;
|
||||
watcherProcess = spawnGogServe(runtimeConfig);
|
||||
watcherProcess = spawnGogWatcherProcess(runtimeConfig);
|
||||
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
|
||||
renewInterval = setInterval(() => {
|
||||
if (shuttingDown) {
|
||||
@@ -364,7 +369,7 @@ export async function startGmailWatcher(
|
||||
}, renewMs);
|
||||
|
||||
log.info(
|
||||
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
|
||||
`gmail watcher started for ${runtimeConfig.account} (${runtimeConfig.delivery.mode}, renew every ${runtimeConfig.renewEveryMinutes}m)`,
|
||||
);
|
||||
|
||||
return { started: true };
|
||||
|
||||
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { type OpenClawConfig, DEFAULT_GATEWAY_PORT } from "../config/config.js";
|
||||
import {
|
||||
buildDefaultHookUrl,
|
||||
buildGogWatchPullLogArgs,
|
||||
buildGogWatchServeLogArgs,
|
||||
buildTopicPath,
|
||||
parseSubscriptionPath,
|
||||
parseTopicPath,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
@@ -48,6 +50,10 @@ describe("gmail hook config", () => {
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value.delivery.mode).toBe("push");
|
||||
if (result.value.delivery.mode !== "push") {
|
||||
return;
|
||||
}
|
||||
expect(result.value.serve.path).toBe(expected.servePath);
|
||||
expect(result.value.tailscale.path).toBe(expected.publicPath);
|
||||
if (expected.target !== undefined) {
|
||||
@@ -69,24 +75,93 @@ describe("gmail hook config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses subscription path", () => {
|
||||
expect(parseSubscriptionPath("projects/proj/subscriptions/sub")).toEqual({
|
||||
projectId: "proj",
|
||||
subscriptionName: "sub",
|
||||
});
|
||||
expect(parseSubscriptionPath("sub")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves runtime config with defaults", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.account).toBe("openclaw@gmail.com");
|
||||
expect(result.value.delivery.mode).toBe("push");
|
||||
expect(result.value.label).toBe("INBOX");
|
||||
expect(result.value.includeBody).toBe(true);
|
||||
if (result.value.delivery.mode !== "push") {
|
||||
return;
|
||||
}
|
||||
expect(result.value.serve.port).toBe(8788);
|
||||
expect(result.value.hookUrl).toBe(`http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/hooks/gmail`);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves pull delivery without push token or callback server settings", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(
|
||||
{
|
||||
hooks: {
|
||||
token: "hook-token",
|
||||
gmail: {
|
||||
account: "openclaw@gmail.com",
|
||||
topic: "projects/demo/topics/gog-gmail-watch",
|
||||
delivery: {
|
||||
mode: "pull",
|
||||
subscription: "projects/demo/subscriptions/gog-gmail-watch",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value.delivery).toEqual({
|
||||
mode: "pull",
|
||||
subscription: "projects/demo/subscriptions/gog-gmail-watch",
|
||||
});
|
||||
expect("pushToken" in result.value).toBe(false);
|
||||
expect("serve" in result.value).toBe(false);
|
||||
expect("tailscale" in result.value).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a full Pub/Sub subscription path for pull delivery", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(
|
||||
{
|
||||
hooks: {
|
||||
token: "hook-token",
|
||||
gmail: {
|
||||
account: "openclaw@gmail.com",
|
||||
topic: "projects/demo/topics/gog-gmail-watch",
|
||||
subscription: "gog-gmail-watch",
|
||||
delivery: { mode: "pull" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("full Pub/Sub path");
|
||||
}
|
||||
});
|
||||
|
||||
it("builds watch serve log args without secrets", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value.delivery.mode).toBe("push");
|
||||
if (result.value.delivery.mode !== "push") {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = buildGogWatchServeLogArgs(result.value);
|
||||
expect(args).not.toContain("push-token");
|
||||
@@ -112,6 +187,50 @@ describe("gmail hook config", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds watch pull log args without hook secrets", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(
|
||||
{
|
||||
hooks: {
|
||||
token: "hook-token",
|
||||
gmail: {
|
||||
account: "openclaw@gmail.com",
|
||||
topic: "projects/demo/topics/gog-gmail-watch",
|
||||
delivery: {
|
||||
mode: "pull",
|
||||
subscription: "projects/demo/subscriptions/gog-gmail-watch",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value.delivery.mode).toBe("pull");
|
||||
if (result.value.delivery.mode !== "pull") {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = buildGogWatchPullLogArgs(result.value);
|
||||
expect(args).not.toContain("hook-token");
|
||||
expect(args).not.toContain("--hook-token");
|
||||
expect(args).not.toContain("--hook-url");
|
||||
expect(args).toEqual([
|
||||
"gmail",
|
||||
"watch",
|
||||
"pull",
|
||||
"--account",
|
||||
"openclaw@gmail.com",
|
||||
"--subscription",
|
||||
"projects/demo/subscriptions/gog-gmail-watch",
|
||||
"--include-body",
|
||||
"--max-bytes",
|
||||
"20000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails without hook token", () => {
|
||||
const result = resolveGmailHookRuntimeConfig(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
|
||||
import {
|
||||
type OpenClawConfig,
|
||||
DEFAULT_GATEWAY_PORT,
|
||||
type HooksGmailDeliveryMode,
|
||||
type HooksGmailTailscaleMode,
|
||||
resolveGatewayPort,
|
||||
} from "../config/config.js";
|
||||
@@ -27,6 +28,7 @@ let gogBin: string | undefined;
|
||||
|
||||
export type GmailHookOverrides = {
|
||||
account?: string;
|
||||
deliveryMode?: HooksGmailDeliveryMode;
|
||||
label?: string;
|
||||
topic?: string;
|
||||
subscription?: string;
|
||||
@@ -44,17 +46,24 @@ export type GmailHookOverrides = {
|
||||
tailscaleTarget?: string;
|
||||
};
|
||||
|
||||
export type GmailHookRuntimeConfig = {
|
||||
export type GmailHookBaseRuntimeConfig = {
|
||||
account: string;
|
||||
label: string;
|
||||
topic: string;
|
||||
subscription: string;
|
||||
pushToken: string;
|
||||
hookToken: string;
|
||||
hookUrl: string;
|
||||
includeBody: boolean;
|
||||
maxBytes: number;
|
||||
renewEveryMinutes: number;
|
||||
};
|
||||
|
||||
export type GmailHookPushRuntimeConfig = GmailHookBaseRuntimeConfig & {
|
||||
delivery: {
|
||||
mode: "push";
|
||||
subscription: string;
|
||||
};
|
||||
pushToken: string;
|
||||
serve: {
|
||||
bind: string;
|
||||
port: number;
|
||||
@@ -67,6 +76,27 @@ export type GmailHookRuntimeConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GmailHookPullRuntimeConfig = GmailHookBaseRuntimeConfig & {
|
||||
delivery: {
|
||||
mode: "pull";
|
||||
subscription: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GmailHookRuntimeConfig = GmailHookPushRuntimeConfig | GmailHookPullRuntimeConfig;
|
||||
|
||||
export function isGmailHookPushRuntimeConfig(
|
||||
cfg: GmailHookRuntimeConfig,
|
||||
): cfg is GmailHookPushRuntimeConfig {
|
||||
return cfg.delivery.mode === "push";
|
||||
}
|
||||
|
||||
export function isGmailHookPullRuntimeConfig(
|
||||
cfg: GmailHookRuntimeConfig,
|
||||
): cfg is GmailHookPullRuntimeConfig {
|
||||
return cfg.delivery.mode === "pull";
|
||||
}
|
||||
|
||||
export function generateHookToken(bytes = 24): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
@@ -127,12 +157,9 @@ export function resolveGmailHookRuntimeConfig(
|
||||
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 deliveryMode = overrides.deliveryMode ?? gmail?.delivery?.mode ?? "push";
|
||||
const configuredSubscription =
|
||||
overrides.subscription ?? gmail?.delivery?.subscription ?? gmail?.subscription;
|
||||
|
||||
const hookUrl =
|
||||
overrides.hookUrl ??
|
||||
@@ -155,6 +182,50 @@ export function resolveGmailHookRuntimeConfig(
|
||||
? Math.floor(renewEveryMinutesRaw)
|
||||
: DEFAULT_GMAIL_RENEW_MINUTES;
|
||||
|
||||
const label = overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL;
|
||||
const baseRuntimeConfig = {
|
||||
account,
|
||||
label,
|
||||
topic,
|
||||
hookToken,
|
||||
hookUrl,
|
||||
includeBody,
|
||||
maxBytes,
|
||||
renewEveryMinutes,
|
||||
};
|
||||
|
||||
if (deliveryMode === "pull") {
|
||||
if (!configuredSubscription) {
|
||||
return { ok: false, error: "gmail pull subscription required" };
|
||||
}
|
||||
const subscriptionPath = parseSubscriptionPath(configuredSubscription);
|
||||
if (!subscriptionPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"gmail pull subscription must be a full Pub/Sub path (projects/<project>/subscriptions/<subscription>)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...baseRuntimeConfig,
|
||||
subscription: configuredSubscription,
|
||||
delivery: {
|
||||
mode: "pull",
|
||||
subscription: configuredSubscription,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const subscription = configuredSubscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
|
||||
|
||||
const pushToken = overrides.pushToken ?? gmail?.pushToken ?? "";
|
||||
if (!pushToken) {
|
||||
return { ok: false, error: "gmail push token required" };
|
||||
}
|
||||
|
||||
const serveBind = overrides.serveBind ?? gmail?.serve?.bind ?? DEFAULT_GMAIL_SERVE_BIND;
|
||||
const servePortRaw = overrides.servePort ?? gmail?.serve?.port;
|
||||
const servePort =
|
||||
@@ -190,16 +261,13 @@ export function resolveGmailHookRuntimeConfig(
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
account,
|
||||
label: overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL,
|
||||
topic,
|
||||
...baseRuntimeConfig,
|
||||
subscription,
|
||||
delivery: {
|
||||
mode: "push",
|
||||
subscription,
|
||||
},
|
||||
pushToken,
|
||||
hookToken,
|
||||
hookUrl,
|
||||
includeBody,
|
||||
maxBytes,
|
||||
renewEveryMinutes,
|
||||
serve: {
|
||||
bind: serveBind,
|
||||
port: servePort,
|
||||
@@ -215,7 +283,7 @@ export function resolveGmailHookRuntimeConfig(
|
||||
}
|
||||
|
||||
export function buildGogWatchStartArgs(
|
||||
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
||||
cfg: Pick<GmailHookBaseRuntimeConfig, "account" | "label" | "topic">,
|
||||
): string[] {
|
||||
return [
|
||||
"gmail",
|
||||
@@ -230,7 +298,7 @@ export function buildGogWatchStartArgs(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
|
||||
export function buildGogWatchServeArgs(cfg: GmailHookPushRuntimeConfig): string[] {
|
||||
const args = [
|
||||
"gmail",
|
||||
"watch",
|
||||
@@ -259,14 +327,45 @@ export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildGogWatchServeLogArgs(cfg: GmailHookRuntimeConfig): string[] {
|
||||
return buildGogWatchServeArgs(cfg).filter(
|
||||
export function buildGogWatchPullArgs(cfg: GmailHookPullRuntimeConfig): string[] {
|
||||
const args = [
|
||||
"gmail",
|
||||
"watch",
|
||||
"pull",
|
||||
"--account",
|
||||
cfg.account,
|
||||
"--subscription",
|
||||
cfg.delivery.subscription,
|
||||
"--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;
|
||||
}
|
||||
|
||||
function removeGogWatchSensitiveArgs(args: string[]): string[] {
|
||||
return args.filter(
|
||||
(arg, index, args) =>
|
||||
!GMAIL_WATCH_SENSITIVE_FLAGS.has(arg) &&
|
||||
!GMAIL_WATCH_SENSITIVE_FLAGS.has(args[index - 1] ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGogWatchServeLogArgs(cfg: GmailHookPushRuntimeConfig): string[] {
|
||||
return removeGogWatchSensitiveArgs(buildGogWatchServeArgs(cfg));
|
||||
}
|
||||
|
||||
export function buildGogWatchPullLogArgs(cfg: GmailHookPullRuntimeConfig): string[] {
|
||||
return removeGogWatchSensitiveArgs(buildGogWatchPullArgs(cfg));
|
||||
}
|
||||
|
||||
export function resolveGogExecutable(): string {
|
||||
return (gogBin ??= resolveExecutable("gog"));
|
||||
}
|
||||
@@ -313,6 +412,16 @@ export function parseTopicPath(topic: string): { projectId: string; topicName: s
|
||||
return { projectId: match[1] ?? "", topicName: match[2] ?? "" };
|
||||
}
|
||||
|
||||
export function parseSubscriptionPath(
|
||||
subscription: string,
|
||||
): { projectId: string; subscriptionName: string } | null {
|
||||
const match = subscription.trim().match(/^projects\/([^/]+)\/subscriptions\/([^/]+)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { projectId: match[1] ?? "", subscriptionName: match[2] ?? "" };
|
||||
}
|
||||
|
||||
function joinUrl(base: string, pathLocal: string): string {
|
||||
const url = new URL(base);
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
|
||||
Reference in New Issue
Block a user