@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. package/lib/public/js/components/usage-tab.js +0 -531
@@ -1,5 +1,7 @@
1
1
  const {
2
2
  kWatchdogCheckIntervalMs,
3
+ kWatchdogDegradedCheckIntervalMs,
4
+ kWatchdogStartupFailureThreshold,
3
5
  kWatchdogMaxRepairAttempts,
4
6
  kWatchdogCrashLoopWindowMs,
5
7
  kWatchdogCrashLoopThreshold,
@@ -64,9 +66,34 @@ const createWatchdog = ({
64
66
  expectedRestartInProgress: false,
65
67
  expectedRestartUntilMs: 0,
66
68
  pendingRecoveryNoticeSource: "",
69
+ startupConsecutiveHealthFailures: 0,
67
70
  };
68
71
  let healthTimer = null;
69
72
  let bootstrapHealthTimer = null;
73
+ let degradedHealthTimer = null;
74
+
75
+ const clearDegradedHealthCheckTimer = () => {
76
+ if (!degradedHealthTimer) return;
77
+ clearTimeout(degradedHealthTimer);
78
+ degradedHealthTimer = null;
79
+ };
80
+
81
+ const scheduleDegradedHealthCheck = () => {
82
+ if (degradedHealthTimer) return;
83
+ if (state.health !== "degraded" || state.lifecycle !== "running") return;
84
+ degradedHealthTimer = setTimeout(async () => {
85
+ degradedHealthTimer = null;
86
+ if (state.health !== "degraded" || state.lifecycle !== "running") return;
87
+ await runHealthCheck({
88
+ source: "degraded_retry",
89
+ allowAutoRepair: false,
90
+ });
91
+ if (state.health === "degraded" && state.lifecycle === "running") {
92
+ scheduleDegradedHealthCheck();
93
+ }
94
+ }, kWatchdogDegradedCheckIntervalMs);
95
+ if (typeof degradedHealthTimer.unref === "function") degradedHealthTimer.unref();
96
+ };
70
97
 
71
98
  const clearExpectedRestartWindow = () => {
72
99
  state.expectedRestartInProgress = false;
@@ -337,6 +364,8 @@ const createWatchdog = ({
337
364
  }
338
365
  if (parsed.ok) {
339
366
  const wasUnhealthy = state.health !== "healthy";
367
+ state.startupConsecutiveHealthFailures = 0;
368
+ clearDegradedHealthCheckTimer();
340
369
  clearExpectedRestartWindow();
341
370
  state.health = "healthy";
342
371
  if (state.lifecycle !== "crash_loop") state.lifecycle = "running";
@@ -359,6 +388,8 @@ const createWatchdog = ({
359
388
  return true;
360
389
  }
361
390
  if (restartWindowActive) {
391
+ state.startupConsecutiveHealthFailures = 0;
392
+ clearDegradedHealthCheckTimer();
362
393
  logEvent(
363
394
  "health_check",
364
395
  source,
@@ -381,6 +412,8 @@ const createWatchdog = ({
381
412
  state.lifecycle === "running" &&
382
413
  !state.crashRecoveryActive;
383
414
  if (withinStartupGrace) {
415
+ state.startupConsecutiveHealthFailures = 0;
416
+ clearDegradedHealthCheckTimer();
384
417
  logEvent(
385
418
  "health_check",
386
419
  source,
@@ -397,7 +430,31 @@ const createWatchdog = ({
397
430
  return false;
398
431
  }
399
432
 
433
+ if (state.health === "unknown" && state.lifecycle === "running") {
434
+ state.startupConsecutiveHealthFailures += 1;
435
+ if (state.startupConsecutiveHealthFailures < kWatchdogStartupFailureThreshold) {
436
+ logEvent(
437
+ "health_check",
438
+ source,
439
+ "ok",
440
+ {
441
+ reason: parsed.reason,
442
+ result,
443
+ skipped: true,
444
+ startupFailureRetryActive: true,
445
+ startupConsecutiveFailures: state.startupConsecutiveHealthFailures,
446
+ startupFailureThreshold: kWatchdogStartupFailureThreshold,
447
+ },
448
+ correlationId,
449
+ );
450
+ return false;
451
+ }
452
+ } else {
453
+ state.startupConsecutiveHealthFailures = 0;
454
+ }
455
+
400
456
  state.health = "degraded";
457
+ scheduleDegradedHealthCheck();
401
458
  logEvent(
402
459
  "health_check",
403
460
  source,
@@ -435,6 +492,7 @@ const createWatchdog = ({
435
492
 
436
493
  const onGatewayExit = ({ code, signal, expectedExit = false, stderrTail = [] } = {}) => {
437
494
  const correlationId = createCorrelationId();
495
+ clearDegradedHealthCheckTimer();
438
496
  if (expectedExit) {
439
497
  state.lifecycle = "restarting";
440
498
  state.health = "unknown";
@@ -504,8 +562,10 @@ const createWatchdog = ({
504
562
  };
505
563
 
506
564
  const onGatewayLaunch = ({ startedAt = Date.now(), pid = null } = {}) => {
565
+ clearDegradedHealthCheckTimer();
507
566
  state.lifecycle = "running";
508
567
  state.health = "unknown";
568
+ state.startupConsecutiveHealthFailures = 0;
509
569
  state.crashRecoveryActive = false;
510
570
  clearExpectedRestartWindow();
511
571
  state.uptimeStartedAt = startedAt;
@@ -515,8 +575,10 @@ const createWatchdog = ({
515
575
  };
516
576
 
517
577
  const onExpectedRestart = () => {
578
+ clearDegradedHealthCheckTimer();
518
579
  state.lifecycle = "restarting";
519
580
  state.health = "unknown";
581
+ state.startupConsecutiveHealthFailures = 0;
520
582
  state.crashRecoveryActive = false;
521
583
  markExpectedRestartWindow();
522
584
  startBootstrapHealthChecks();
@@ -533,14 +595,17 @@ const createWatchdog = ({
533
595
 
534
596
  const start = () => {
535
597
  if (healthTimer || bootstrapHealthTimer) return;
598
+ clearDegradedHealthCheckTimer();
536
599
  state.lifecycle = "running";
537
600
  state.health = "unknown";
601
+ state.startupConsecutiveHealthFailures = 0;
538
602
  state.uptimeStartedAt = Date.now();
539
603
  state.gatewayStartedAt = Date.now();
540
604
  startBootstrapHealthChecks();
541
605
  };
542
606
 
543
607
  const stop = () => {
608
+ clearDegradedHealthCheckTimer();
544
609
  if (bootstrapHealthTimer) {
545
610
  clearTimeout(bootstrapHealthTimer);
546
611
  bootstrapHealthTimer = null;
@@ -550,6 +615,7 @@ const createWatchdog = ({
550
615
  healthTimer = null;
551
616
  }
552
617
  state.lifecycle = "stopped";
618
+ state.startupConsecutiveHealthFailures = 0;
553
619
  };
554
620
 
555
621
  const getStatus = () => {
@@ -3,6 +3,8 @@ const https = require("https");
3
3
  const { URL } = require("url");
4
4
 
5
5
  const kRedactedHeaderKeys = new Set(["authorization", "cookie", "x-webhook-token"]);
6
+ const kGmailDedupeTtlMs = 24 * 60 * 60 * 1000;
7
+ const kGmailDedupeCleanupIntervalMs = 60 * 1000;
6
8
 
7
9
  const normalizeIp = (ip) => String(ip || "").replace(/^::ffff:/, "");
8
10
 
@@ -74,6 +76,51 @@ const resolveGatewayPath = ({ pathname, search }) => {
74
76
  return `${pathname}${search || ""}`;
75
77
  };
76
78
 
79
+ const parseJsonSafe = (rawValue) => {
80
+ try {
81
+ return JSON.parse(String(rawValue || "").trim() || "{}");
82
+ } catch {
83
+ return null;
84
+ }
85
+ };
86
+
87
+ const getGmailPayloadData = (parsedBody) => {
88
+ if (!parsedBody || typeof parsedBody !== "object") return null;
89
+ if (parsedBody.payload && typeof parsedBody.payload === "object") {
90
+ return parsedBody.payload;
91
+ }
92
+ return parsedBody;
93
+ };
94
+
95
+ const getGmailMessageId = (message = {}) => {
96
+ const preferredId = String(message?.id || "").trim();
97
+ if (preferredId) return preferredId;
98
+ const fallbackId = String(message?.messageId || "").trim();
99
+ return fallbackId;
100
+ };
101
+
102
+ const buildGmailDedupedBodyBuffer = ({ parsedBody, filteredMessages }) => {
103
+ if (parsedBody?.payload && typeof parsedBody.payload === "object") {
104
+ return Buffer.from(
105
+ JSON.stringify({
106
+ ...parsedBody,
107
+ payload: {
108
+ ...parsedBody.payload,
109
+ messages: filteredMessages,
110
+ },
111
+ }),
112
+ "utf8",
113
+ );
114
+ }
115
+ return Buffer.from(
116
+ JSON.stringify({
117
+ ...(parsedBody || {}),
118
+ messages: filteredMessages,
119
+ }),
120
+ "utf8",
121
+ );
122
+ };
123
+
77
124
  const createWebhookMiddleware = ({
78
125
  gatewayUrl,
79
126
  insertRequest,
@@ -81,6 +128,18 @@ const createWebhookMiddleware = ({
81
128
  }) => {
82
129
  const gateway = new URL(gatewayUrl);
83
130
  const protocolClient = gateway.protocol === "https:" ? https : http;
131
+ const gmailSeenMessageIds = new Map();
132
+ let lastGmailDedupeCleanupAt = 0;
133
+
134
+ const pruneGmailSeenMessageIds = (nowMs) => {
135
+ if (nowMs - lastGmailDedupeCleanupAt < kGmailDedupeCleanupIntervalMs) return;
136
+ for (const [messageKey, seenAt] of gmailSeenMessageIds.entries()) {
137
+ if (nowMs - seenAt > kGmailDedupeTtlMs) {
138
+ gmailSeenMessageIds.delete(messageKey);
139
+ }
140
+ }
141
+ lastGmailDedupeCleanupAt = nowMs;
142
+ };
84
143
 
85
144
  return (req, res) => {
86
145
  const inboundUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
@@ -90,8 +149,47 @@ const createWebhookMiddleware = ({
90
149
  inboundUrl.searchParams.delete("token");
91
150
  }
92
151
 
93
- const bodyBuffer = extractBodyBuffer(req);
152
+ let bodyBuffer = extractBodyBuffer(req);
94
153
  const hookName = resolveHookName(req);
154
+
155
+ if (hookName === "gmail" && bodyBuffer.length > 0) {
156
+ const parsedBody = parseJsonSafe(bodyBuffer.toString("utf8"));
157
+ const payloadData = getGmailPayloadData(parsedBody);
158
+ const accountKey = String(
159
+ payloadData?.account || payloadData?.email || payloadData?.inbox || "unknown",
160
+ )
161
+ .trim()
162
+ .toLowerCase();
163
+ const messages = Array.isArray(payloadData?.messages) ? payloadData.messages : [];
164
+ if (messages.length > 0) {
165
+ const nowMs = Date.now();
166
+ pruneGmailSeenMessageIds(nowMs);
167
+ const unseenMessages = [];
168
+ for (const message of messages) {
169
+ const messageId = getGmailMessageId(message);
170
+ if (!messageId) {
171
+ unseenMessages.push(message);
172
+ continue;
173
+ }
174
+ const dedupeKey = `${accountKey}:${messageId}`;
175
+ if (gmailSeenMessageIds.has(dedupeKey)) {
176
+ continue;
177
+ }
178
+ gmailSeenMessageIds.set(dedupeKey, nowMs);
179
+ unseenMessages.push(message);
180
+ }
181
+ if (unseenMessages.length === 0) {
182
+ return res.status(200).json({ ok: true, deduped: true });
183
+ }
184
+ if (unseenMessages.length < messages.length && parsedBody) {
185
+ bodyBuffer = buildGmailDedupedBodyBuffer({
186
+ parsedBody,
187
+ filteredMessages: unseenMessages,
188
+ });
189
+ }
190
+ }
191
+ }
192
+
95
193
  const sourceIp = normalizeIp(
96
194
  req.ip || req.headers["x-forwarded-for"] || req.socket?.remoteAddress || "",
97
195
  );
@@ -2,6 +2,14 @@ const path = require("path");
2
2
 
3
3
  const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4
4
  const kTransformsDir = "hooks/transforms";
5
+ const kManagedWebhookConfigs = [
6
+ {
7
+ name: "gmail",
8
+ preset: "gmail",
9
+ description:
10
+ "Managed by AlphaClaw Gmail Watch setup. Required for internal Gmail watch delivery.",
11
+ },
12
+ ];
5
13
 
6
14
  const getConfigPath = ({ OPENCLAW_DIR }) =>
7
15
  path.join(OPENCLAW_DIR, "openclaw.json");
@@ -45,7 +53,25 @@ const ensureHooksRoot = (cfg) => {
45
53
  if (typeof cfg.hooks.path !== "string" || !cfg.hooks.path.trim())
46
54
  cfg.hooks.path = "/hooks";
47
55
  if (typeof cfg.hooks.token !== "string" || !cfg.hooks.token.trim()) {
48
- cfg.hooks.token = "${WEBHOOK_TOKEN}";
56
+ cfg.hooks.token = "${OPENCLAW_HOOKS_TOKEN}";
57
+ }
58
+ if (
59
+ typeof cfg.hooks.defaultSessionKey !== "string" ||
60
+ !cfg.hooks.defaultSessionKey.trim()
61
+ ) {
62
+ cfg.hooks.defaultSessionKey = "hook:ingress";
63
+ }
64
+ if (typeof cfg.hooks.allowRequestSessionKey !== "boolean") {
65
+ cfg.hooks.allowRequestSessionKey = false;
66
+ }
67
+ if (!Array.isArray(cfg.hooks.allowedSessionKeyPrefixes)) {
68
+ cfg.hooks.allowedSessionKeyPrefixes = ["hook:"];
69
+ }
70
+ if (!cfg.hooks.allowedSessionKeyPrefixes.includes("hook:")) {
71
+ cfg.hooks.allowedSessionKeyPrefixes = [
72
+ ...cfg.hooks.allowedSessionKeyPrefixes,
73
+ "hook:",
74
+ ];
49
75
  }
50
76
  return cfg.hooks.mappings;
51
77
  };
@@ -100,36 +126,155 @@ const normalizeMappingTransformModules = (mappings) => {
100
126
  return changed;
101
127
  };
102
128
 
129
+ const buildDefaultTransformSource = (name) =>
130
+ [
131
+ "export default async function transform(payload, context) {",
132
+ " const data = payload.payload || payload;",
133
+ " return {",
134
+ " message: data.message,",
135
+ ` name: data.name || "${name}",`,
136
+ ' wakeMode: data.wakeMode || "now",',
137
+ " };",
138
+ "}",
139
+ "",
140
+ ].join("\n");
141
+
142
+ const ensureWebhookTransform = ({ fs, constants, name, source = "" }) => {
143
+ const webhookName = validateWebhookName(name);
144
+ const transformAbsolutePath = getTransformAbsolutePath(
145
+ constants,
146
+ webhookName,
147
+ );
148
+ fs.mkdirSync(path.dirname(transformAbsolutePath), { recursive: true });
149
+ if (fs.existsSync(transformAbsolutePath)) {
150
+ return { changed: false, path: transformAbsolutePath };
151
+ }
152
+ fs.writeFileSync(
153
+ transformAbsolutePath,
154
+ String(source || "").trim()
155
+ ? `${String(source).replace(/\s+$/, "")}\n`
156
+ : buildDefaultTransformSource(webhookName),
157
+ );
158
+ return { changed: true, path: transformAbsolutePath };
159
+ };
160
+
161
+ const ensureWebhookMapping = ({ cfg, name, mapping = {} }) => {
162
+ const webhookName = validateWebhookName(name);
163
+ const mappings = ensureHooksRoot(cfg);
164
+ const normalizedModulesChanged = normalizeMappingTransformModules(mappings);
165
+ const index = findMappingIndexByName(mappings, webhookName);
166
+ const defaults = {
167
+ match: { path: webhookName },
168
+ action: "agent",
169
+ name: webhookName,
170
+ wakeMode: "now",
171
+ transform: { module: getTransformModulePath(webhookName) },
172
+ };
173
+ if (index === -1) {
174
+ mappings.push({
175
+ ...defaults,
176
+ ...mapping,
177
+ match: { ...defaults.match, ...(mapping.match || {}) },
178
+ transform: { ...defaults.transform, ...(mapping.transform || {}) },
179
+ });
180
+ return { changed: true, created: true, normalizedModulesChanged };
181
+ }
182
+ const current = mappings[index] || {};
183
+ const next = {
184
+ ...current,
185
+ ...mapping,
186
+ match: {
187
+ ...(current.match || {}),
188
+ ...(mapping.match || {}),
189
+ path: webhookName,
190
+ },
191
+ action: mapping.action || current.action || defaults.action,
192
+ wakeMode: mapping.wakeMode || current.wakeMode || defaults.wakeMode,
193
+ transform: {
194
+ ...(current.transform || {}),
195
+ ...(mapping.transform || {}),
196
+ module:
197
+ String(mapping?.transform?.module || "").trim() ||
198
+ String(current?.transform?.module || "").trim() ||
199
+ defaults.transform.module,
200
+ },
201
+ };
202
+ if (JSON.stringify(current) !== JSON.stringify(next)) {
203
+ mappings[index] = next;
204
+ return { changed: true, created: false, normalizedModulesChanged };
205
+ }
206
+ return {
207
+ changed: normalizedModulesChanged,
208
+ created: false,
209
+ normalizedModulesChanged,
210
+ };
211
+ };
212
+
213
+ const listManagedWebhooksFromConfig = ({ cfg }) => {
214
+ const presets = Array.isArray(cfg?.hooks?.presets) ? cfg.hooks.presets : [];
215
+ return kManagedWebhookConfigs
216
+ .filter((managed) => presets.includes(managed.preset))
217
+ .map((managed) => ({
218
+ name: managed.name,
219
+ enabled: true,
220
+ createdAt: null,
221
+ path: `/hooks/${managed.name}`,
222
+ transformPath: null,
223
+ transformExists: true,
224
+ managed: true,
225
+ managedReason: managed.description,
226
+ }));
227
+ };
228
+
229
+ const isManagedWebhook = ({ cfg, name }) => {
230
+ const normalized = String(name || "")
231
+ .trim()
232
+ .toLowerCase();
233
+ if (!normalized) return false;
234
+ return listManagedWebhooksFromConfig({ cfg }).some(
235
+ (webhook) => webhook.name === normalized,
236
+ );
237
+ };
238
+
103
239
  const listWebhooks = ({ fs, constants }) => {
104
240
  const { cfg } = readConfig({ fs, constants });
105
241
  const mappings = ensureHooksRoot(cfg);
106
- return mappings
107
- .filter(isWebhookMapping)
108
- .map((mapping) => {
109
- const name = getMappingHookName(mapping);
110
- const transformPath = resolveTransformPathFromMapping(name, mapping);
111
- const transformAbsolutePath = path.join(
112
- constants.OPENCLAW_DIR,
113
- transformPath,
114
- );
115
- let createdAt = null;
116
- try {
117
- const stat = fs.statSync(transformAbsolutePath);
118
- createdAt =
119
- stat.birthtime?.toISOString?.() ||
120
- stat.ctime?.toISOString?.() ||
121
- null;
122
- } catch {}
123
- return {
124
- name,
125
- enabled: true,
126
- createdAt,
127
- path: `/hooks/${name}`,
128
- transformPath,
129
- transformExists: fs.existsSync(transformAbsolutePath),
130
- };
131
- })
132
- .sort((a, b) => a.name.localeCompare(b.name));
242
+ const managedWebhooks = listManagedWebhooksFromConfig({ cfg });
243
+ const managedByName = new Map(
244
+ managedWebhooks.map((item) => [item.name, item]),
245
+ );
246
+ const mappingWebhooks = mappings.filter(isWebhookMapping).map((mapping) => {
247
+ const name = getMappingHookName(mapping);
248
+ const managed = managedByName.get(name);
249
+ const transformPath = resolveTransformPathFromMapping(name, mapping);
250
+ const transformAbsolutePath = path.join(
251
+ constants.OPENCLAW_DIR,
252
+ transformPath,
253
+ );
254
+ let createdAt = null;
255
+ try {
256
+ const stat = fs.statSync(transformAbsolutePath);
257
+ createdAt =
258
+ stat.birthtime?.toISOString?.() || stat.ctime?.toISOString?.() || null;
259
+ } catch {}
260
+ return {
261
+ name,
262
+ enabled: true,
263
+ createdAt,
264
+ path: `/hooks/${name}`,
265
+ transformPath,
266
+ transformExists: fs.existsSync(transformAbsolutePath),
267
+ managed: Boolean(managed),
268
+ managedReason: managed?.managedReason || "",
269
+ };
270
+ });
271
+ const mappingNames = new Set(mappingWebhooks.map((item) => item.name));
272
+ const syntheticManagedWebhooks = managedWebhooks.filter(
273
+ (item) => !mappingNames.has(item.name),
274
+ );
275
+ return [...mappingWebhooks, ...syntheticManagedWebhooks].sort((a, b) =>
276
+ a.name.localeCompare(b.name),
277
+ );
133
278
  };
134
279
 
135
280
  const getWebhookDetail = ({ fs, constants, name }) => {
@@ -137,6 +282,12 @@ const getWebhookDetail = ({ fs, constants, name }) => {
137
282
  const hooks = listWebhooks({ fs, constants });
138
283
  const detail = hooks.find((item) => item.name === webhookName);
139
284
  if (!detail) return null;
285
+ if (detail.managed || !detail.transformPath) {
286
+ return {
287
+ ...detail,
288
+ transformExists: true,
289
+ };
290
+ }
140
291
  const transformAbsolutePath = path.join(
141
292
  constants.OPENCLAW_DIR,
142
293
  detail.transformPath,
@@ -147,56 +298,54 @@ const getWebhookDetail = ({ fs, constants, name }) => {
147
298
  };
148
299
  };
149
300
 
150
- const ensureStarterTransform = ({ fs, constants, name }) => {
151
- const transformAbsolutePath = getTransformAbsolutePath(constants, name);
152
- fs.mkdirSync(path.dirname(transformAbsolutePath), { recursive: true });
153
- if (fs.existsSync(transformAbsolutePath)) return transformAbsolutePath;
154
- fs.writeFileSync(
155
- transformAbsolutePath,
156
- [
157
- "export default async function transform(payload, context) {",
158
- " const data = payload.payload || payload;",
159
- " return {",
160
- " message: data.message,",
161
- ` name: data.name || "${name}",`,
162
- ' wakeMode: data.wakeMode || "now",',
163
- " };",
164
- "}",
165
- "",
166
- ].join("\n"),
167
- );
168
- return transformAbsolutePath;
169
- };
170
-
171
- const createWebhook = ({ fs, constants, name }) => {
301
+ const createWebhook = ({
302
+ fs,
303
+ constants,
304
+ name,
305
+ upsert = false,
306
+ allowManagedName = false,
307
+ mapping = {},
308
+ transformSource = "",
309
+ }) => {
172
310
  const webhookName = validateWebhookName(name);
173
311
  const { cfg, configPath } = readConfig({ fs, constants });
174
- if (!cfg.hooks) cfg.hooks = {};
175
- const mappings = ensureHooksRoot(cfg);
176
- const normalizedModules = normalizeMappingTransformModules(mappings);
177
- if (findMappingIndexByName(mappings, webhookName) !== -1) {
312
+ if (!allowManagedName && isManagedWebhook({ cfg, name: webhookName })) {
313
+ throw new Error(
314
+ `Webhook "${webhookName}" is managed and cannot be created manually`,
315
+ );
316
+ }
317
+ const existingMappings = ensureHooksRoot(cfg);
318
+ const exists = findMappingIndexByName(existingMappings, webhookName) !== -1;
319
+ if (exists && !upsert) {
178
320
  throw new Error(`Webhook "${webhookName}" already exists`);
179
321
  }
180
- mappings.push({
181
- match: { path: webhookName },
182
- action: "agent",
322
+ const ensuredMapping = ensureWebhookMapping({
323
+ cfg,
183
324
  name: webhookName,
184
- wakeMode: "now",
185
- transform: { module: getTransformModulePath(webhookName) },
325
+ mapping,
186
326
  });
187
-
188
- if (normalizedModules) {
189
- // Keep all existing mappings consistent with transformsDir-relative module paths.
190
- cfg.hooks.mappings = mappings;
327
+ const ensuredTransform = ensureWebhookTransform({
328
+ fs,
329
+ constants,
330
+ name: webhookName,
331
+ source: transformSource,
332
+ });
333
+ if (ensuredMapping.changed || ensuredTransform.changed || !exists) {
334
+ writeConfig({ fs, configPath, cfg });
191
335
  }
192
- writeConfig({ fs, configPath, cfg });
193
- ensureStarterTransform({ fs, constants, name: webhookName });
194
336
  return getWebhookDetail({ fs, constants, name: webhookName });
195
337
  };
196
338
 
197
339
  const deleteWebhook = ({ fs, constants, name, deleteTransformDir = false }) => {
198
340
  const webhookName = validateWebhookName(name);
199
341
  const { cfg, configPath } = readConfig({ fs, constants });
342
+ if (isManagedWebhook({ cfg, name: webhookName })) {
343
+ return {
344
+ removed: false,
345
+ managed: true,
346
+ deletedTransformDir: false,
347
+ };
348
+ }
200
349
  const mappings = ensureHooksRoot(cfg);
201
350
  const normalizedModules = normalizeMappingTransformModules(mappings);
202
351
  const index = findMappingIndexByName(mappings, webhookName);
package/lib/server.js CHANGED
@@ -98,6 +98,7 @@ const { registerTelegramRoutes } = require("./server/routes/telegram");
98
98
  const { registerWebhookRoutes } = require("./server/routes/webhooks");
99
99
  const { registerWatchdogRoutes } = require("./server/routes/watchdog");
100
100
  const { registerUsageRoutes } = require("./server/routes/usage");
101
+ const { registerGmailRoutes } = require("./server/routes/gmail");
101
102
 
102
103
  const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants;
103
104
 
@@ -111,6 +112,7 @@ migrateManagedInternalFiles({
111
112
  const app = express();
112
113
  app.set("trust proxy", kTrustProxyHops);
113
114
  app.use(["/webhook", "/hooks"], express.raw({ type: "*/*", limit: "5mb" }));
115
+ app.use("/gmail-pubsub", express.raw({ type: "*/*", limit: "5mb" }));
114
116
  app.use(express.json());
115
117
 
116
118
  const proxy = httpProxy.createProxyServer({
@@ -241,6 +243,18 @@ registerGoogleRoutes({
241
243
  getApiEnableUrl,
242
244
  constants,
243
245
  });
246
+ const gmailWatchService = registerGmailRoutes({
247
+ app,
248
+ fs,
249
+ constants,
250
+ gogCmd,
251
+ getBaseUrl,
252
+ readGoogleCredentials,
253
+ readEnvFile,
254
+ writeEnvFile,
255
+ reloadEnv,
256
+ restartRequiredState,
257
+ });
244
258
  const telegramApi = createTelegramApi(() => process.env.TELEGRAM_BOT_TOKEN);
245
259
  const discordApi = createDiscordApi(() => process.env.DISCORD_BOT_TOKEN);
246
260
  const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi });
@@ -345,7 +359,20 @@ server.listen(PORT, "0.0.0.0", () => {
345
359
  ensureGatewayProxyConfig(null);
346
360
  startGateway();
347
361
  watchdog.start();
362
+ gmailWatchService.start();
348
363
  } else {
349
364
  console.log("[alphaclaw] Awaiting onboarding via Setup UI");
350
365
  }
351
366
  });
367
+
368
+ const shutdownGmailWatchService = async () => {
369
+ try {
370
+ await gmailWatchService.stop();
371
+ } catch {}
372
+ };
373
+ process.on("SIGTERM", () => {
374
+ shutdownGmailWatchService();
375
+ });
376
+ process.on("SIGINT", () => {
377
+ shutdownGmailWatchService();
378
+ });
@@ -6,10 +6,16 @@
6
6
  !workspace/**
7
7
  workspace/.openclaw/
8
8
  workspace/.openclaw/**
9
+ !gogcli/
10
+ gogcli/*
11
+ !gogcli/state.json
9
12
  db/
10
13
  db/**
11
14
  !skills/
12
15
  !skills/**
16
+ !hooks/
17
+ !hooks/transforms/
18
+ !hooks/transforms/**
13
19
  !cron/
14
20
  !cron/jobs.json
15
21
  !openclaw.json