Add webhook framework and Throne wishlist plugin

This commit is contained in:
Franz Rolfsvaag 2026-06-10 18:57:38 +02:00
parent 6af21664e8
commit 5588819df4
18 changed files with 2212 additions and 2 deletions

View File

@ -110,6 +110,8 @@ Plugins (important)
- index.js exports { id, init(...) } - index.js exports { id, init(...) }
- web.mount("/plugins/<id>", router, navItem) to add nav entry - web.mount("/plugins/<id>", router, navItem) to add nav entry
- Plugins should avoid core edits unless explicitly requested - Plugins should avoid core edits unless explicitly requested
- Plugins receive `webhooks` for raw-body inbound endpoint registration and
outbound webhook sending. See `docs/webhooks.md`.
Current notable plugins Current notable plugins
- echonomy-framework: - echonomy-framework:

46
docs/webhooks.md Normal file
View File

@ -0,0 +1,46 @@
# Plugin Webhook Framework
Lumi exposes a webhook service to enabled plugins as `webhooks` in their
`init` context and as `global.lumiFrameworks.webhooks`.
## Inbound Webhooks
```js
init({ webhooks }) {
webhooks.registerInbound({
pluginId: "example",
endpointId: "primary",
namespace: "example",
slug: webhooks.generateSlug({ identifier: "alerts" }),
handler: async ({ rawBody, rawBodyText, parsedJson }) => {
return { status: 204 };
}
});
}
```
Endpoints are mounted below `/webhooks/:namespace/:slug`, accept `POST` by
default, and have a 256 KB request limit. Handlers receive the exact raw body as
a `Buffer` and UTF-8 text. Provider signature verification must use `rawBody`
or `rawBodyText`, never a re-serialized `parsedJson` object.
Use `unregisterInbound({ pluginId, endpointId })` before replacing or removing
an endpoint. `generateSlug`, `buildPublicUrl`, and
`isTimestampWithinWindow` support common endpoint and replay-protection work.
## Outbound Webhooks
```js
const result = await webhooks.sendJson({
pluginId: "example",
url: "https://example.test/hooks",
payload: { event: "example" },
timeoutMs: 10000,
retries: 1
});
```
`send` supports strings, buffers, custom headers, methods, retries, and an
optional signing callback. Results include status, response headers, response
body text, duration, and a success flag. Query strings and authorization
headers are not written to Lumi logs.

View File

@ -0,0 +1,63 @@
# Throne Wishlist
This plugin receives signed Throne wishlist events through Lumi's core webhook
framework, logs every payload, deduplicates by `event_id`, and can publish
messages to configured platform destinations.
## Setup
1. Install the core webhook framework patch.
2. Install and enable this plugin.
3. Open **Plugins -> Throne Wishlist** as an administrator.
4. Create an endpoint with a short identifier.
5. Copy the generated `/webhooks/throne/{identifier}-{uuid}` URL into Throne's
webhook integration.
Throne requires an HTTPS subscriber URL in production. Configure Lumi behind an
HTTPS reverse proxy and set `public_base_url` in Lumi settings when available.
## Security and Delivery
Throne requests are verified with the Ed25519 public key published in Throne's
current webhook documentation. Verification uses
`X-Signature-Timestamp + "." + the exact raw request body`, with a five-minute
replay window. Invalid signatures never trigger platform messages.
Authentic duplicate `event_id` values receive a successful response without
repeating side effects. Supported events are `gift_purchased`,
`contribution_purchased`, and `gift_crowdfunded`. Unknown authentic events are
logged and acknowledged without sending messages.
## Debug Mode
The admin Debug modal uses a transient Server-Sent Events session. It only shows
payloads received after the session starts. Closing the modal or disconnecting
ends the session. Sessions expire after five minutes without payloads and after
30 minutes total. Debug payloads are not stored separately; all webhook payloads
are already written to Lumi's internal logs.
## Destinations
- Discord requires a regular guild text channel where the bot can view and send.
- Twitch uses the first connected Lumi Twitch channel.
- YouTube uses Lumi's active live-chat sender.
- Kick remains unavailable until Lumi implements a send-message capability.
All destinations default to disabled. A failure on one destination does not
prevent delivery attempts to other enabled destinations.
## Placeholders
Common placeholders:
`{contract_version}`, `{event_id}`, `{event_type}`,
`{webhook_identifier}`, `{webhook_uuid}`, `{received_at}`,
`{received_at_iso}`, `{creator_id}`, `{creator_username}`,
`{gifter_username}`, `{message}`, `{item_name}`,
`{item_thumbnail_url}`, `{price}`, `{amount}`, `{currency}`,
`{is_surprise_gift}`, `{price_display}`, `{amount_display}`, `{value}`,
`{value_display}`, and `{payload_json}`.
Documented data fields are also available with a `data.` prefix, such as
`{data.item_name}`. Unknown placeholders remain unchanged in rendered messages.
The admin page warns when they are saved.

