@chrysb/alphaclaw 0.2.2 → 0.3.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 (65) hide show
  1. package/bin/alphaclaw.js +79 -0
  2. package/lib/public/css/shell.css +57 -2
  3. package/lib/public/css/theme.css +184 -0
  4. package/lib/public/js/app.js +330 -89
  5. package/lib/public/js/components/action-button.js +92 -0
  6. package/lib/public/js/components/channels.js +16 -7
  7. package/lib/public/js/components/confirm-dialog.js +25 -19
  8. package/lib/public/js/components/credentials-modal.js +32 -23
  9. package/lib/public/js/components/device-pairings.js +15 -2
  10. package/lib/public/js/components/envars.js +22 -65
  11. package/lib/public/js/components/features.js +1 -1
  12. package/lib/public/js/components/gateway.js +139 -32
  13. package/lib/public/js/components/global-restart-banner.js +31 -0
  14. package/lib/public/js/components/google.js +9 -9
  15. package/lib/public/js/components/icons.js +19 -0
  16. package/lib/public/js/components/info-tooltip.js +18 -0
  17. package/lib/public/js/components/loading-spinner.js +32 -0
  18. package/lib/public/js/components/modal-shell.js +42 -0
  19. package/lib/public/js/components/models.js +34 -29
  20. package/lib/public/js/components/onboarding/welcome-form-step.js +45 -32
  21. package/lib/public/js/components/onboarding/welcome-pairing-step.js +2 -2
  22. package/lib/public/js/components/onboarding/welcome-setup-step.js +7 -24
  23. package/lib/public/js/components/page-header.js +13 -0
  24. package/lib/public/js/components/pairings.js +15 -2
  25. package/lib/public/js/components/providers.js +216 -142
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/secret-input.js +1 -0
  28. package/lib/public/js/components/telegram-workspace.js +37 -49
  29. package/lib/public/js/components/toast.js +34 -5
  30. package/lib/public/js/components/toggle-switch.js +25 -0
  31. package/lib/public/js/components/update-action-button.js +13 -53
  32. package/lib/public/js/components/watchdog-tab.js +312 -0
  33. package/lib/public/js/components/webhooks.js +981 -0
  34. package/lib/public/js/components/welcome.js +2 -1
  35. package/lib/public/js/lib/api.js +102 -1
  36. package/lib/public/js/lib/model-config.js +0 -5
  37. package/lib/public/login.html +1 -0
  38. package/lib/public/setup.html +1 -0
  39. package/lib/server/alphaclaw-version.js +5 -3
  40. package/lib/server/constants.js +33 -0
  41. package/lib/server/discord-api.js +48 -0
  42. package/lib/server/gateway.js +64 -4
  43. package/lib/server/log-writer.js +102 -0
  44. package/lib/server/onboarding/github.js +21 -1
  45. package/lib/server/openclaw-version.js +2 -6
  46. package/lib/server/restart-required-state.js +86 -0
  47. package/lib/server/routes/auth.js +9 -4
  48. package/lib/server/routes/proxy.js +12 -14
  49. package/lib/server/routes/system.js +61 -15
  50. package/lib/server/routes/telegram.js +17 -48
  51. package/lib/server/routes/watchdog.js +68 -0
  52. package/lib/server/routes/webhooks.js +214 -0
  53. package/lib/server/telegram-api.js +11 -0
  54. package/lib/server/watchdog-db.js +148 -0
  55. package/lib/server/watchdog-notify.js +93 -0
  56. package/lib/server/watchdog.js +585 -0
  57. package/lib/server/webhook-middleware.js +195 -0
  58. package/lib/server/webhooks-db.js +265 -0
  59. package/lib/server/webhooks.js +238 -0
  60. package/lib/server.js +119 -4
  61. package/lib/setup/core-prompts/AGENTS.md +84 -0
  62. package/lib/setup/core-prompts/TOOLS.md +13 -0
  63. package/lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md +50 -0
  64. package/lib/setup/gitignore +2 -0
  65. package/package.json +2 -1
