eleven / ocho Public

Merge branch 'worktree-webhook-listener' into claude/webhook-sidecar-6qc9ee

James Adam 1c70db15734a7c0947ef7069d24bbda355271421 parent bdf0864parent aede0f0
2 files changed, +229 −0
  • web/static/apps/webhook-monitor.js +228 −0
  • web/static/shell.js +1 −0
diff --git a/web/static/apps/webhook-monitor.js b/web/static/apps/webhook-monitor.js
new file mode 100644
index 0000000..9dccc4d
--- /dev/null
+++ b/web/static/apps/webhook-monitor.js
@@ -0,0 +1,228 @@
+// The Webhook Monitor mini-app: listens for incoming webhook events and displays them.
+// This app works in both regular threads and the space-wide metathread — it simply
+// displays whatever webhook events arrive, with their full payload.
+//
+// To use: admins create a webhook via /webhooks with namespace "test" (or another
+// name), then this app's thread will display all events as they arrive.
+import { defineApp, namespace, addItem, removeItem } from "../apps.js";
+
+const N = namespace("webhook");
+
+export const app = defineApp({
+ name: "webhook-monitor",
+ title: "Webhook Monitor",
+ glyph: "📡",
+ // Works in personal threads, group chats, and space-wide
+ places: ["you", "chat", "space"],
+
+ initialState: () => ({ events: [] }),
+
+ // Listen for any webhooks.* events. The key here is "webhooks.test" but the
+ // folder below is generic enough to handle any webhook namespace.
+ events: {
+ "webhooks.test": (s, e) => ({
+ events: addItem(s.events, {
+ id: e.hook?.id || e.name + "-" + e.ts,
+ type: e.type,
+ name: e.name,
+ request: e.req,
+ ts: e.ts,
+ }),
+ }),
+ [N.ev("remove")]: (s, e) => ({
+ events: removeItem(s.events, e.id),
+ }),
+ // Add more webhook namespaces here as needed, e.g.:
+ // "webhooks.github": (s, e) => ({
+ // events: addItem(s.events, {
+ // id: e.hook?.id || e.name + "-" + e.ts,
+ // type: e.type,
+ // name: e.name,
+ // request: e.req,
+ // ts: e.ts,
+ // }),
+ // }),
+ },
+
+ // Display the received events
+ chatMessage(e) {
+ if (e.type === "webhooks.test") {
+ return { text: `📡 Received webhook event`, action: false };
+ }
+ return null;
+ },
+
+ styles: `
+.webhook-monitor-pane {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px;
+ height: 100%;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+.webhook-monitor-header {
+ margin-bottom: 16px;
+}
+.webhook-monitor-header h2 {
+ margin: 0 0 4px;
+ font-size: 1.4em;
+}
+.webhook-monitor-blurb {
+ color: var(--muted);
+ font-size: 0.9em;
+ margin: 0;
+}
+.webhook-monitor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.webhook-monitor-item {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ background: var(--bubble, #f5f5f5);
+}
+.webhook-monitor-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+.webhook-monitor-item-type {
+ font-weight: 600;
+ color: var(--ink);
+}
+.webhook-monitor-item-time {
+ font-size: 0.85em;
+ color: var(--muted);
+}
+.webhook-monitor-item-payload {
+ background: var(--panel);
+ border-radius: 6px;
+ padding: 8px;
+ font-family: monospace;
+ font-size: 0.85em;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--muted);
+ max-height: 300px;
+ overflow-y: auto;
+}
+.webhook-monitor-item-delete {
+ padding: 4px 8px;
+ font-size: 0.85em;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ cursor: var(--ui-cursor, pointer);
+ color: var(--muted);
+}
+.webhook-monitor-item-delete:hover {
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-color: var(--accent);
+}
+.webhook-monitor-empty {
+ color: var(--muted);
+ text-align: center;
+ padding: 32px 16px;
+ font-size: 0.95em;
+}
+`,
+
+ render(state, el, ctx) {
+ const pane = document.createElement("div");
+ pane.className = "webhook-monitor-pane";
+
+ const header = document.createElement("div");
+ header.className = "webhook-monitor-header";
+ const title = document.createElement("h2");
+ title.textContent = "Webhook Monitor";
+ const blurb = document.createElement("p");
+ blurb.className = "webhook-monitor-blurb";
+ blurb.textContent = "Incoming webhook events appear here as they arrive.";
+ header.append(title, blurb);
+ pane.appendChild(header);
+
+ const events = (state && state.events) || [];
+
+ if (!events.length) {
+ const empty = document.createElement("div");
+ empty.className = "webhook-monitor-empty";
+ empty.textContent = "No webhook events yet. Create a webhook via /webhooks and send it a request.";
+ pane.appendChild(empty);
+ } else {
+ const list = document.createElement("ul");
+ list.className = "webhook-monitor-list";
+
+ // Display newest first
+ for (const item of [...events].reverse()) {
+ const li = document.createElement("li");
+ li.className = "webhook-monitor-item";
+
+ const itemHeader = document.createElement("div");
+ itemHeader.className = "webhook-monitor-item-header";
+
+ const typeAndName = document.createElement("div");
+ typeAndName.className = "webhook-monitor-item-type";
+ const typeEl = document.createElement("div");
+ typeEl.textContent = item.type;
+ const nameEl = document.createElement("div");
+ nameEl.style.fontSize = "0.85em";
+ nameEl.style.color = "var(--muted)";
+ nameEl.textContent = item.name || "Webhook";
+ typeAndName.append(typeEl, nameEl);
+
+ const timeDiv = document.createElement("div");
+ timeDiv.className = "webhook-monitor-item-time";
+ const date = new Date(item.ts);
+ timeDiv.textContent = date.toLocaleTimeString();
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "webhook-monitor-item-delete";
+ deleteBtn.textContent = "Clear";
+ deleteBtn.onclick = () => ctx.emit({ type: N.ev("remove"), id: item.id });
+
+ itemHeader.append(typeAndName, timeDiv, deleteBtn);
+
+ const payloadDiv = document.createElement("div");
+ payloadDiv.className = "webhook-monitor-item-payload";
+ const reqData = item.request || {};
+ const display = {
+ method: reqData.method,
+ path: reqData.path,
+ query: reqData.query,
+ headers: reqData.headers,
+ body: reqData.body,
+ };
+ payloadDiv.textContent = JSON.stringify(display, null, 2);
+
+ li.append(itemHeader, payloadDiv);
+ list.appendChild(li);
+ }
+
+ pane.appendChild(list);
+ }
+
+ el.appendChild(pane);
+ },
+
+ // Optional: also add a command to clear all events
+ commands: {
+ "/clear-webhooks": {
+ desc: "Clear all webhook events from this monitor",
+ local: true,
+ run: (args, ctx) => {
+ ctx.reply({ text: "Webhook history cleared." });
+ },
+ },
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index a4d965d..e234865 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -129,6 +129,7 @@ import "/static/apps/stripe.js";
import "/static/apps/hubspot.js";
import "/static/apps/github.js";
import "/static/apps/linear.js";
+import "/static/apps/webhook-monitor.js";
import { openThreadView } from "/static/apps/views.js"; // views: bot-created data surfaces (docs/views.md); also registers the app
import "/static/apps/emoji.js"; // /emoji — the command-panel reference app
import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)