View File

@ -0,0 +1,4 @@
{
"pluginId": "throne_wishlist",
"commands": []
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"id": "throne_wishlist",
"name": "Throne Wishlist",
"version": "0.1.0",
"description": "Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages.",
"main": "index.js"
}

View File

@ -0,0 +1,153 @@
.throne-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.throne-header h1,
.throne-header p {
margin: 0;
}
.diagnostic-grid,
.destination-grid,
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.diagnostic-grid > div,
.destination-panel,
.template-panel {
border: 1px solid var(--border);
background: var(--surface-2);
padding: 14px;
border-radius: 8px;
}
.diagnostic-grid span,
.diagnostic-grid small,
.destination-panel small {
display: block;
color: var(--ink-soft);
}
.inline-form,
.copy-row,
.action-row,
.template-heading,
.debug-toolbar {
display: flex;
align-items: end;
gap: 10px;
}
.inline-form .field,
.copy-row input,
.debug-toolbar input {
flex: 1;
}
.throne-table code {
word-break: break-all;
}
.throne-table td small {
display: block;
}
.copy-status {
min-height: 1.2em;
}
.destination-panel {
min-width: 0;
}
.destination-panel legend {
font-weight: 700;
}
.destination-panel[disabled] {
opacity: 0.62;
}
.placeholder-list {
margin-bottom: 18px;
}
.placeholder-list summary {
cursor: pointer;
font-weight: 700;
}
.placeholder-list div {
margin-top: 10px;
line-height: 2;
}
.event-sections {
display: grid;
gap: 20px;
}
.event-section {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.template-panel {
display: grid;
gap: 10px;
}
.template-heading {
justify-content: space-between;
align-items: center;
}
.button.danger {
background: var(--rose);
color: white;
}
.debug-modal {
width: min(900px, calc(100vw - 32px));
}
.debug-toolbar {
align-items: center;
margin-bottom: 12px;
}
.debug-summary {
display: grid;
gap: 4px;
margin-bottom: 12px;
}
.debug-json {
max-height: 52vh;
overflow: auto;
padding: 14px;
border: 1px solid var(--border);
background: var(--surface-3);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 720px) {
.throne-header,
.inline-form,
.action-row {
align-items: stretch;
flex-direction: column;
}
.throne-table {
min-width: 900px;
}
}

View File

@ -0,0 +1,67 @@
(() => {
document.querySelectorAll("[data-copy-button]").forEach((button) => {
button.addEventListener("click", async () => {
const id = button.dataset.copyButton;
const input = document.querySelector(`[data-copy-source="${id}"]`);
const status = document.querySelector(`[data-copy-status="${id}"]`);
if (!input) return;
try {
await navigator.clipboard.writeText(input.value);
if (status) status.textContent = "Copied.";
} catch {
input.focus();
input.select();
const copied = document.execCommand("copy");
if (status) status.textContent = copied ? "Copied." : "Select and copy the URL manually.";
}
});
});
const modal = document.querySelector("[data-confirm-modal]");
const form = modal?.querySelector("[data-confirm-form]");
const heading = modal?.querySelector("[data-confirm-heading]");
const description = modal?.querySelector("[data-confirm-description]");
const submit = modal?.querySelector("[data-confirm-submit]");
let timer = null;
const close = () => {
if (!modal) return;
clearInterval(timer);
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
document.querySelectorAll("[data-confirm-open]").forEach((button) => {
button.addEventListener("click", () => {
if (!modal || !form || !submit) return;
form.action = button.dataset.confirmAction;
heading.textContent = button.dataset.confirmTitle || "Confirm action";
description.textContent = button.dataset.confirmText || "";
let remaining = 3;
submit.disabled = true;
submit.textContent = `Confirm in ${remaining}`;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
timer = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(timer);
submit.disabled = false;
submit.textContent = "Confirm";
submit.focus();
} else {
submit.textContent = `Confirm in ${remaining}`;
}
}, 1000);
});
});
modal?.querySelectorAll("[data-confirm-close]").forEach((button) => {
button.addEventListener("click", close);
});
modal?.addEventListener("click", (event) => {
if (event.target === modal) close();
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal?.classList.contains("is-open")) close();
});
})();

View File