@@ -0,0 +1,195 @@
1
+ const http = require("http");
2
+ const https = require("https");
3
+ const { URL } = require("url");
4
+
5
+ const kRedactedHeaderKeys = new Set(["authorization", "cookie", "x-webhook-token"]);
6
+
7
+ const normalizeIp = (ip) => String(ip || "").replace(/^::ffff:/, "");
8
+
9
+ const sanitizeHeaders = (headers) => {
10
+ const sanitized = {};
11
+ for (const [key, value] of Object.entries(headers || {})) {
12
+ const normalizedKey = String(key || "").toLowerCase();
13
+ if (!normalizedKey) continue;
14
+ if (kRedactedHeaderKeys.has(normalizedKey)) {
15
+ sanitized[normalizedKey] = "[REDACTED]";
16
+ continue;
17
+ }
18
+ sanitized[normalizedKey] = Array.isArray(value) ? value.join(", ") : String(value || "");
19
+ }
20
+ return sanitized;
21
+ };
22
+
23
+ const extractBodyBuffer = (req) => {
24
+ if (Buffer.isBuffer(req.body)) return req.body;
25
+ if (typeof req.body === "string") return Buffer.from(req.body, "utf8");
26
+ if (req.body && typeof req.body === "object") {
27
+ return Buffer.from(JSON.stringify(req.body), "utf8");
28
+ }
29
+ return Buffer.alloc(0);
30
+ };
31
+
32
+ const truncateText = (text, maxBytes) => {
33
+ const buffer = Buffer.isBuffer(text) ? text : Buffer.from(String(text || ""), "utf8");
34
+ if (buffer.length <= maxBytes) {
35
+ return { text: buffer.toString("utf8"), truncated: false };
36
+ }
37
+ return {
38
+ text: buffer.subarray(0, maxBytes).toString("utf8"),
39
+ truncated: true,
40
+ };
41
+ };
42
+
43
+ const toGatewayRequestHeaders = ({ reqHeaders, contentLength, authorization }) => {
44
+ const headers = { ...reqHeaders };
45
+ delete headers.host;
46
+ delete headers["content-length"];
47
+ delete headers["transfer-encoding"];
48
+ headers["content-length"] = String(contentLength);
49
+ if (authorization) headers.authorization = authorization;
50
+ return headers;
51
+ };
52
+
53
+ const resolveHookName = (req) => {
54
+ const paramPath =
55
+ req?.params?.path ??
56
+ req?.params?.[0] ??
57
+ req?.params?.["*"] ??
58
+ "";
59
+ const fromParams = String(paramPath).split("/").filter(Boolean)[0] || "";
60
+ if (fromParams) return decodeURIComponent(fromParams);
61
+
62
+ const pathname = String(req?.path || req?.originalUrl || "").split("?")[0];
63
+ const segments = pathname.split("/").filter(Boolean);
64
+ if (segments.length >= 2 && (segments[0] === "hooks" || segments[0] === "webhook")) {
65
+ return decodeURIComponent(segments[1] || "");
66
+ }
67
+ return "";
68
+ };
69
+
70
+ const resolveGatewayPath = ({ pathname, search }) => {
71
+ if (pathname.startsWith("/webhook/")) {
72
+ return `/hooks/${pathname.slice("/webhook/".length)}${search || ""}`;
73
+ }
74
+ return `${pathname}${search || ""}`;
75
+ };
76
+
77
+ const createWebhookMiddleware = ({
78
+ gatewayUrl,
79
+ insertRequest,
80
+ maxPayloadBytes = 50 * 1024,
81
+ }) => {
82
+ const gateway = new URL(gatewayUrl);
83
+ const protocolClient = gateway.protocol === "https:" ? https : http;
84
+
85
+ return (req, res) => {
86
+ const inboundUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
87
+ let tokenFromQuery = "";
88
+ if (!req.headers.authorization && inboundUrl.searchParams.has("token")) {
89
+ tokenFromQuery = String(inboundUrl.searchParams.get("token") || "");
90
+ inboundUrl.searchParams.delete("token");
91
+ }
92
+
93
+ const bodyBuffer = extractBodyBuffer(req);
94
+ const hookName = resolveHookName(req);
95
+ const sourceIp = normalizeIp(
96
+ req.ip || req.headers["x-forwarded-for"] || req.socket?.remoteAddress || "",
97
+ );
98
+ const sanitizedHeaders = sanitizeHeaders(req.headers);
99
+ const payload = truncateText(bodyBuffer, maxPayloadBytes);
100
+
101
+ const gatewayHeaders = toGatewayRequestHeaders({
102
+ reqHeaders: req.headers,
103
+ contentLength: bodyBuffer.length,
104
+ authorization: tokenFromQuery ? `Bearer ${tokenFromQuery}` : req.headers.authorization,
105
+ });
106
+
107
+ const requestOptions = {
108
+ protocol: gateway.protocol,
109
+ hostname: gateway.hostname,
110
+ port: gateway.port,
111
+ method: req.method,
112
+ path: resolveGatewayPath({
113
+ pathname: inboundUrl.pathname,
114
+ search: inboundUrl.search,
115
+ }),
116
+ headers: gatewayHeaders,
117
+ };
118
+
119
+ const proxyReq = protocolClient.request(requestOptions, (proxyRes) => {
120
+ const responseChunks = [];
121
+ let responseSize = 0;
122
+ let responseTruncated = false;
123
+
124
+ proxyRes.on("data", (chunk) => {
125
+ if (!Buffer.isBuffer(chunk)) return;
126
+ if (responseSize >= maxPayloadBytes) {
127
+ responseTruncated = true;
128
+ return;
129
+ }
130
+ const remaining = maxPayloadBytes - responseSize;
131
+ if (chunk.length > remaining) {
132
+ responseChunks.push(chunk.subarray(0, remaining));
133
+ responseSize += remaining;
134
+ responseTruncated = true;
135
+ return;
136
+ }
137
+ responseChunks.push(chunk);
138
+ responseSize += chunk.length;
139
+ });
140
+
141
+ proxyRes.on("end", () => {
142
+ const responseText = Buffer.concat(responseChunks).toString("utf8");
143
+ const gatewayBody = responseTruncated ? `${responseText}\n[TRUNCATED]` : responseText;
144
+ try {
145
+ insertRequest({
146
+ hookName,
147
+ method: req.method,
148
+ headers: sanitizedHeaders,
149
+ payload: payload.text,
150
+ payloadTruncated: payload.truncated,
151
+ payloadSize: bodyBuffer.length,
152
+ sourceIp,
153
+ gatewayStatus: proxyRes.statusCode || null,
154
+ gatewayBody,
155
+ });
156
+ } catch (err) {
157
+ console.error("[webhook] failed to write request log:", err.message);
158
+ }
159
+ });
160
+
161
+ res.statusCode = proxyRes.statusCode || 502;
162
+ for (const [key, value] of Object.entries(proxyRes.headers || {})) {
163
+ if (value == null) continue;
164
+ res.setHeader(key, value);
165
+ }
166
+ proxyRes.pipe(res);
167
+ });
168
+
169
+ proxyReq.on("error", (err) => {
170
+ try {
171
+ insertRequest({
172
+ hookName,
173
+ method: req.method,
174
+ headers: sanitizedHeaders,
175
+ payload: payload.text,
176
+ payloadTruncated: payload.truncated,
177
+ payloadSize: bodyBuffer.length,
178
+ sourceIp,
179
+ gatewayStatus: 502,
180
+ gatewayBody: err.message || "Gateway unavailable",
181
+ });
182
+ } catch {}
183
+ if (!res.headersSent) {
184
+ res.status(502).json({ error: "Gateway unavailable" });
185
+ }
186
+ });
187
+
188
+ if (bodyBuffer.length > 0) {
189
+ proxyReq.write(bodyBuffer);
190
+ }
191
+ proxyReq.end();
192
+ };
193
+ };
194
+
195
+ module.exports = { createWebhookMiddleware };
@@ -0,0 +1,265 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { DatabaseSync } = require("node:sqlite");
4
+
5
+ let db = null;
6
+ let pruneTimer = null;
7
+
8
+ const kDefaultRequestLimit = 50;
9
+ const kMaxRequestLimit = 200;
10
+ const kPruneIntervalMs = 12 * 60 * 60 * 1000;
11
+
12
+ const ensureDb = () => {
13
+ if (!db) throw new Error("Webhooks DB not initialized");
14
+ return db;
15
+ };
16
+
17
+ const createSchema = (database) => {
18
+ database.exec(`
19
+ CREATE TABLE IF NOT EXISTS webhook_requests (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ hook_name TEXT NOT NULL,
22
+ method TEXT,
23
+ headers TEXT,
24
+ payload TEXT,
25
+ payload_truncated INTEGER DEFAULT 0,
26
+ payload_size INTEGER,
27
+ source_ip TEXT,
28
+ gateway_status INTEGER,
29
+ gateway_body TEXT,
30
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
31
+ );
32
+ `);
33
+ database.exec(`
34
+ CREATE INDEX IF NOT EXISTS idx_webhook_requests_hook_ts
35
+ ON webhook_requests(hook_name, created_at DESC);
36
+ `);
37
+ };
38
+
39
+ const initWebhooksDb = ({ rootDir, pruneDays = 30 }) => {
40
+ const dbDir = path.join(rootDir, "db");
41
+ fs.mkdirSync(dbDir, { recursive: true });
42
+ const dbPath = path.join(dbDir, "webhooks.db");
43
+ db = new DatabaseSync(dbPath);
44
+ createSchema(db);
45
+ pruneOldEntries(pruneDays);
46
+ if (pruneTimer) clearInterval(pruneTimer);
47
+ pruneTimer = setInterval(() => {
48
+ try {
49
+ pruneOldEntries(pruneDays);
50
+ } catch (err) {
51
+ console.error("[webhooks-db] prune error:", err.message);
52
+ }
53
+ }, kPruneIntervalMs);
54
+ if (typeof pruneTimer.unref === "function") pruneTimer.unref();
55
+ return { path: dbPath };
56
+ };
57
+
58
+ const parseJsonText = (value) => {
59
+ if (typeof value !== "string" || !value) return null;
60
+ try {
61
+ return JSON.parse(value);
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+
67
+ const toRequestModel = (row) => {
68
+ if (!row) return null;
69
+ return {
70
+ id: row.id,
71
+ hookName: row.hook_name,
72
+ method: row.method || "",
73
+ headers: parseJsonText(row.headers) || {},
74
+ payload: row.payload || "",
75
+ payloadTruncated: !!row.payload_truncated,
76
+ payloadSize: Number(row.payload_size || 0),
77
+ sourceIp: row.source_ip || "",
78
+ gatewayStatus: row.gateway_status == null ? null : Number(row.gateway_status),
79
+ gatewayBody: row.gateway_body || "",
80
+ createdAt: row.created_at,
81
+ status:
82
+ row.gateway_status >= 200 && row.gateway_status < 300 ? "success" : "error",
83
+ };
84
+ };
85
+
86
+ const insertRequest = ({
87
+ hookName,
88
+ method,
89
+ headers,
90
+ payload,
91
+ payloadTruncated,
92
+ payloadSize,
93
+ sourceIp,
94
+ gatewayStatus,
95
+ gatewayBody,
96
+ }) => {
97
+ const database = ensureDb();
98
+ const stmt = database.prepare(`
99
+ INSERT INTO webhook_requests (
100
+ hook_name,
101
+ method,
102
+ headers,
103
+ payload,
104
+ payload_truncated,
105
+ payload_size,
106
+ source_ip,
107
+ gateway_status,
108
+ gateway_body
109
+ ) VALUES (
110
+ $hook_name,
111
+ $method,
112
+ $headers,
113
+ $payload,
114
+ $payload_truncated,
115
+ $payload_size,
116
+ $source_ip,
117
+ $gateway_status,
118
+ $gateway_body
119
+ )
120
+ `);
121
+ const info = stmt.run({
122
+ $hook_name: hookName,
123
+ $method: method || "",
124
+ $headers: JSON.stringify(headers || {}),
125
+ $payload: payload || "",
126
+ $payload_truncated: payloadTruncated ? 1 : 0,
127
+ $payload_size: Number(payloadSize || 0),
128
+ $source_ip: sourceIp || "",
129
+ $gateway_status:
130
+ Number.isFinite(Number(gatewayStatus)) ? Number(gatewayStatus) : null,
131
+ $gateway_body: gatewayBody || "",
132
+ });
133
+ return Number(info.lastInsertRowid || 0);
134
+ };
135
+
136
+ const resolveStatusWhereClause = (status) => {
137
+ if (status === "success") return "AND gateway_status >= 200 AND gateway_status < 300";
138
+ if (status === "error")
139
+ return "AND (gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300)";
140
+ return "";
141
+ };
142
+
143
+ const getRequests = (hookName, { limit, offset, status = "all" } = {}) => {
144
+ const database = ensureDb();
145
+ const safeLimit = Math.max(
146
+ 1,
147
+ Math.min(Number.parseInt(String(limit || kDefaultRequestLimit), 10) || kDefaultRequestLimit, kMaxRequestLimit),
148
+ );
149
+ const safeOffset = Math.max(0, Number.parseInt(String(offset || 0), 10) || 0);
150
+ const statusClause = resolveStatusWhereClause(status);
151
+ const rows = database
152
+ .prepare(`
153
+ SELECT
154
+ id,
155
+ hook_name,
156
+ method,
157
+ headers,
158
+ payload,
159
+ payload_truncated,
160
+ payload_size,
161
+ source_ip,
162
+ gateway_status,
163
+ gateway_body,
164
+ created_at
165
+ FROM webhook_requests
166
+ WHERE hook_name = $hook_name
167
+ ${statusClause}
168
+ ORDER BY created_at DESC
169
+ LIMIT $limit
170
+ OFFSET $offset
171
+ `)
172
+ .all({
173
+ $hook_name: hookName,
174
+ $limit: safeLimit,
175
+ $offset: safeOffset,
176
+ });
177
+ return rows.map(toRequestModel);
178
+ };
179
+
180
+ const getRequestById = (hookName, id) => {
181
+ const database = ensureDb();
182
+ const row = database
183
+ .prepare(`
184
+ SELECT
185
+ id,
186
+ hook_name,
187
+ method,
188
+ headers,
189
+ payload,
190
+ payload_truncated,
191
+ payload_size,
192
+ source_ip,
193
+ gateway_status,
194
+ gateway_body,
195
+ created_at
196
+ FROM webhook_requests
197
+ WHERE hook_name = $hook_name
198
+ AND id = $id
199
+ LIMIT 1
200
+ `)
201
+ .get({
202
+ $hook_name: hookName,
203
+ $id: Number.parseInt(String(id || 0), 10) || 0,
204
+ });
205
+ return toRequestModel(row);
206
+ };
207
+
208
+ const getHookSummaries = () => {
209
+ const database = ensureDb();
210
+ const rows = database
211
+ .prepare(`
212
+ SELECT
213
+ hook_name,
214
+ MAX(created_at) AS last_received,
215
+ COUNT(*) AS total_count,
216
+ SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count,
217
+ SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count
218
+ FROM webhook_requests
219
+ GROUP BY hook_name
220
+ `)
221
+ .all();
222
+ return rows.map((row) => ({
223
+ hookName: row.hook_name,
224
+ lastReceived: row.last_received || null,
225
+ totalCount: Number(row.total_count || 0),
226
+ successCount: Number(row.success_count || 0),
227
+ errorCount: Number(row.error_count || 0),
228
+ }));
229
+ };
230
+
231
+ const deleteRequestsByHook = (hookName) => {
232
+ const database = ensureDb();
233
+ const result = database
234
+ .prepare(`
235
+ DELETE FROM webhook_requests
236
+ WHERE hook_name = $hook_name
237
+ `)
238
+ .run({
239
+ $hook_name: String(hookName || ""),
240
+ });
241
+ return Number(result.changes || 0);
242
+ };
243
+
244
+ const pruneOldEntries = (days = 30) => {
245
+ const database = ensureDb();
246
+ const safeDays = Math.max(1, Number.parseInt(String(days || 30), 10) || 30);
247
+ const modifier = `-${safeDays} days`;
248
+ const result = database
249
+ .prepare(`
250
+ DELETE FROM webhook_requests
251
+ WHERE created_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', $modifier)
252
+ `)
253
+ .run({ $modifier: modifier });
254
+ return Number(result.changes || 0);
255
+ };
256
+
257
+ module.exports = {
258
+ initWebhooksDb,
259
+ insertRequest,
260
+ getRequests,
261
+ getRequestById,
262
+ getHookSummaries,
263
+ deleteRequestsByHook,
264
+ pruneOldEntries,
265
+ };
@@ -0,0 +1,238 @@
1
+ const path = require("path");
2
+
3
+ const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4
+ const kTransformsDir = "hooks/transforms";
5
+
6
+ const getConfigPath = ({ OPENCLAW_DIR }) =>
7
+ path.join(OPENCLAW_DIR, "openclaw.json");
8
+
9
+ const readConfig = ({ fs, constants }) => {
10
+ const configPath = getConfigPath(constants);
11
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
12
+ return { cfg, configPath };
13
+ };
14
+
15
+ const writeConfig = ({ fs, configPath, cfg }) => {
16
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
17
+ };
18
+
19
+ const getTransformRelativePath = (name) =>
20
+ `${kTransformsDir}/${name}/${name}-transform.mjs`;
21
+ const getTransformModulePath = (name) => `${name}/${name}-transform.mjs`;
22
+ const getTransformAbsolutePath = ({ OPENCLAW_DIR }, name) =>
23
+ path.join(OPENCLAW_DIR, getTransformRelativePath(name));
24
+ const getTransformDirectoryRelativePath = (name) => `${kTransformsDir}/${name}`;
25
+ const getTransformDirectoryAbsolutePath = ({ OPENCLAW_DIR }, name) =>
26
+ path.join(OPENCLAW_DIR, getTransformDirectoryRelativePath(name));
27
+ const normalizeTransformModulePath = ({ modulePath, name }) => {
28
+ const rawModulePath = String(modulePath || "")
29
+ .trim()
30
+ .replace(/^\/+/, "");
31
+ const fallbackModulePath = getTransformModulePath(name);
32
+ const nextModulePath = rawModulePath || fallbackModulePath;
33
+ if (nextModulePath.startsWith(`${kTransformsDir}/`)) {
34
+ return nextModulePath.slice(kTransformsDir.length + 1);
35
+ }
36
+ return nextModulePath;
37
+ };
38
+
39
+ const ensureHooksRoot = (cfg) => {
40
+ if (!cfg.hooks) cfg.hooks = {};
41
+ if (!Array.isArray(cfg.hooks.mappings)) {
42
+ cfg.hooks.mappings = [];
43
+ }
44
+ if (typeof cfg.hooks.enabled !== "boolean") cfg.hooks.enabled = true;
45
+ if (typeof cfg.hooks.path !== "string" || !cfg.hooks.path.trim())
46
+ cfg.hooks.path = "/hooks";
47
+ if (typeof cfg.hooks.token !== "string" || !cfg.hooks.token.trim()) {
48
+ cfg.hooks.token = "${WEBHOOK_TOKEN}";
49
+ }
50
+ return cfg.hooks.mappings;
51
+ };
52
+
53
+ const getMappingHookName = (mapping) =>
54
+ String(mapping?.match?.path || "").trim();
55
+ const isWebhookMapping = (mapping) => !!getMappingHookName(mapping);
56
+ const findMappingIndexByName = (mappings, name) =>
57
+ mappings.findIndex((mapping) => getMappingHookName(mapping) === name);
58
+
59
+ const validateWebhookName = (name) => {
60
+ const normalized = String(name || "")
61
+ .trim()
62
+ .toLowerCase();
63
+ if (!normalized) throw new Error("Webhook name is required");
64
+ if (!kNamePattern.test(normalized)) {
65
+ throw new Error(
66
+ "Webhook name must be lowercase letters, numbers, and hyphens",
67
+ );
68
+ }
69
+ return normalized;
70
+ };
71
+
72
+ const resolveTransformPathFromMapping = (name, mapping) => {
73
+ const modulePath = normalizeTransformModulePath({
74
+ modulePath: mapping?.transform?.module,
75
+ name,
76
+ });
77
+ return `${kTransformsDir}/${modulePath}`;
78
+ };
79
+
80
+ const normalizeMappingTransformModules = (mappings) => {
81
+ let changed = false;
82
+ for (const mapping of mappings || []) {
83
+ const name = getMappingHookName(mapping);
84
+ if (!name) continue;
85
+ const normalizedModulePath = normalizeTransformModulePath({
86
+ modulePath: mapping?.transform?.module,
87
+ name,
88
+ });
89
+ if (
90
+ !mapping.transform ||
91
+ mapping.transform.module !== normalizedModulePath
92
+ ) {
93
+ mapping.transform = {
94
+ ...(mapping.transform || {}),
95
+ module: normalizedModulePath,
96
+ };
97
+ changed = true;
98
+ }
99
+ }
100
+ return changed;
101
+ };
102
+
103
+ const listWebhooks = ({ fs, constants }) => {
104
+ const { cfg } = readConfig({ fs, constants });
105
+ 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));
133
+ };
134
+
135
+ const getWebhookDetail = ({ fs, constants, name }) => {
136
+ const webhookName = validateWebhookName(name);
137
+ const hooks = listWebhooks({ fs, constants });
138
+ const detail = hooks.find((item) => item.name === webhookName);
139
+ if (!detail) return null;
140
+ const transformAbsolutePath = path.join(
141
+ constants.OPENCLAW_DIR,
142
+ detail.transformPath,
143
+ );
144
+ return {
145
+ ...detail,
146
+ transformExists: fs.existsSync(transformAbsolutePath),
147
+ };
148
+ };
149
+
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 }) => {
172
+ const webhookName = validateWebhookName(name);
173
+ 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) {
178
+ throw new Error(`Webhook "${webhookName}" already exists`);
179
+ }
180
+ mappings.push({
181
+ match: { path: webhookName },
182
+ action: "agent",
183
+ name: webhookName,
184
+ wakeMode: "now",
185
+ transform: { module: getTransformModulePath(webhookName) },
186
+ });
187
+
188
+ if (normalizedModules) {
189
+ // Keep all existing mappings consistent with transformsDir-relative module paths.
190
+ cfg.hooks.mappings = mappings;
191
+ }
192
+ writeConfig({ fs, configPath, cfg });
193
+ ensureStarterTransform({ fs, constants, name: webhookName });
194
+ return getWebhookDetail({ fs, constants, name: webhookName });
195
+ };
196
+
197
+ const deleteWebhook = ({ fs, constants, name, deleteTransformDir = false }) => {
198
+ const webhookName = validateWebhookName(name);
199
+ const { cfg, configPath } = readConfig({ fs, constants });
200
+ const mappings = ensureHooksRoot(cfg);
201
+ const normalizedModules = normalizeMappingTransformModules(mappings);
202
+ const index = findMappingIndexByName(mappings, webhookName);
203
+ if (index === -1) {
204
+ if (normalizedModules) writeConfig({ fs, configPath, cfg });
205
+ return false;
206
+ }
207
+ mappings.splice(index, 1);
208
+ writeConfig({ fs, configPath, cfg });
209
+ let deletedTransformDir = false;
210
+ if (deleteTransformDir) {
211
+ const transformDirAbsolutePath = getTransformDirectoryAbsolutePath(
212
+ constants,
213
+ webhookName,
214
+ );
215
+ if (fs.existsSync(transformDirAbsolutePath)) {
216
+ fs.rmSync(transformDirAbsolutePath, { recursive: true, force: true });
217
+ deletedTransformDir = !fs.existsSync(transformDirAbsolutePath);
218
+ if (!deletedTransformDir) {
219
+ throw new Error(
220
+ `Failed to delete transform directory: ${getTransformDirectoryRelativePath(webhookName)}`,
221
+ );
222
+ }
223
+ }
224
+ }
225
+ return {
226
+ removed: true,
227
+ deletedTransformDir,
228
+ };
229
+ };
230
+
231
+ module.exports = {
232
+ listWebhooks,
233
+ getWebhookDetail,
234
+ createWebhook,
235
+ deleteWebhook,
236
+ validateWebhookName,
237
+ getTransformRelativePath,
238
+ };