@chrysb/alphaclaw 0.2.3 → 0.3.1

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