@ -0,0 +1,131 @@
(() => {
const modal = document.querySelector("[data-debug-modal]");
const openButton = document.querySelector("[data-debug-open]");
const closeButton = modal?.querySelector("[data-debug-close]");
const status = modal?.querySelector("[data-debug-status]");
const page = modal?.querySelector("[data-debug-page]");
const slider = modal?.querySelector("[data-debug-slider]");
const previous = modal?.querySelector("[data-debug-prev]");
const next = modal?.querySelector("[data-debug-next]");
const summary = modal?.querySelector("[data-debug-summary]");
const json = modal?.querySelector("[data-debug-json]");
const items = [];
let current = -1;
let sessionId = null;
let source = null;
const render = () => {
const total = items.length;
if (!total) {
current = -1;
page.textContent = "0 / 0";
slider.min = "1";
slider.max = "1";
slider.value = "1";
slider.disabled = true;
previous.disabled = true;
next.disabled = true;
summary.textContent = "No payloads received during this session.";
json.textContent = "{}";
return;
}
current = Math.max(0, Math.min(current, total - 1));
const item = items[current];
page.textContent = `${current + 1} / ${total}`;
slider.min = "1";
slider.max = String(total);
slider.value = String(current + 1);
slider.disabled = false;
previous.disabled = current === 0;
next.disabled = current === total - 1;
summary.innerHTML = "";
[
`Endpoint: ${item.endpointIdentifier}`,
`Event: ${item.eventType || "unknown"}`,
`Event ID: ${item.eventId || "none"}`,
`Authenticity: ${item.authentic ? "valid" : item.authenticityStatus}`
].forEach((text) => {
const line = document.createElement("div");
line.textContent = text;
summary.appendChild(line);
});
json.textContent = JSON.stringify(item.payload || {
raw_body_preview: item.rawBodyPreview
}, null, 2);
};
const stop = async () => {
source?.close();
source = null;
if (sessionId) {
navigator.sendBeacon?.(`/plugins/throne_wishlist/debug/stop/${sessionId}`);
sessionId = null;
}
};
const close = async () => {
await stop();
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
const open = async () => {
items.length = 0;
current = -1;
render();
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
status.textContent = "Starting debug session…";
try {
const response = await fetch("/plugins/throne_wishlist/debug/start", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }
});
if (!response.ok) throw new Error("Unable to start debug session.");
const data = await response.json();
sessionId = data.sessionId;
source = new EventSource(`/plugins/throne_wishlist/debug/stream/${sessionId}`);
source.addEventListener("ready", () => {
status.textContent = "Listening for new Throne payloads.";
});
source.addEventListener("payload", (event) => {
items.push(JSON.parse(event.data));
current = items.length - 1;
status.textContent = "Payload received.";
render();
});
source.addEventListener("close", (event) => {
const data = JSON.parse(event.data || "{}");
status.textContent = `Debug session ended: ${data.reason || "closed"}.`;
source?.close();
});
source.onerror = () => {
status.textContent = "Debug stream disconnected.";
};
} catch (error) {
status.textContent = error.message;
}
};
openButton?.addEventListener("click", open);
closeButton?.addEventListener("click", close);
modal?.addEventListener("click", (event) => {
if (event.target === modal) close();
});
previous?.addEventListener("click", () => {
current -= 1;
render();
});
next?.addEventListener("click", () => {
current += 1;
render();
});
slider?.addEventListener("input", () => {
current = Number(slider.value) - 1;
render();
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal?.classList.contains("is-open")) close();
});
window.addEventListener("beforeunload", stop);
})();

View File

