@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,981 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { usePolling } from "../hooks/usePolling.js";
5
+ import {
6
+ createWebhook,
7
+ deleteWebhook,
8
+ fetchWebhookDetail,
9
+ fetchWebhookRequests,
10
+ fetchWebhooks,
11
+ } from "../lib/api.js";
12
+ import { showToast } from "./toast.js";
13
+ import { PageHeader } from "./page-header.js";
14
+ import { ConfirmDialog } from "./confirm-dialog.js";
15
+ import { ActionButton } from "./action-button.js";
16
+ import { ModalShell } from "./modal-shell.js";
17
+ import { CloseIcon } from "./icons.js";
18
+
19
+ const html = htm.bind(h);
20
+ const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ const kStatusFilters = ["all", "success", "error"];
22
+
23
+ const formatDateTime = (value) => {
24
+ if (!value) return "—";
25
+ try {
26
+ return new Date(value).toLocaleString();
27
+ } catch {
28
+ return value;
29
+ }
30
+ };
31
+
32
+ const formatLastReceived = (value) => {
33
+ if (!value) return "—";
34
+ try {
35
+ const timestamp = new Date(value);
36
+ const now = new Date();
37
+ const isSameDay =
38
+ timestamp.getFullYear() === now.getFullYear() &&
39
+ timestamp.getMonth() === now.getMonth() &&
40
+ timestamp.getDate() === now.getDate();
41
+ return isSameDay
42
+ ? timestamp.toLocaleTimeString()
43
+ : timestamp.toLocaleString();
44
+ } catch {
45
+ return value;
46
+ }
47
+ };
48
+
49
+ const formatBytes = (size) => {
50
+ const bytes = Number(size || 0);
51
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
52
+ if (bytes < 1024) return `${bytes} B`;
53
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
54
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
55
+ };
56
+
57
+ const healthClassName = (health) => {
58
+ if (health === "red") return "bg-red-500";
59
+ if (health === "yellow") return "bg-yellow-500";
60
+ return "bg-green-500";
61
+ };
62
+
63
+ const statusBadgeClass = (status) =>
64
+ status === "success"
65
+ ? "bg-green-500/10 text-green-300 border border-green-500/30"
66
+ : "bg-red-500/10 text-red-300 border border-red-500/30";
67
+
68
+ const jsonPretty = (value) => {
69
+ if (typeof value === "string") {
70
+ try {
71
+ const parsed = JSON.parse(value);
72
+ return JSON.stringify(parsed, null, 2);
73
+ } catch {
74
+ return value;
75
+ }
76
+ }
77
+ try {
78
+ return JSON.stringify(value || {}, null, 2);
79
+ } catch {
80
+ return String(value || "");
81
+ }
82
+ };
83
+
84
+ const CreateWebhookModal = ({
85
+ visible,
86
+ name,
87
+ onNameChange,
88
+ canCreate,
89
+ creating,
90
+ onCreate,
91
+ onClose,
92
+ }) => {
93
+ if (!visible) return null;
94
+ const normalized = String(name || "")
95
+ .trim()
96
+ .toLowerCase();
97
+ const previewName = normalized || "{name}";
98
+ const previewUrl = `${window.location.origin}/hooks/${previewName}`;
99
+ return html`
100
+ <${ModalShell}
101
+ visible=${visible}
102
+ onClose=${onClose}
103
+ panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
104
+ >
105
+ <${PageHeader}
106
+ title="Create Webhook"
107
+ actions=${html`
108
+ <button
109
+ type="button"
110
+ onclick=${onClose}
111
+ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
112
+ aria-label="Close modal"
113
+ >
114
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
115
+ </button>
116
+ `}
117
+ />
118
+ <div class="space-y-2">
119
+ <p class="text-xs text-gray-500">Name</p>
120
+ <input
121
+ type="text"
122
+ value=${name}
123
+ placeholder="fathom"
124
+ onInput=${(e) => onNameChange(e.target.value)}
125
+ onKeyDown=${(e) => {
126
+ if (e.key === "Enter" && canCreate && !creating) onCreate();
127
+ if (e.key === "Escape") onClose();
128
+ }}
129
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-1.5 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
130
+ />
131
+ </div>
132
+ <div class="border border-border rounded-lg overflow-hidden">
133
+ <table class="w-full text-xs">
134
+ <tbody>
135
+ <tr class="border-b border-border">
136
+ <td class="w-24 px-3 py-2 text-gray-500">Path</td>
137
+ <td class="px-3 py-2 text-gray-300 font-mono">
138
+ <code>/hooks/${previewName}</code>
139
+ </td>
140
+ </tr>
141
+ <tr class="border-b border-border">
142
+ <td class="w-24 px-3 py-2 text-gray-500">URL</td>
143
+ <td class="px-3 py-2 text-gray-300 font-mono break-all">
144
+ <code>${previewUrl}</code>
145
+ </td>
146
+ </tr>
147
+ <tr>
148
+ <td class="w-24 px-3 py-2 text-gray-500">Transform</td>
149
+ <td class="px-3 py-2 text-gray-300 font-mono">
150
+ <code
151
+ >hooks/transforms/${previewName}/${previewName}-transform.mjs</code
152
+ >
153
+ </td>
154
+ </tr>
155
+ </tbody>
156
+ </table>
157
+ </div>
158
+ <div class="pt-1 flex items-center justify-end gap-2">
159
+ <${ActionButton}
160
+ onClick=${onClose}
161
+ tone="secondary"
162
+ size="md"
163
+ idleLabel="Cancel"
164
+ className="px-4 py-2 rounded-lg text-sm"
165
+ />
166
+ <${ActionButton}
167
+ onClick=${onCreate}
168
+ disabled=${!canCreate || creating}
169
+ loading=${creating}
170
+ tone="primary"
171
+ size="md"
172
+ idleLabel="Create"
173
+ loadingLabel="Creating..."
174
+ className="px-4 py-2 rounded-lg text-sm"
175
+ />
176
+ </div>
177
+ </${ModalShell}>
178
+ `;
179
+ };
180
+
181
+ export const Webhooks = ({
182
+ selectedHookName = "",
183
+ onSelectHook = () => {},
184
+ onBackToList = () => {},
185
+ onRestartRequired = () => {},
186
+ }) => {
187
+ const [isCreating, setIsCreating] = useState(false);
188
+ const [newName, setNewName] = useState("");
189
+ const [creating, setCreating] = useState(false);
190
+ const [deleting, setDeleting] = useState(false);
191
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
192
+ const [deleteTransformDir, setDeleteTransformDir] = useState(true);
193
+ const [authMode, setAuthMode] = useState("headers");
194
+ const [statusFilter, setStatusFilter] = useState("all");
195
+ const [expandedRows, setExpandedRows] = useState(() => new Set());
196
+ const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
197
+ const [replayingRequestId, setReplayingRequestId] = useState(null);
198
+
199
+ const listPoll = usePolling(fetchWebhooks, 15000);
200
+ const webhooks = listPoll.data?.webhooks || [];
201
+
202
+ const detailPoll = usePolling(
203
+ async () => {
204
+ if (!selectedHookName) return null;
205
+ const data = await fetchWebhookDetail(selectedHookName);
206
+ return data.webhook || null;
207
+ },
208
+ 10000,
209
+ { enabled: !!selectedHookName },
210
+ );
211
+
212
+ const requestsPoll = usePolling(
213
+ async () => {
214
+ if (!selectedHookName) return { requests: [] };
215
+ const data = await fetchWebhookRequests(selectedHookName, {
216
+ limit: 25,
217
+ offset: 0,
218
+ status: statusFilter,
219
+ });
220
+ return data;
221
+ },
222
+ 5000,
223
+ { enabled: !!selectedHookName },
224
+ );
225
+
226
+ const selectedWebhook = detailPoll.data;
227
+ const requests = requestsPoll.data?.requests || [];
228
+ const webhookUrl =
229
+ selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
230
+ const webhookUrlWithQueryToken =
231
+ selectedWebhook?.queryStringUrl ||
232
+ `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
233
+ const derivedTokenFromQuery = (() => {
234
+ try {
235
+ const parsed = new URL(webhookUrlWithQueryToken);
236
+ return String(parsed.searchParams.get("token") || "").trim();
237
+ } catch {
238
+ return "";
239
+ }
240
+ })();
241
+ const authHeaderValue =
242
+ selectedWebhook?.authHeaderValue ||
243
+ (derivedTokenFromQuery
244
+ ? `Authorization: Bearer ${derivedTokenFromQuery}`
245
+ : "Authorization: Bearer <WEBHOOK_TOKEN>");
246
+ const bearerTokenValue = authHeaderValue.startsWith("Authorization: ")
247
+ ? authHeaderValue.slice("Authorization: ".length)
248
+ : authHeaderValue;
249
+ const webhookTestPayload = useMemo(
250
+ () => ({
251
+ source: "manual-test",
252
+ message: `This is a test of the ${selectedHookName || "webhook"} webhook. Please acknowledge receipt.`,
253
+ }),
254
+ [selectedHookName],
255
+ );
256
+ const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);
257
+ const curlCommandHeaders =
258
+ `curl -X POST "${webhookUrl}" ` +
259
+ `-H "Content-Type: application/json" ` +
260
+ `-H "${authHeaderValue}" ` +
261
+ `-d '${webhookTestPayloadJson}'`;
262
+ const curlCommandQuery =
263
+ `curl -X POST "${webhookUrlWithQueryToken}" ` +
264
+ `-H "Content-Type: application/json" ` +
265
+ `-d '${webhookTestPayloadJson}'`;
266
+ const activeCurlCommand =
267
+ authMode === "query" ? curlCommandQuery : curlCommandHeaders;
268
+
269
+ const canCreate = useMemo(() => {
270
+ const name = String(newName || "")
271
+ .trim()
272
+ .toLowerCase();
273
+ return kNamePattern.test(name);
274
+ }, [newName]);
275
+
276
+ const refreshAll = useCallback(() => {
277
+ listPoll.refresh();
278
+ detailPoll.refresh();
279
+ requestsPoll.refresh();
280
+ }, [listPoll.refresh, detailPoll.refresh, requestsPoll.refresh]);
281
+
282
+ const handleCreate = useCallback(async () => {
283
+ const candidateName = String(newName || "")
284
+ .trim()
285
+ .toLowerCase();
286
+ if (!kNamePattern.test(candidateName)) {
287
+ showToast(
288
+ "Name must be lowercase letters, numbers, and hyphens",
289
+ "error",
290
+ );
291
+ return;
292
+ }
293
+ if (creating) return;
294
+ setCreating(true);
295
+ try {
296
+ const data = await createWebhook(candidateName);
297
+ setIsCreating(false);
298
+ setNewName("");
299
+ onSelectHook(candidateName);
300
+ if (data.restartRequired) onRestartRequired(true);
301
+ showToast("Webhook created", "success");
302
+ if (data.syncWarning) {
303
+ showToast(
304
+ `Created, but git-sync failed: ${data.syncWarning}`,
305
+ "warning",
306
+ );
307
+ }
308
+ refreshAll();
309
+ } catch (err) {
310
+ showToast(err.message || "Could not create webhook", "error");
311
+ } finally {
312
+ setCreating(false);
313
+ }
314
+ }, [newName, creating, refreshAll, onSelectHook, onRestartRequired]);
315
+
316
+ const handleDeleteConfirmed = useCallback(async () => {
317
+ if (!selectedHookName || deleting) return;
318
+ setDeleting(true);
319
+ try {
320
+ const data = await deleteWebhook(selectedHookName, {
321
+ deleteTransformDir,
322
+ });
323
+ if (data.restartRequired) onRestartRequired(true);
324
+ onBackToList();
325
+ setShowDeleteConfirm(false);
326
+ setDeleteTransformDir(true);
327
+ showToast("Webhook removed", "success");
328
+ if (data.deletedTransformDir) {
329
+ showToast("Transform directory deleted", "success");
330
+ }
331
+ if (data.syncWarning) {
332
+ showToast(
333
+ `Deleted, but git-sync failed: ${data.syncWarning}`,
334
+ "warning",
335
+ );
336
+ }
337
+ refreshAll();
338
+ } catch (err) {
339
+ showToast(err.message || "Could not delete webhook", "error");
340
+ } finally {
341
+ setDeleting(false);
342
+ }
343
+ }, [
344
+ selectedHookName,
345
+ deleting,
346
+ deleteTransformDir,
347
+ refreshAll,
348
+ onBackToList,
349
+ onRestartRequired,
350
+ ]);
351
+
352
+ const toggleRow = useCallback((id) => {
353
+ setExpandedRows((prev) => {
354
+ const next = new Set(prev);
355
+ if (next.has(id)) next.delete(id);
356
+ else next.add(id);
357
+ return next;
358
+ });
359
+ }, []);
360
+
361
+ const handleSendTestWebhook = useCallback(async () => {
362
+ if (!selectedHookName || sendingTestWebhook) return;
363
+ setSendingTestWebhook(true);
364
+ const requestUrl =
365
+ authMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
366
+ const headers = { "Content-Type": "application/json" };
367
+ if (authMode === "headers") {
368
+ headers.Authorization = bearerTokenValue;
369
+ }
370
+ try {
371
+ const response = await fetch(requestUrl, {
372
+ method: "POST",
373
+ headers,
374
+ body: webhookTestPayloadJson,
375
+ });
376
+ const bodyText = await response.text();
377
+ let body = null;
378
+ try {
379
+ body = bodyText ? JSON.parse(bodyText) : null;
380
+ } catch {
381
+ body = null;
382
+ }
383
+ const errorMessage =
384
+ body?.ok === false
385
+ ? body?.error || "Webhook rejected"
386
+ : !response.ok
387
+ ? body?.error || bodyText || `HTTP ${response.status}`
388
+ : "";
389
+ if (errorMessage) {
390
+ showToast(`Test webhook failed: ${errorMessage}`, "error");
391
+ return;
392
+ }
393
+ showToast("Test webhook sent", "success");
394
+ setTimeout(() => requestsPoll.refresh(), 0);
395
+ } catch (err) {
396
+ showToast(err.message || "Could not send test webhook", "error");
397
+ } finally {
398
+ setSendingTestWebhook(false);
399
+ }
400
+ }, [
401
+ authMode,
402
+ bearerTokenValue,
403
+ requestsPoll.refresh,
404
+ selectedHookName,
405
+ sendingTestWebhook,
406
+ webhookTestPayloadJson,
407
+ webhookUrl,
408
+ webhookUrlWithQueryToken,
409
+ ]);
410
+
411
+ const handleReplayRequest = useCallback(
412
+ async (item) => {
413
+ if (!item || replayingRequestId === item.id) return;
414
+ if (item.payloadTruncated) {
415
+ showToast("Cannot replay a truncated payload", "warning");
416
+ return;
417
+ }
418
+ const requestUrl =
419
+ authMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
420
+ const headers = { "Content-Type": "application/json" };
421
+ if (authMode === "headers") {
422
+ headers.Authorization = bearerTokenValue;
423
+ }
424
+ setReplayingRequestId(item.id);
425
+ try {
426
+ const response = await fetch(requestUrl, {
427
+ method: "POST",
428
+ headers,
429
+ body: String(item.payload || ""),
430
+ });
431
+ const bodyText = await response.text();
432
+ let body = null;
433
+ try {
434
+ body = bodyText ? JSON.parse(bodyText) : null;
435
+ } catch {
436
+ body = null;
437
+ }
438
+ const errorMessage =
439
+ body?.ok === false
440
+ ? body?.error || "Webhook rejected"
441
+ : !response.ok
442
+ ? body?.error || bodyText || `HTTP ${response.status}`
443
+ : "";
444
+ if (errorMessage) {
445
+ showToast(`Replay failed: ${errorMessage}`, "error");
446
+ return;
447
+ }
448
+ showToast("Request replayed", "success");
449
+ setTimeout(() => requestsPoll.refresh(), 0);
450
+ } catch (err) {
451
+ showToast(err.message || "Could not replay request", "error");
452
+ } finally {
453
+ setReplayingRequestId(null);
454
+ }
455
+ },
456
+ [
457
+ authMode,
458
+ bearerTokenValue,
459
+ replayingRequestId,
460
+ requestsPoll.refresh,
461
+ webhookUrl,
462
+ webhookUrlWithQueryToken,
463
+ ],
464
+ );
465
+
466
+ const handleCopyRequestField = useCallback(async (value, label) => {
467
+ try {
468
+ await navigator.clipboard.writeText(String(value || ""));
469
+ showToast(`${label} copied`, "success");
470
+ } catch {
471
+ showToast(`Could not copy ${String(label || "value").toLowerCase()}`, "error");
472
+ }
473
+ }, []);
474
+
475
+ const isListLoading = !listPoll.data && !listPoll.error;
476
+
477
+ return html`
478
+ <div class="space-y-4">
479
+ <${PageHeader}
480
+ title="Webhooks"
481
+ leading=${selectedHookName
482
+ ? html`
483
+ <button
484
+ class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 transition-colors"
485
+ onclick=${onBackToList}
486
+ >
487
+ <svg
488
+ width="16"
489
+ height="16"
490
+ viewBox="0 0 16 16"
491
+ fill="currentColor"
492
+ >
493
+ <path
494
+ d="M10.354 3.354a.5.5 0 00-.708-.708l-5 5a.5.5 0 000 .708l5 5a.5.5 0 00.708-.708L5.707 8l4.647-4.646z"
495
+ />
496
+ </svg>
497
+ Back
498
+ </button>
499
+ `
500
+ : null}
501
+ actions=${selectedHookName
502
+ ? null
503
+ : html`
504
+ <button
505
+ class="text-xs px-3 py-1.5 rounded-lg ac-btn-secondary"
506
+ onclick=${() => setIsCreating((open) => !open)}
507
+ >
508
+ Create new
509
+ </button>
510
+ `}
511
+ />
512
+
513
+ ${selectedHookName
514
+ ? html`
515
+ <div
516
+ class="bg-surface border border-border rounded-xl p-4 space-y-4"
517
+ >
518
+ <div>
519
+ <h2 class="font-semibold text-sm">
520
+ ${selectedWebhook?.path || `/hooks/${selectedHookName}`}
521
+ </h2>
522
+ </div>
523
+
524
+ <div
525
+ class="bg-black/20 border border-border rounded-lg p-3 space-y-4"
526
+ >
527
+ <div class="space-y-2">
528
+ <p class="text-xs text-gray-500">Auth mode</p>
529
+ <div class="flex items-center gap-2">
530
+ <button
531
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
532
+ "headers"
533
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
534
+ : "border-border text-gray-400 hover:text-gray-200"}"
535
+ onclick=${() => setAuthMode("headers")}
536
+ >
537
+ Headers
538
+ </button>
539
+ <button
540
+ class="text-xs px-2 py-1 rounded border transition-colors ${authMode ===
541
+ "query"
542
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
543
+ : "border-border text-gray-400 hover:text-gray-200"}"
544
+ onclick=${() => setAuthMode("query")}
545
+ >
546
+ Query string
547
+ </button>
548
+ </div>
549
+ </div>
550
+ <div class="space-y-2">
551
+ <p class="text-xs text-gray-500">Webhook URL</p>
552
+ <div class="flex items-center gap-2">
553
+ <input
554
+ type="text"
555
+ readonly
556
+ value=${authMode === "query"
557
+ ? webhookUrlWithQueryToken
558
+ : webhookUrl}
559
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
560
+ />
561
+ <button
562
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
563
+ onclick=${async () => {
564
+ try {
565
+ await navigator.clipboard.writeText(
566
+ authMode === "query"
567
+ ? webhookUrlWithQueryToken
568
+ : webhookUrl,
569
+ );
570
+ showToast("Webhook URL copied", "success");
571
+ } catch {
572
+ showToast("Could not copy URL", "error");
573
+ }
574
+ }}
575
+ >
576
+ Copy
577
+ </button>
578
+ </div>
579
+ </div>
580
+ ${authMode === "headers"
581
+ ? html`
582
+ <div class="space-y-2">
583
+ <p class="text-xs text-gray-500">Auth headers</p>
584
+ <div class="flex items-center gap-2">
585
+ <input
586
+ type="text"
587
+ readonly
588
+ value=${authHeaderValue}
589
+ class="h-8 flex-1 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono"
590
+ />
591
+ <button
592
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0"
593
+ onclick=${async () => {
594
+ try {
595
+ await navigator.clipboard.writeText(
596
+ bearerTokenValue,
597
+ );
598
+ showToast("Bearer token copied", "success");
599
+ } catch {
600
+ showToast(
601
+ "Could not copy bearer token",
602
+ "error",
603
+ );
604
+ }
605
+ }}
606
+ >
607
+ Copy
608
+ </button>
609
+ </div>
610
+ </div>
611
+ `
612
+ : html`
613
+ <p class="text-xs text-yellow-300">
614
+ Always use auth headers when possible. Query string is
615
+ less secure.
616
+ </p>
617
+ `}
618
+ </div>
619
+
620
+ <div
621
+ class="bg-black/20 border border-border rounded-lg p-3 space-y-2"
622
+ >
623
+ <p class="text-xs text-gray-500">Test webhook</p>
624
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
625
+ <input
626
+ type="text"
627
+ readonly
628
+ value=${activeCurlCommand}
629
+ class="h-8 w-full sm:flex-1 sm:min-w-0 bg-black/30 border border-border rounded-lg px-3 text-xs text-gray-200 outline-none font-mono overflow-x-auto scrollbar-hidden"
630
+ />
631
+ <div
632
+ class="grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center"
633
+ >
634
+ <button
635
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0"
636
+ onclick=${async () => {
637
+ try {
638
+ await navigator.clipboard.writeText(
639
+ activeCurlCommand,
640
+ );
641
+ showToast("curl command copied", "success");
642
+ } catch {
643
+ showToast("Could not copy curl command", "error");
644
+ }
645
+ }}
646
+ >
647
+ Copy
648
+ </button>
649
+ <button
650
+ class="h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60"
651
+ onclick=${handleSendTestWebhook}
652
+ disabled=${sendingTestWebhook}
653
+ >
654
+ ${sendingTestWebhook ? "Sending..." : "Send"}
655
+ </button>
656
+ </div>
657
+ </div>
658
+ </div>
659
+
660
+ <div class="bg-black/20 border border-border rounded-lg p-3">
661
+ <div class="flex items-center gap-2 text-xs text-gray-300">
662
+ <span class="text-gray-500">Transform:</span>
663
+ <code
664
+ class="flex-1 min-w-0 truncate block"
665
+ title=${selectedWebhook?.transformPath || "—"}
666
+ >${selectedWebhook?.transformPath || "—"}</code
667
+ >
668
+ <span
669
+ class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${
670
+ selectedWebhook?.transformExists
671
+ ? "border-green-500/30 text-green-300 bg-green-500/10"
672
+ : "border-yellow-500/30 text-yellow-300 bg-yellow-500/10"
673
+ }`}
674
+ >
675
+ <span class="font-sans text-sm leading-none">
676
+ ${selectedWebhook?.transformExists ? "✓" : "!"}
677
+ </span>
678
+ ${selectedWebhook?.transformExists
679
+ ? null
680
+ : html`<span>missing</span>`}
681
+ </span>
682
+ </div>
683
+ </div>
684
+
685
+ <div class="flex items-center justify-between gap-3">
686
+ <p class="text-xs text-gray-600">
687
+ Created: ${formatDateTime(selectedWebhook?.createdAt)}
688
+ </p>
689
+ <${ActionButton}
690
+ onClick=${() => {
691
+ if (deleting) return;
692
+ setDeleteTransformDir(true);
693
+ setShowDeleteConfirm(true);
694
+ }}
695
+ disabled=${deleting}
696
+ loading=${deleting}
697
+ tone="danger"
698
+ size="sm"
699
+ idleLabel="Delete"
700
+ loadingLabel="Deleting..."
701
+ className="shrink-0 px-2.5 py-1"
702
+ />
703
+ </div>
704
+ </div>
705
+
706
+ <div
707
+ class="bg-surface border border-border rounded-xl p-4 space-y-3"
708
+ >
709
+ <div class="flex items-center justify-between gap-3">
710
+ <h3 class="font-semibold text-sm">Request history</h3>
711
+ <div class="flex items-center gap-2">
712
+ ${kStatusFilters.map(
713
+ (filter) => html`
714
+ <button
715
+ class="text-xs px-2 py-1 rounded border ${statusFilter ===
716
+ filter
717
+ ? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
718
+ : "border-border text-gray-400 hover:text-gray-200"}"
719
+ onclick=${() => {
720
+ setStatusFilter(filter);
721
+ setExpandedRows(new Set());
722
+ setTimeout(() => requestsPoll.refresh(), 0);
723
+ }}
724
+ >
725
+ ${filter}
726
+ </button>
727
+ `,
728
+ )}
729
+ </div>
730
+ </div>
731
+
732
+ ${requests.length === 0
733
+ ? html`<p class="text-sm text-gray-500">
734
+ No requests logged yet.
735
+ </p>`
736
+ : html`
737
+ <div class="divide-y divide-border">
738
+ ${requests.map(
739
+ (item) => html`
740
+ <div class="py-2">
741
+ <button
742
+ class="w-full text-left"
743
+ onclick=${() => toggleRow(item.id)}
744
+ >
745
+ <div
746
+ class="flex items-center justify-between gap-3"
747
+ >
748
+ <div class="text-xs text-gray-300">
749
+ ${formatLastReceived(item.createdAt)}
750
+ </div>
751
+ <div class="flex items-center gap-2">
752
+ <span class="text-xs text-gray-500"
753
+ >${formatBytes(item.payloadSize)}</span
754
+ >
755
+ <span
756
+ class="text-[11px] px-2 py-0.5 rounded ${statusBadgeClass(
757
+ item.status,
758
+ )}"
759
+ >
760
+ ${item.status}
761
+ </span>
762
+ </div>
763
+ </div>
764
+ </button>
765
+ ${expandedRows.has(item.id)
766
+ ? html`
767
+ <div class="mt-2 space-y-3">
768
+ <div>
769
+ <p class="text-[11px] text-gray-500 mb-1">
770
+ Headers
771
+ </p>
772
+ <pre
773
+ class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
774
+ >
775
+ ${jsonPretty(item.headers)}</pre
776
+ >
777
+ <div class="mt-2 flex justify-start">
778
+ <button
779
+ class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
780
+ onclick=${() =>
781
+ handleCopyRequestField(
782
+ jsonPretty(item.headers),
783
+ "Headers",
784
+ )}
785
+ >
786
+ Copy
787
+ </button>
788
+ </div>
789
+ </div>
790
+ <div>
791
+ <p class="text-[11px] text-gray-500 mb-1">
792
+ Payload
793
+ ${item.payloadTruncated
794
+ ? "(truncated)"
795
+ : ""}
796
+ </p>
797
+ <pre
798
+ class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
799
+ >
800
+ ${jsonPretty(item.payload)}</pre
801
+ >
802
+ <div class="mt-2 flex justify-start gap-2">
803
+ <button
804
+ class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
805
+ onclick=${() =>
806
+ handleCopyRequestField(
807
+ item.payload,
808
+ "Payload",
809
+ )}
810
+ >
811
+ Copy
812
+ </button>
813
+ <button
814
+ class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60"
815
+ onclick=${() =>
816
+ handleReplayRequest(item)}
817
+ disabled=${item.payloadTruncated ||
818
+ replayingRequestId === item.id}
819
+ title=${item.payloadTruncated
820
+ ? "Cannot replay truncated payload"
821
+ : "Replay this payload"}
822
+ >
823
+ ${replayingRequestId === item.id
824
+ ? "Replaying..."
825
+ : "Replay"}
826
+ </button>
827
+ </div>
828
+ </div>
829
+ <div>
830
+ <p class="text-[11px] text-gray-500 mb-1">
831
+ Gateway response
832
+ (${item.gatewayStatus || "n/a"})
833
+ </p>
834
+ <pre
835
+ class="text-xs bg-black/30 border border-border rounded p-2 overflow-auto"
836
+ >
837
+ ${jsonPretty(item.gatewayBody)}</pre
838
+ >
839
+ <div class="mt-2 flex justify-start">
840
+ <button
841
+ class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
842
+ onclick=${() =>
843
+ handleCopyRequestField(
844
+ item.gatewayBody,
845
+ "Gateway response",
846
+ )}
847
+ >
848
+ Copy
849
+ </button>
850
+ </div>
851
+ </div>
852
+ </div>
853
+ `
854
+ : null}
855
+ </div>
856
+ `,
857
+ )}
858
+ </div>
859
+ `}
860
+ </div>
861
+ `
862
+ : html`
863
+ <div
864
+ class="bg-surface border border-border rounded-xl p-4 space-y-4"
865
+ >
866
+ ${isListLoading
867
+ ? html`<p class="text-xs text-gray-500">Loading webhooks...</p>`
868
+ : null}
869
+ ${!isListLoading && webhooks.length === 0
870
+ ? html`<p class="text-sm text-gray-500">
871
+ No webhooks configured yet. Create one to get started.
872
+ </p>`
873
+ : null}
874
+ ${webhooks.length > 0
875
+ ? html`
876
+ <div class="overflow-auto">
877
+ <table class="w-full text-sm">
878
+ <thead>
879
+ <tr
880
+ class="text-left text-xs text-gray-500 border-b border-border"
881
+ >
882
+ <th class="pb-2 pr-3">Path</th>
883
+ <th class="pb-2 pr-3">Last received</th>
884
+ <th class="pb-2 pr-3">Errors</th>
885
+ <th class="pb-2 pr-3">Health</th>
886
+ </tr>
887
+ </thead>
888
+ <tbody>
889
+ <tr aria-hidden="true">
890
+ <td class="h-2 p-0" colspan="4"></td>
891
+ </tr>
892
+ ${webhooks.map(
893
+ (item) => html`
894
+ <tr
895
+ class="group cursor-pointer"
896
+ onclick=${() => {
897
+ onSelectHook(item.name);
898
+ setStatusFilter("all");
899
+ setExpandedRows(new Set());
900
+ }}
901
+ >
902
+ <td
903
+ class="px-3 py-2.5 group-hover:bg-white/5 first:rounded-l-lg transition-colors"
904
+ >
905
+ <code
906
+ >${item.path || `/hooks/${item.name}`}</code
907
+ >
908
+ </td>
909
+ <td
910
+ class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
911
+ >
912
+ ${formatLastReceived(item.lastReceived)}
913
+ </td>
914
+ <td
915
+ class="px-3 py-2.5 text-xs group-hover:bg-white/5 transition-colors"
916
+ >
917
+ ${item.errorCount || 0}
918
+ </td>
919
+ <td
920
+ class="px-3 py-2.5 group-hover:bg-white/5 last:rounded-r-lg transition-colors"
921
+ >
922
+ <span
923
+ class="inline-block w-2.5 h-2.5 rounded-full ${healthClassName(
924
+ item.health,
925
+ )}"
926
+ title=${item.health}
927
+ />
928
+ </td>
929
+ </tr>
930
+ `,
931
+ )}
932
+ </tbody>
933
+ </table>
934
+ </div>
935
+ `
936
+ : null}
937
+ </div>
938
+ `}
939
+
940
+ <${CreateWebhookModal}
941
+ visible=${isCreating && !selectedHookName}
942
+ name=${newName}
943
+ onNameChange=${setNewName}
944
+ canCreate=${canCreate}
945
+ creating=${creating}
946
+ onCreate=${handleCreate}
947
+ onClose=${() => setIsCreating(false)}
948
+ />
949
+ <${ConfirmDialog}
950
+ visible=${showDeleteConfirm && !!selectedHookName}
951
+ title="Delete webhook?"
952
+ message=${`This removes "/hooks/${selectedHookName}" from openclaw.json.`}
953
+ details=${html`
954
+ <div class="rounded-lg border border-border bg-black/20 p-3">
955
+ <label
956
+ class="flex items-center gap-2 text-xs text-gray-300 select-none"
957
+ >
958
+ <input
959
+ type="checkbox"
960
+ checked=${deleteTransformDir}
961
+ onInput=${(event) =>
962
+ setDeleteTransformDir(!!event.target.checked)}
963
+ />
964
+ Also delete <code>hooks/transforms/${selectedHookName}</code>
965
+ </label>
966
+ </div>
967
+ `}
968
+ confirmLabel="Delete webhook"
969
+ confirmLoadingLabel="Deleting..."
970
+ confirmLoading=${deleting}
971
+ cancelLabel="Cancel"
972
+ onCancel=${() => {
973
+ if (deleting) return;
974
+ setDeleteTransformDir(true);
975
+ setShowDeleteConfirm(false);
976
+ }}
977
+ onConfirm=${handleDeleteConfirmed}
978
+ />
979
+ </div>
980
+ `;
981
+ };