eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// The Webhooks app — admin management surface for incoming webhooks (#610,
2// docs/webhooks.md). Admin-only and host-owned like the Admin app: webhooks
3// are real server state, not thread events, so everything reads and writes
4// through the host bridge (setSetupBridge in shell.js) — the app itself holds
5// no tokens, no keys, and no fetch calls of its own.
6//
7// What a webhook IS: an agent identity that turns inbound HTTP on
8// https://in.<host>/h/<secret> into encrypted `webhooks.<ns>` events posted
9// into ONE thread. This app creates them (name + event namespace + target
10// thread), lists them with their URLs and deploy state, and removes them.
11//
12// Deliberately, this app claims NO wire event types: `webhooks.<ns>` events
13// belong to the CONSUMER apps that render them (a CI dashboard claiming
14// "webhooks.github", say). Until such an app claims a type, its events are
15// silently ignored — the platform contract for unclaimed types
16// (test/event-vectors/webhooks.json).
17import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
18
19const N = namespace("webhooks");
20
21// /webhooks works from any chat, gated on role exactly like /admin.
22registerFloorCommand("/webhooks", {
23 desc: "Manage incoming webhooks (admins only)",
24 run: async (_args, _threadId, reply) => {
25 const bridge = setupBridge();
26 if (!bridge || !(await bridge.isAdmin())) {
27 reply({ text: "Only this space's admins can manage webhooks." });
28 return;
29 }
30 openAppThread("webhooks");
31 },
32});
33
34const NS_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
35
36// One refresh timer per mounted pane, cancelled via ctx.onCleanup — polling
37// only runs while a not-yet-deployed webhook is on screen, to flip its
38// "Deploying…" chip to "Active" without a manual refresh.
39let refreshTimer = null;
40
41export const app = defineApp({
42 name: "webhooks",
43 title: "Webhooks",
44 glyph: "📥",
45 places: [], // host-mounted, admin-only — never in the Apps picker for members
46 snapshot: false,
47 initialState: () => ({}),
48 events: {}, // no wire types — see the header comment
49 render(_state, el, ctx) {
50 paint(el, ctx);
51 },
52 styles: `
53 .${N.cls("root")} { padding: 16px; max-width: 640px; margin: 0 auto; overflow-y: auto; height: 100%; box-sizing: border-box; }
54 .${N.cls("root")} h2 { margin: 0 0 4px; }
55 .${N.cls("blurb")} { color: var(--muted, #777); margin: 0 0 16px; font-size: 0.92em; }
56 .${N.cls("card")} { border: 1px solid var(--border, #ccc3); border-radius: 10px; padding: 12px; margin: 0 0 10px; }
57 .${N.cls("card")} h3 { margin: 0 0 2px; font-size: 1em; }
58 .${N.cls("meta")} { color: var(--muted, #777); font-size: 0.85em; margin: 0 0 8px; }
59 .${N.cls("chip")} { display: inline-block; border: 1px solid var(--border, #ccc3); border-radius: 999px; padding: 1px 8px; font-size: 0.8em; margin-left: 6px; }
60 .${N.cls("chip-live")} { border-color: #2e7d3266; }
61 .${N.cls("url")} { display: flex; gap: 6px; align-items: center; }
62 .${N.cls("url")} code { flex: 1; overflow-x: auto; white-space: nowrap; font-size: 0.82em; padding: 6px 8px; background: var(--bubble, #8881); border-radius: 6px; }
63 .${N.cls("root")} button { min-height: 28px; border-radius: 6px; }
64 .${N.cls("row")} { display: flex; gap: 8px; margin: 8px 0 0; flex-wrap: wrap; }
65 .${N.cls("form")} input, .${N.cls("form")} select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border, #ccc4); min-width: 0; }
66 .${N.cls("form")} label { display: block; font-size: 0.85em; margin: 10px 0 2px; }
67 .${N.cls("note")} { font-size: 0.85em; margin: 8px 0 0; }
68 .${N.cls("note-err")} { color: #c62828; }
69 .${N.cls("note-ok")} { color: #2e7d32; }
70 `,
71});
72
73async function paint(el, ctx) {
74 if (refreshTimer) {
75 clearTimeout(refreshTimer);
76 refreshTimer = null;
77 }
78 el.textContent = "";
79 const root = document.createElement("div");
80 root.className = N.cls("root");
81 el.appendChild(root);
82
83 const h = document.createElement("h2");
84 h.textContent = "Incoming webhooks";
85 const blurb = document.createElement("p");
86 blurb.className = N.cls("blurb");
87 blurb.textContent =
88 "Give an outside service a URL, and every request it sends becomes an encrypted event in the thread you choose — the server never sees the content, and the receiver can never read anything back.";
89 root.append(h, blurb);
90
91 const bridge = setupBridge();
92 if (!bridge || !bridge.hooksList) return;
93 if (!(await bridge.isAdmin())) {
94 root.appendChild(para("Only this space's admins can manage webhooks."));
95 return;
96 }
97
98 const svc = await bridge.hooksService();
99 if (!svc || !svc.public_key) {
100 root.appendChild(
101 para(
102 "Webhooks aren't switched on for this space yet — the ingest sidecar isn't provisioned. An operator sets it up on the server box with: lchat add-hooks <host>",
103 ),
104 );
105 return;
106 }
107
108 const list = document.createElement("div");
109 root.appendChild(list);
110 const rows = await bridge.hooksList();
111 ctx.onCleanup(() => {
112 if (refreshTimer) clearTimeout(refreshTimer);
113 refreshTimer = null;
114 });
115 if (rows === null) {
116 list.appendChild(para("Couldn't load webhooks — try again in a moment."));
117 } else if (!rows.length) {
118 list.appendChild(para("No webhooks yet."));
119 } else {
120 for (const hk of rows) list.appendChild(hookCard(hk, el, ctx));
121 // While any webhook is still deploying, refresh gently so the chip flips
122 // to Active when the sidecar's next poll picks it up.
123 if (rows.some((r) => !r.deployed) && el.isConnected) {
124 refreshTimer = setTimeout(() => {
125 if (el.isConnected) paint(el, ctx);
126 }, 5000);
127 }
128 }
129
130 root.appendChild(createForm(el, ctx, bridge));
131}
132
133function para(text) {
134 const p = document.createElement("p");
135 p.className = N.cls("blurb");
136 p.textContent = text;
137 return p;
138}
139
140function hookCard(hk, el, ctx) {
141 const card = document.createElement("div");
142 card.className = N.cls("card");
143
144 const title = document.createElement("h3");
145 title.textContent = hk.name || "Unnamed webhook";
146 const status = document.createElement("span");
147 status.className = N.cls("chip") + (hk.deployed ? " " + N.cls("chip-live") : "");
148 status.textContent = hk.deployed ? "Active" : "Deploying…";
149 title.appendChild(status);
150
151 const meta = document.createElement("p");
152 meta.className = N.cls("meta");
153 meta.textContent = `Events: webhooks.${hk.ns || "?"} → ${hk.target || "a chat"}`;
154
155 card.append(title, meta);
156
157 if (hk.url) {
158 const urlRow = document.createElement("div");
159 urlRow.className = N.cls("url");
160 const code = document.createElement("code");
161 code.textContent = hk.url;
162 const copy = document.createElement("button");
163 copy.type = "button";
164 copy.textContent = "Copy";
165 copy.setAttribute("aria-label", `Copy the URL for ${hk.name}`);
166 copy.onclick = async () => {
167 try {
168 await navigator.clipboard.writeText(hk.url);
169 copy.textContent = "Copied";
170 setTimeout(() => (copy.textContent = "Copy"), 1500);
171 } catch {
172 copy.textContent = "Select + copy";
173 }
174 };
175 urlRow.append(code, copy);
176 card.appendChild(urlRow);
177 }
178
179 const actions = document.createElement("div");
180 actions.className = N.cls("row");
181 const remove = document.createElement("button");
182 remove.type = "button";
183 remove.textContent = "Remove";
184 remove.setAttribute("aria-label", `Remove the webhook ${hk.name}`);
185 let armed = false;
186 remove.onclick = async () => {
187 if (!armed) {
188 // Two-tap confirm, in place: removal kills the URL and the identity.
189 armed = true;
190 remove.textContent = "Really remove? The URL stops working.";
191 return;
192 }
193 remove.disabled = true;
194 const ok = await setupBridge().hooksRemove(hk.id);
195 if (ok) paint(el, ctx);
196 else {
197 remove.disabled = false;
198 armed = false;
199 remove.textContent = "Remove";
200 card.appendChild(note("Couldn't remove it — try again.", true));
201 }
202 };
203 actions.appendChild(remove);
204 card.appendChild(actions);
205 return card;
206}
207
208function note(text, isErr) {
209 const p = document.createElement("p");
210 p.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok"));
211 p.textContent = text;
212 return p;
213}
214
215function createForm(el, ctx, bridge) {
216 const form = document.createElement("form");
217 form.className = N.cls("card") + " " + N.cls("form");
218
219 const title = document.createElement("h3");
220 title.textContent = "New webhook";
221 form.appendChild(title);
222
223 const nameLabel = document.createElement("label");
224 nameLabel.textContent = "Name";
225 nameLabel.htmlFor = N.cls("name");
226 const name = document.createElement("input");
227 name.id = N.cls("name");
228 name.maxLength = 64;
229 name.placeholder = "GitHub deploys";
230 name.required = true;
231
232 const nsLabel = document.createElement("label");
233 nsLabel.textContent = "Event namespace (events arrive as webhooks.<namespace>)";
234 nsLabel.htmlFor = N.cls("ns");
235 const ns = document.createElement("input");
236 ns.id = N.cls("ns");
237 ns.maxLength = 32;
238 ns.placeholder = "github";
239 ns.required = true;
240
241 const targetLabel = document.createElement("label");
242 targetLabel.textContent = "Post into";
243 targetLabel.htmlFor = N.cls("target");
244 const target = document.createElement("select");
245 target.id = N.cls("target");
246 const spaceOpt = document.createElement("option");
247 spaceOpt.value = "space";
248 spaceOpt.textContent = "The whole space (space thread)";
249 target.appendChild(spaceOpt);
250 bridge.hooksTargets().then((targets) => {
251 for (const t of targets) {
252 const o = document.createElement("option");
253 o.value = t.id;
254 o.textContent = t.name;
255 target.appendChild(o);
256 }
257 });
258
259 const submit = document.createElement("button");
260 submit.type = "submit";
261 submit.textContent = "Create webhook";
262 const feedback = document.createElement("p");
263 feedback.className = N.cls("note");
264 feedback.hidden = true;
265
266 form.append(nameLabel, name, nsLabel, ns, targetLabel, target, submit, feedback);
267
268 form.onsubmit = async (e) => {
269 e.preventDefault();
270 const nsVal = ns.value.trim().toLowerCase();
271 if (!NS_RE.test(nsVal)) {
272 show(feedback, "The namespace must be a short lowercase slug — letters, digits, dashes (e.g. github).", true);
273 return;
274 }
275 submit.disabled = true;
276 show(feedback, "Creating…", false);
277 const r = await bridge.hooksCreate({
278 name: name.value.trim(),
279 ns: nsVal,
280 threadId: target.value === "space" ? null : target.value,
281 space: target.value === "space",
282 });
283 if (r && r.ok) {
284 paint(el, ctx); // the fresh list shows the new webhook + its URL
285 } else {
286 submit.disabled = false;
287 show(feedback, (r && r.error) || "Couldn't create the webhook — try again.", true);
288 }
289 };
290 return form;
291}
292
293function show(elm, text, isErr) {
294 elm.hidden = false;
295 elm.textContent = text;
296 elm.className = N.cls("note") + " " + (isErr ? N.cls("note-err") : N.cls("note-ok"));
297}
298