@ -0,0 +1,227 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<link rel="stylesheet" href="/plugins/throne_wishlist/assets/admin.css?v=<%= assetVersion %>" />
<% const destinationMap = new Map(destinations.map((item) => [item.platform, item])); %>
<% const templateMap = new Map(templates.map((item) => [item.event_type + ":" + item.platform, item])); %>
<% const statusMap = new Map(platformStatus.map((item) => [item.id, item])); %>
<header class="throne-header">
<div>
<h1>Throne Wishlist</h1>
<p>Verified wishlist events and cross-platform notifications.</p>
</div>
<button type="button" class="button subtle" data-debug-open>Debug</button>
</header>
<section class="card">
<div class="section-header">
<div>
<h2>Diagnostics</h2>
<p class="hint">Core availability and public delivery readiness.</p>
</div>
</div>
<div class="diagnostic-grid">
<div><span>Webhook framework</span><strong><%= webhookAvailable ? "Available" : "Unavailable" %></strong></div>
<div><span>Public base URL</span><strong><%= publicBaseUrl %></strong></div>
<% platformStatus.forEach((platform) => { %>
<div><span><%= platform.label %></span><strong><%= platform.sendAvailable ? "Ready" : "Unavailable" %></strong><small><%= platform.diagnostic %></small></div>
<% }) %>
</div>
<% if (publicBaseUrlWarning) { %>
<div class="callout"><%= publicBaseUrlWarning %></div>
<% } %>
<% if (!webhookAvailable) { %>
<div class="callout">Install and restart with the core webhook framework patch before creating endpoints.</div>
<% } %>
</section>
<section class="card">
<div class="section-header">
<div>
<h2>Endpoints</h2>
<p class="hint">Create multiple subscriber URLs and paste them into Throne.</p>
</div>
</div>
<form method="post" action="/plugins/throne_wishlist/endpoints/create" class="inline-form">
<div class="field">
<label for="throne-identifier">Identifier</label>
<input id="throne-identifier" name="identifier" required placeholder="cozy-carnage" />
</div>
<button type="submit" class="button" <%= webhookAvailable ? "" : "disabled" %>>Create endpoint</button>
</form>
<% if (!endpoints.length) { %>
<p>No Throne endpoints configured.</p>
<% } else { %>
<div class="table-wrap">
<table class="table throne-table">
<thead>
<tr>
<th>Identifier</th>
<th>UUID</th>
<th>Link</th>
<th>Last payload</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% endpoints.forEach((endpoint) => { %>
<tr>
<td><strong><%= endpoint.identifier %></strong></td>
<td><code><%= endpoint.uuid %></code></td>
<td>
<div class="copy-row">
<input value="<%= endpoint.publicUrl %>" readonly data-copy-source="<%= endpoint.id %>" aria-label="Webhook URL for <%= endpoint.identifier %>" />
<button type="button" class="icon-button" data-copy-button="<%= endpoint.id %>" title="Copy webhook URL" aria-label="Copy webhook URL">
<span aria-hidden="true">⧉</span>
</button>
</div>
<small class="copy-status" data-copy-status="<%= endpoint.id %>" aria-live="polite"></small>
</td>
<td>
<% if (endpoint.last_payload_preview) { %>
<% let preview = {}; try { preview = JSON.parse(endpoint.last_payload_preview); } catch {} %>
<strong><%= preview.event_type || "unknown" %></strong>
<small><%= preview.event_id || "No event id" %></small>
<small><%= preview.authentic ? "Authentic" : "Rejected" %> · <%= formatTimestamp(endpoint.last_payload_at) %></small>
<% } else { %>
<span>Never</span>
<% } %>
</td>
<td>
<div class="action-row">
<button type="button" class="button subtle" data-confirm-open data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button>
<button type="button" class="button subtle danger" data-confirm-open data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<section class="card">
<div class="section-header">
<div>
<h2>Platform Destinations</h2>
<p class="hint">Only destinations with a working Lumi send capability can be enabled.</p>
</div>
</div>
<form method="post" action="/plugins/throne_wishlist/destinations" class="destination-grid">
<% const availablePlatforms = platformStatus.filter((platform) => platform.sendAvailable); %>
<% if (!availablePlatforms.length) { %>
<p>No active platform sender is currently available.</p>
<% } %>
<% availablePlatforms.forEach((platform) => { const destination = destinationMap.get(platform.id); %>
<fieldset class="destination-panel">
<legend><%= platform.label %></legend>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled_platforms" value="<%= platform.id %>" <%= destination?.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= destination?.enabled ? "Enabled" : "Disabled" %></span>
</label>
<% if (platform.id === "discord") { %>
<div class="field">
<label for="discord-destination">Text channel</label>
<select id="discord-destination" name="discord_destination_id">
<option value="">Select a regular text channel</option>
<% discordChannels.forEach((channel) => { %>
<option value="<%= channel.id %>" <%= destination?.destination_id === channel.id ? "selected" : "" %>><%= channel.label %></option>
<% }) %>
</select>
</div>
<% } %>
<small><%= platform.diagnostic %></small>
</fieldset>
<% }) %>
<div class="field full">
<button type="submit" class="button">Save destinations</button>
</div>
</form>
</section>
<section class="card">
<div class="section-header">
<div>
<h2>Event Messages</h2>
<p class="hint">One message per event and platform. Unknown placeholders remain unchanged.</p>
</div>
</div>
<details class="placeholder-list">
<summary>Available placeholders</summary>
<div>
<% placeholders.forEach((placeholder) => { %><code>{<%= placeholder %>}</code> <% }) %>
</div>
</details>
<div class="event-sections">
<% eventTypes.forEach((eventType) => { %>
<section class="event-section">
<h3><%= eventType %></h3>
<div class="template-grid">
<% if (!activePlatforms.length) { %>
<p>No active platform templates are available.</p>
<% } %>
<% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %>
<form method="post" action="/plugins/throne_wishlist/templates" class="template-panel">
<input type="hidden" name="event_type" value="<%= eventType %>" />
<input type="hidden" name="platform" value="<%= platform %>" />
<div class="template-heading">
<strong><%= status?.label || platform %></strong>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= template?.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
</label>
</div>
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
</form>
<% }) %>
</div>
</section>
<% }) %>
</div>
</section>
<div class="modal-backdrop" data-confirm-modal aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-header">
<h3 id="confirm-title" data-confirm-heading>Confirm action</h3>
<button type="button" class="icon-button" data-confirm-close aria-label="Close confirmation">×</button>
</div>
<p data-confirm-description></p>
<form method="post" data-confirm-form>
<div class="modal-actions">
<button type="button" class="button subtle" data-confirm-close>Cancel</button>
<button type="submit" class="button danger" data-confirm-submit disabled>Confirm in 3</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" data-debug-modal aria-hidden="true">
<div class="modal debug-modal" role="dialog" aria-modal="true" aria-labelledby="debug-title">
<div class="modal-header">
<div>
<h3 id="debug-title">Live Throne Payloads</h3>
<p class="hint" data-debug-status>Starting debug session…</p>
</div>
<button type="button" class="icon-button" data-debug-close aria-label="Close debug viewer">×</button>
</div>
<div class="debug-toolbar">
<button type="button" class="icon-button" data-debug-prev aria-label="Previous payload">←</button>
<span data-debug-page>0 / 0</span>
<input type="range" min="1" max="1" value="1" data-debug-slider aria-label="Payload page" />
<button type="button" class="icon-button" data-debug-next aria-label="Next payload">→</button>
</div>
<div class="debug-summary" data-debug-summary>No payloads received during this session.</div>
<pre class="debug-json" data-debug-json>{}</pre>
</div>
</div>
<script src="/plugins/throne_wishlist/assets/admin.js?v=<%= assetVersion %>"></script>
<script src="/plugins/throne_wishlist/assets/debug-modal.js?v=<%= assetVersion %>"></script>
<%- include("../../../src/web/views/partials/layout-bottom") %>

View File

@ -0,0 +1,93 @@
const assert = require("assert");
const http = require("http");
const express = require("express");
const {
createWebhookService,
generateSlug,
sanitizeIdentifier,
isTimestampWithinWindow
} = require("../src/services/webhooks");
async function main() {
assert.strictEqual(sanitizeIdentifier(" Cozy Alerts! "), "cozy-alerts");
assert.strictEqual(
generateSlug({
identifier: "Cozy Alerts",
uuid: "123e4567-e89b-42d3-a456-426614174000"
}),
"cozy-alerts-123e4567-e89b-42d3-a456-426614174000"
);
assert.throws(() => sanitizeIdentifier("../"));
assert.strictEqual(
isTimestampWithinWindow(String(Math.floor(Date.now() / 1000))),
true
);
const service = createWebhookService();
const app = express();
app.use("/webhooks", service.router);
let observed = null;
service.registerInbound({
pluginId: "verification",
endpointId: "raw-body",
namespace: "test",
slug: "raw-body",
handler: async (context) => {
observed = context;
return { status: 202, body: { accepted: true } };
}
});
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const raw = '{"z":1, "a":2}';
const response = await fetch(
`http://127.0.0.1:${server.address().port}/webhooks/test/raw-body`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: raw
}
);
assert.strictEqual(response.status, 202);
assert.strictEqual(observed.rawBodyText, raw);
assert.deepStrictEqual(observed.parsedJson, { z: 1, a: 2 });
const methodResponse = await fetch(
`http://127.0.0.1:${server.address().port}/webhooks/test/raw-body`
);
assert.strictEqual(methodResponse.status, 405);
const unknownResponse = await fetch(
`http://127.0.0.1:${server.address().port}/webhooks/test/unknown`,
{ method: "POST", body: "{}" }
);
assert.strictEqual(unknownResponse.status, 404);
const receiver = http.createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
res.writeHead(201, { "Content-Type": "text/plain" });
res.end(body);
});
});
await new Promise((resolve) => receiver.listen(0, "127.0.0.1", resolve));
const outbound = await service.sendJson({
pluginId: "verification",
url: `http://127.0.0.1:${receiver.address().port}/receive`,
payload: { hello: "world" }
});
assert.strictEqual(outbound.status, 201);
assert.strictEqual(outbound.success, true);
assert.strictEqual(outbound.body, '{"hello":"world"}');
await new Promise((resolve) => server.close(resolve));
await new Promise((resolve) => receiver.close(resolve));
console.log("Webhook framework verification passed.");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -49,7 +49,7 @@ async function main() {
const app = createWebServer({ const app = createWebServer({
discordClient, discordClient,
loadPlugins: (appInstance, web) => { loadPlugins: (appInstance, web, webhooks) => {
loadEnabled({ loadEnabled({
app: appInstance, app: appInstance,
discordClient, discordClient,
@ -57,6 +57,7 @@ async function main() {
youtubeClient, youtubeClient,
settings: settingsApi, settings: settingsApi,
web, web,
webhooks,
commandRouter commandRouter
}); });
} }

View File

@ -96,6 +96,7 @@ function loadEnabled({
youtubeClient, youtubeClient,
settings, settings,
web, web,
webhooks,
commandRouter commandRouter
}) { }) {
const installed = scanPluginDirectories(); const installed = scanPluginDirectories();
@ -126,6 +127,7 @@ function loadEnabled({
youtubeClient, youtubeClient,
settings, settings,
web, web,
webhooks,
db, db,
plugin, plugin,
commandRouter commandRouter

376
src/services/webhooks.js Normal file
View File

@ -0,0 +1,376 @@
const crypto = require("crypto");
const express = require("express");
const { log } = require("./logger");
function createWebhookService({ limit = "256kb" } = {}) {
const endpoints = new Map();
const endpointKeysByPlugin = new Map();
const router = express.Router();
router.use(express.raw({ type: "*/*", limit }));
router.all("/:namespace/:slug", async (req, res) => {
const namespace = normalizeSegment(req.params.namespace);
const slug = normalizeSegment(req.params.slug);
const endpoint = namespace && slug
? endpoints.get(`${namespace}/${slug}`)
: null;
if (!endpoint) {
return res.status(404).json({ error: "Webhook endpoint not found." });
}
if (!endpoint.methods.includes(req.method.toUpperCase())) {
res.set("Allow", endpoint.methods.join(", "));
return res.status(405).json({ error: "Method not allowed." });
}
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || "");
const rawBodyText = rawBody.toString("utf8");
let parsedJson = null;
let jsonError = null;
if (rawBody.length) {
try {
parsedJson = JSON.parse(rawBodyText);
} catch (error) {
jsonError = error;
}
}
const context = {
req,
headers: { ...req.headers },
method: req.method,
namespace,
slug,
rawBody,
rawBodyText,
parsedJson,
jsonError,
receivedAt: Date.now(),
remoteAddress: req.ip || req.socket?.remoteAddress || null,
userAgent: req.get("user-agent") || null
};
try {
if (endpoint.verify) {
const verification = await endpoint.verify(context);
if (verification === false || verification?.ok === false) {
return res.status(Number(verification?.status) || 401).json({
error: verification?.message || "Webhook verification failed."
});
}
context.verification = verification || { ok: true };
}
return sendHandlerResult(res, await endpoint.handler(context));
} catch (error) {
log("error", "Webhook handler failed", {
pluginId: endpoint.pluginId,
endpointId: endpoint.endpointId,
namespace,
slug,
message: error?.message || String(error),
stack: error?.stack || ""
});
if (!res.headersSent) {
return res.status(500).json({ error: "Webhook processing failed." });
}
return null;
}
});
router.use((error, req, res, _next) => {
const status = error?.type === "entity.too.large" ? 413 : 400;
log("warn", "Webhook request rejected", {
path: req.path,
status,
message: error?.message || String(error)
});
res.status(status).json({
error: status === 413 ? "Webhook payload is too large." : "Invalid webhook request."
});
});
function registerInbound({
pluginId,
namespace,
endpointId,
slug,
description,
handler,
verify,
options = {}
}) {
const safePluginId = requireValue(pluginId, "pluginId");
const safeEndpointId = requireValue(endpointId, "endpointId");
const safeNamespace = requireSegment(namespace, "namespace");
const safeSlug = requireSegment(slug, "slug");
if (typeof handler !== "function") {
throw new Error("Webhook handler must be a function.");
}
const key = `${safeNamespace}/${safeSlug}`;
if (endpoints.has(key)) {
throw new Error(`Webhook endpoint already registered: ${key}`);
}
unregisterInbound({ pluginId: safePluginId, endpointId: safeEndpointId });
const endpoint = {
pluginId: safePluginId,
endpointId: safeEndpointId,
namespace: safeNamespace,
slug: safeSlug,
description: (description || "").toString(),
handler,
verify: typeof verify === "function" ? verify : null,
methods: normalizeMethods(options.methods),
options
};
endpoints.set(key, endpoint);
if (!endpointKeysByPlugin.has(safePluginId)) {
endpointKeysByPlugin.set(safePluginId, new Map());
}
endpointKeysByPlugin.get(safePluginId).set(safeEndpointId, key);
log("info", "Webhook endpoint registered", {
pluginId: safePluginId,
endpointId: safeEndpointId,
path: `/webhooks/${key}`
});
return { namespace: safeNamespace, slug: safeSlug, path: `/webhooks/${key}` };
}
function unregisterInbound({ pluginId, endpointId }) {
const pluginEndpoints = endpointKeysByPlugin.get((pluginId || "").toString());
const key = pluginEndpoints?.get((endpointId || "").toString());
if (!key) {
return false;
}
endpoints.delete(key);
pluginEndpoints.delete((endpointId || "").toString());
if (!pluginEndpoints.size) {
endpointKeysByPlugin.delete((pluginId || "").toString());
}
log("debug", "Webhook endpoint unregistered", { pluginId, endpointId });
return true;
}
function buildPublicUrl({ namespace, slug, req, baseUrl }) {
const safeNamespace = requireSegment(namespace, "namespace");
const safeSlug = requireSegment(slug, "slug");
const origin = (baseUrl || requestOrigin(req) || "").replace(/\/+$/, "");
const routePath = `/webhooks/${safeNamespace}/${safeSlug}`;
return origin ? `${origin}${routePath}` : routePath;
}
return {
router,
registerInbound,
unregisterInbound,
buildPublicUrl,
generateSlug,
send: sendWebhook,
sendJson: ({ payload, ...options }) => sendWebhook({ ...options, json: payload }),
isTimestampWithinWindow,
getRegisteredEndpoints: () =>
Array.from(endpoints.values()).map(({ handler, verify, ...entry }) => entry)
};
}
async function sendWebhook({
pluginId,
url,
method = "POST",
headers = {},
json,
body,
timeoutMs = 10000,
retries = 0,
sign
}) {
if (!url) {
throw new Error("Webhook URL is required.");
}
if (json !== undefined && body !== undefined) {
throw new Error("Provide either json or body, not both.");
}
const requestHeaders = { ...headers };
let requestBody = body;
if (json !== undefined) {
requestBody = JSON.stringify(json);
if (!hasHeader(requestHeaders, "content-type")) {
requestHeaders["Content-Type"] = "application/json";
}
}
if (typeof sign === "function") {
const signedHeaders = await sign({
method,
url,
headers: { ...requestHeaders },
body: requestBody
});
if (signedHeaders && typeof signedHeaders === "object") {
Object.assign(requestHeaders, signedHeaders);
}
}
const attempts = Math.max(1, Number(retries) + 1);
let lastError = null;
let lastDurationMs = 0;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs)));
try {
const response = await fetch(url, {
method,
headers: requestHeaders,
body: requestBody,
signal: controller.signal
});
const result = {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: await response.text(),
durationMs: Date.now() - startedAt,
success: response.ok
};
if (response.ok || attempt === attempts) {
if (!response.ok) {
log("warn", "Outbound webhook returned an error", {
pluginId: pluginId || null,
url: redactUrl(url),
status: response.status,
attempt
});
}
return result;
}
} catch (error) {
lastError = error;
lastDurationMs = Date.now() - startedAt;
if (attempt === attempts) {
log("error", "Outbound webhook failed", {
pluginId: pluginId || null,
url: redactUrl(url),
attempt,
message: error?.message || String(error)
});
}
} finally {
clearTimeout(timer);
}
}
return {
status: 0,
headers: {},
body: "",
durationMs: lastDurationMs,
success: false,
error: lastError?.message || "Webhook request failed."
};
}
function generateSlug({ identifier, uuid = crypto.randomUUID() }) {
return `${sanitizeIdentifier(identifier)}-${requireUuid(uuid)}`;
}
function sanitizeIdentifier(value) {
const safe = (value || "")
.toString()
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
if (!safe) {
throw new Error("Webhook identifier must contain URL-safe letters or numbers.");
}
return safe;
}
function requireUuid(value) {
const uuid = (value || "").toString().toLowerCase();
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid)) {
throw new Error("Webhook UUID is invalid.");
}
return uuid;
}
function requireSegment(value, label) {
const normalized = normalizeSegment(value);
if (!normalized) {
throw new Error(`Webhook ${label} is invalid.`);
}
return normalized;
}
function normalizeSegment(value) {
const raw = (value || "").toString().trim().toLowerCase();
if (!raw || raw.includes("/") || raw.includes("\\") || raw.includes("..")) {
return null;
}
if (/%2f|%5c/i.test(raw)) {
return null;
}
return /^[a-z0-9][a-z0-9_-]*$/.test(raw) ? raw : null;
}
function requireValue(value, label) {
const safe = (value || "").toString().trim();
if (!safe) {
throw new Error(`Webhook ${label} is required.`);
}
return safe;
}
function normalizeMethods(methods) {
const source = Array.isArray(methods) && methods.length ? methods : ["POST"];
return Array.from(new Set(source.map((method) => method.toString().toUpperCase())));
}
function sendHandlerResult(res, result) {
const status = Number(result?.status) || 204;
if (result?.headers && typeof result.headers === "object") {
res.set(result.headers);
}
if (result?.body === undefined || result?.body === null) {
return res.status(status).end();
}
if (Buffer.isBuffer(result.body) || typeof result.body === "string") {
return res.status(status).send(result.body);
}
return res.status(status).json(result.body);
}
function requestOrigin(req) {
if (!req) {
return "";
}
const forwardedProto = req.get?.("x-forwarded-proto");
const protocol = forwardedProto ? forwardedProto.split(",")[0].trim() : req.protocol;
const host = req.get?.("host");
return protocol && host ? `${protocol}://${host}` : "";
}
function isTimestampWithinWindow(timestamp, windowSeconds = 300, nowMs = Date.now()) {
if (!/^\d+$/.test((timestamp || "").toString())) {
return false;
}
const timestampMs = Number(timestamp) * 1000;
return Number.isFinite(timestampMs) &&
Math.abs(nowMs - timestampMs) <= Math.max(0, Number(windowSeconds)) * 1000;
}
function hasHeader(headers, name) {
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
}
function redactUrl(value) {
try {
const url = new URL(value);
return `${url.protocol}//${url.host}${url.pathname}`;
} catch {
return "[invalid-url]";
}
}
module.exports = {
createWebhookService,
generateSlug,
sanitizeIdentifier,
isTimestampWithinWindow
};

View File

@ -28,6 +28,13 @@ async function startYouTubeBot({ commandRouter } = {}) {
stopped: false, stopped: false,
commandRouter commandRouter
}; };
state.sendMessage = async (content) => {
const liveChatId = await ensureLiveChatId(state);
if (!liveChatId) {
throw new Error("No active YouTube live chat is available.");
}
return sendChatMessage(state, liveChatId, content);
};
client = state; client = state;

View File

@ -33,6 +33,7 @@ const {
getTopCommandOptions getTopCommandOptions
} = require("../services/top"); } = require("../services/top");
const { log, listLogs } = require("../services/logger"); const { log, listLogs } = require("../services/logger");
const { createWebhookService } = require("../services/webhooks");
const { const {
getPlatformStatus, getPlatformStatus,
getEnabledPlatformIds, getEnabledPlatformIds,
@ -1729,6 +1730,11 @@ async function verifyYouTubeSettings(settings) {
function createWebServer({ loadPlugins, discordClient }) { function createWebServer({ loadPlugins, discordClient }) {
const app = express(); const app = express();
const webhooks = createWebhookService();
if (!global.lumiFrameworks) {
global.lumiFrameworks = {};
}
global.lumiFrameworks.webhooks = webhooks;
const assetVersion = Date.now().toString(); const assetVersion = Date.now().toString();
const sessionStore = new BetterSqlite3Store({ const sessionStore = new BetterSqlite3Store({
client: db client: db
@ -1736,6 +1742,7 @@ function createWebServer({ loadPlugins, discordClient }) {
app.set("view engine", "ejs"); app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views")); app.set("views", path.join(__dirname, "views"));
app.use("/webhooks", webhooks.router);
const originalAppRender = app.render.bind(app); const originalAppRender = app.render.bind(app);
app.render = (view, options, callback) => { app.render = (view, options, callback) => {
@ -4423,7 +4430,7 @@ function createWebServer({ loadPlugins, discordClient }) {
}); });
if (typeof loadPlugins === "function") { if (typeof loadPlugins === "function") {
loadPlugins(app, web); loadPlugins(app, web, webhooks);
} }
return app; return app;

Binary file not shown.

Binary file not shown.