diff --git a/codex-guidelines b/codex-guidelines index 8a2b278..190077e 100644 --- a/codex-guidelines +++ b/codex-guidelines @@ -110,6 +110,8 @@ Plugins (important) - index.js exports { id, init(...) } - web.mount("/plugins/", router, navItem) to add nav entry - 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 - echonomy-framework: diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..b762cf9 --- /dev/null +++ b/docs/webhooks.md @@ -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. diff --git a/plugins/throne_wishlist/README.md b/plugins/throne_wishlist/README.md new file mode 100644 index 0000000..ea13f46 --- /dev/null +++ b/plugins/throne_wishlist/README.md @@ -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. diff --git a/plugins/throne_wishlist/cmds.json b/plugins/throne_wishlist/cmds.json new file mode 100644 index 0000000..66d1348 --- /dev/null +++ b/plugins/throne_wishlist/cmds.json @@ -0,0 +1,4 @@ +{ + "pluginId": "throne_wishlist", + "commands": [] +} diff --git a/plugins/throne_wishlist/index.js b/plugins/throne_wishlist/index.js new file mode 100644 index 0000000..779ffc2 --- /dev/null +++ b/plugins/throne_wishlist/index.js @@ -0,0 +1,1024 @@ +const crypto = require("crypto"); +const express = require("express"); +const path = require("path"); +const discord = require("discord.js"); +const { log } = require("../../src/services/logger"); +const { getPlatformStatus } = require("../../src/services/platforms"); + +const PLUGIN_ID = "throne_wishlist"; +const NAMESPACE = "throne"; +const REPLAY_WINDOW_SECONDS = 300; +const DEBUG_IDLE_MS = 300000; +const DEBUG_MAX_MS = 1800000; +const THRONE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAPXbUfxh7XL4SYUVcfhmYMIbxvtR9E9LDd8gPJ1PwSD8= +-----END PUBLIC KEY-----`; +const EVENT_TYPES = [ + "gift_purchased", + "contribution_purchased", + "gift_crowdfunded" +]; +const PLATFORMS = ["discord", "twitch", "youtube", "kick"]; +const DEFAULT_TEMPLATES = { + gift_purchased: + "🎁 {gifter_username} bought {item_name} from {creator_username}'s Throne wishlist!", + contribution_purchased: + "💝 {gifter_username} contributed {amount_display} toward {item_name} on {creator_username}'s Throne wishlist!", + gift_crowdfunded: + "🎉 {item_name} was fully crowdfunded on {creator_username}'s Throne wishlist!" +}; +const PLACEHOLDERS = new Set([ + "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", + "payload_json", + "data.creator_id", + "data.creator_username", + "data.gifter_username", + "data.message", + "data.item_name", + "data.item_thumbnail_url", + "data.price", + "data.amount", + "data.currency", + "data.is_surprise_gift" +]); +const debugSessions = new Map(); + +module.exports = { + id: PLUGIN_ID, + init({ + web, + webhooks, + db, + settings, + discordClient, + twitchClient, + youtubeClient + }) { + ensureTables(db); + ensureDefaults(db); + const webhookApi = webhooks || global.lumiFrameworks?.webhooks || null; + registerStoredEndpoints({ db, webhookApi, clients: { discordClient, twitchClient, youtubeClient } }); + + const router = web.createRouter(); + router.use("/assets", express.static(path.join(__dirname, "public"))); + + router.get("/", async (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const baseUrl = getPublicBaseUrl(req, settings); + const endpoints = listEndpoints(db).map((endpoint) => ({ + ...endpoint, + publicUrl: webhookApi + ? webhookApi.buildPublicUrl({ + namespace: NAMESPACE, + slug: endpoint.slug, + req, + baseUrl: baseUrl.value + }) + : `/webhooks/${NAMESPACE}/${endpoint.slug}` + })); + const platformStatus = await buildPlatformStatus({ + settings, + db, + discordClient, + twitchClient, + youtubeClient + }); + res.render(path.join(__dirname, "views", "admin.ejs"), { + title: "Throne Wishlist", + endpoints, + templates: listTemplates(db), + destinations: listDestinations(db), + discordChannels: getDiscordChannels(discordClient), + platformStatus, + activePlatforms: platformStatus + .filter((platform) => platform.sendAvailable) + .map((platform) => platform.id), + eventTypes: EVENT_TYPES, + platforms: PLATFORMS, + placeholders: Array.from(PLACEHOLDERS).sort(), + webhookAvailable: Boolean(webhookApi), + publicBaseUrl: baseUrl.value, + publicBaseUrlWarning: baseUrl.warning, + formatTimestamp + }); + }); + + router.post("/endpoints/create", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + if (!webhookApi) { + req.session.flash = { type: "error", message: "Core webhook framework is unavailable." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const identifier = (req.body.identifier || "").trim(); + try { + const uuid = crypto.randomUUID(); + const slug = webhookApi.generateSlug({ identifier, uuid }); + const now = Date.now(); + db.prepare( + "INSERT INTO throne_webhook_endpoints (id, identifier, slug, uuid, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)" + ).run(uuid, identifier, slug, uuid, now, now); + registerEndpoint({ + db, + webhookApi, + endpoint: getEndpoint(db, uuid), + clients: { discordClient, twitchClient, youtubeClient } + }); + req.session.flash = { type: "success", message: "Throne webhook endpoint created." }; + } catch (error) { + req.session.flash = { type: "error", message: error?.message || "Unable to create endpoint." }; + } + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/endpoints/:id/renew", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const endpoint = getEndpoint(db, req.params.id); + if (!endpoint || !webhookApi) { + req.session.flash = { type: "error", message: "Endpoint or webhook framework unavailable." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + try { + webhookApi.unregisterInbound({ pluginId: PLUGIN_ID, endpointId: endpoint.id }); + const uuid = crypto.randomUUID(); + const slug = webhookApi.generateSlug({ identifier: endpoint.identifier, uuid }); + db.prepare( + "UPDATE throne_webhook_endpoints SET slug = ?, uuid = ?, updated_at = ? WHERE id = ?" + ).run(slug, uuid, Date.now(), endpoint.id); + registerEndpoint({ + db, + webhookApi, + endpoint: getEndpoint(db, endpoint.id), + clients: { discordClient, twitchClient, youtubeClient } + }); + req.session.flash = { type: "success", message: "Endpoint renewed. The old URL is invalid." }; + } catch (error) { + req.session.flash = { type: "error", message: error?.message || "Unable to renew endpoint." }; + } + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/endpoints/:id/remove", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const endpoint = getEndpoint(db, req.params.id); + if (endpoint) { + webhookApi?.unregisterInbound({ pluginId: PLUGIN_ID, endpointId: endpoint.id }); + db.prepare("DELETE FROM throne_webhook_endpoints WHERE id = ?").run(endpoint.id); + } + req.session.flash = { type: "success", message: "Endpoint removed." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/destinations", async (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const enabled = new Set(normalizeArray(req.body.enabled_platforms)); + const discordChannelId = (req.body.discord_destination_id || "").trim(); + if (enabled.has("discord")) { + const validation = await validateDiscordChannel(discordClient, discordChannelId); + if (!validation.valid) { + req.session.flash = { type: "error", message: validation.message }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + } + const platformStatus = await buildPlatformStatus({ + settings, + db, + discordClient, + twitchClient, + youtubeClient + }); + const statusMap = new Map(platformStatus.map((entry) => [entry.id, entry])); + for (const platform of PLATFORMS) { + const canEnable = statusMap.get(platform)?.sendAvailable; + const isEnabled = enabled.has(platform) && canEnable; + const destinationId = platform === "discord" ? discordChannelId : null; + db.prepare( + "UPDATE throne_platform_destinations SET enabled = ?, destination_id = ?, destination_label = ?, updated_at = ? WHERE platform = ?" + ).run( + isEnabled ? 1 : 0, + destinationId, + platform === "discord" ? statusMap.get(platform)?.destinationLabel || null : null, + Date.now(), + platform + ); + } + req.session.flash = { type: "success", message: "Platform destinations saved." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const eventType = EVENT_TYPES.includes(req.body.event_type) + ? req.body.event_type + : null; + const platform = PLATFORMS.includes(req.body.platform) + ? req.body.platform + : null; + const template = (req.body.template || "").trim(); + const enabled = req.body.enabled === "on"; + if (!eventType || !platform) { + req.session.flash = { type: "error", message: "Invalid event type or platform." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + if (enabled && !template) { + req.session.flash = { type: "error", message: "Enabled templates cannot be empty." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const unknown = findUnknownPlaceholders(template); + db.prepare( + "UPDATE throne_event_message_templates SET enabled = ?, template = ?, updated_at = ? WHERE event_type = ? AND platform = ?" + ).run(enabled ? 1 : 0, template, Date.now(), eventType, platform); + req.session.flash = unknown.length + ? { type: "error", message: `Template saved. Unknown placeholders remain unchanged: ${unknown.join(", ")}.` } + : { type: "success", message: "Event template saved." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/debug/start", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).json({ error: "Access denied." }); + } + const id = crypto.randomUUID(); + const now = Date.now(); + debugSessions.set(id, { + id, + userId: req.session.user.id, + createdAt: now, + lastPayloadAt: now, + response: null, + idleTimer: setTimeout(() => closeDebugSession(id, "inactivity"), DEBUG_IDLE_MS), + expiryTimer: setTimeout(() => closeDebugSession(id, "maximum lifetime"), DEBUG_MAX_MS) + }); + res.json({ sessionId: id, expiresInSeconds: DEBUG_MAX_MS / 1000 }); + }); + + router.get("/debug/stream/:sessionId", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).end(); + } + const session = debugSessions.get(req.params.sessionId); + if (!session || session.userId !== req.session.user.id) { + return res.status(404).end(); + } + res.set({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive" + }); + res.flushHeaders?.(); + session.response = res; + res.write(`event: ready\ndata: ${JSON.stringify({ sessionId: session.id })}\n\n`); + req.on("close", () => closeDebugSession(session.id, "client disconnected")); + return null; + }); + + router.post("/debug/stop/:sessionId", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).json({ error: "Access denied." }); + } + const session = debugSessions.get(req.params.sessionId); + if (session?.userId === req.session.user.id) { + closeDebugSession(session.id, "stopped"); + } + res.status(204).end(); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Throne Wishlist", + role: "admin", + section: "plugins" + }); + ensureSidebarNavItem(settings); + } +}; + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS throne_webhook_endpoints ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + uuid TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_payload_at INTEGER NULL, + last_event_id TEXT NULL, + last_event_type TEXT NULL, + last_authentic INTEGER NULL, + last_payload_preview TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS throne_event_deliveries ( + event_id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL, + event_type TEXT NOT NULL, + authentic INTEGER NOT NULL, + received_at INTEGER NOT NULL, + processed_at INTEGER NULL, + status TEXT NOT NULL, + details TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS throne_event_message_templates ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + platform TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + template TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(event_type, platform) + ); + + CREATE TABLE IF NOT EXISTS throne_platform_destinations ( + platform TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + destination_id TEXT NULL, + destination_label TEXT NULL, + updated_at INTEGER NOT NULL + ); + `); +} + +function ensureDefaults(db) { + const now = Date.now(); + for (const eventType of EVENT_TYPES) { + for (const platform of PLATFORMS) { + db.prepare( + "INSERT OR IGNORE INTO throne_event_message_templates (id, event_type, platform, enabled, template, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?)" + ).run(crypto.randomUUID(), eventType, platform, DEFAULT_TEMPLATES[eventType], now, now); + } + } + for (const platform of PLATFORMS) { + db.prepare( + "INSERT OR IGNORE INTO throne_platform_destinations (platform, enabled, destination_id, destination_label, updated_at) VALUES (?, 0, NULL, NULL, ?)" + ).run(platform, now); + } +} + +function registerStoredEndpoints({ db, webhookApi, clients }) { + if (!webhookApi) { + log("warn", "Throne Wishlist webhook framework unavailable"); + return; + } + for (const endpoint of listEndpoints(db).filter((entry) => entry.enabled)) { + try { + registerEndpoint({ db, webhookApi, endpoint, clients }); + } catch (error) { + log("error", "Throne endpoint registration failed", { + endpointId: endpoint.id, + message: error?.message || String(error) + }); + } + } +} + +function registerEndpoint({ db, webhookApi, endpoint, clients }) { + webhookApi.registerInbound({ + pluginId: PLUGIN_ID, + endpointId: endpoint.id, + namespace: NAMESPACE, + slug: endpoint.slug, + description: `Throne wishlist endpoint: ${endpoint.identifier}`, + handler: (context) => handleIncoming({ db, endpointId: endpoint.id, context, clients }) + }); +} + +async function handleIncoming({ + db, + endpointId, + context, + clients, + signatureVerifier = verifyThroneSignature +}) { + const endpoint = getEndpoint(db, endpointId); + if (!endpoint?.enabled) { + return { status: 404, body: { error: "Endpoint unavailable." } }; + } + const timestamp = headerValue(context.headers, "x-signature-timestamp"); + const signature = headerValue(context.headers, "x-signature-ed25519"); + const verification = signatureVerifier({ + rawBody: context.rawBody, + timestamp, + signature + }); + const payload = context.parsedJson; + const eventType = typeof payload?.event_type === "string" ? payload.event_type : null; + const eventId = typeof payload?.event_id === "string" ? payload.event_id : null; + const documented = EVENT_TYPES.includes(eventType); + let authenticityStatus = verification.status; + if (verification.authentic && context.jsonError) { + authenticityStatus = "parse_error"; + } else if (verification.authentic && eventType && !documented) { + authenticityStatus = "unknown_event"; + } + const preview = buildPayloadPreview({ + receivedAt: context.receivedAt, + eventType, + eventId, + authentic: verification.authentic, + status: authenticityStatus + }); + db.prepare( + "UPDATE throne_webhook_endpoints SET last_payload_at = ?, last_event_id = ?, last_event_type = ?, last_authentic = ?, last_payload_preview = ?, updated_at = ? WHERE id = ?" + ).run( + context.receivedAt, + eventId, + eventType, + verification.authentic ? 1 : 0, + preview, + Date.now(), + endpoint.id + ); + + const debugItem = { + endpointId: endpoint.id, + endpointIdentifier: endpoint.identifier, + webhookUuid: endpoint.uuid, + receivedAt: context.receivedAt, + eventId, + eventType, + authentic: verification.authentic, + authenticityStatus, + payload, + rawBodyPreview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null + }; + publishDebug(debugItem); + writePayloadLog({ + endpoint, + context, + payload, + eventId, + eventType, + verification, + authenticityStatus + }); + + if (!verification.authentic) { + return { status: verification.httpStatus, body: { error: "Invalid Throne signature." } }; + } + if (context.jsonError || !payload) { + return { status: 400, body: { error: "Invalid JSON payload." } }; + } + if (!isValidEnvelope(payload)) { + return { status: 400, body: { error: "Invalid Throne event envelope." } }; + } + const existing = db.prepare( + "SELECT event_id FROM throne_event_deliveries WHERE event_id = ?" + ).get(eventId); + if (existing) { + log("info", `Webhook - Throne - ${EVENT_TYPES.includes(eventType) ? eventType : "unknown"}`, JSON.stringify({ + provider: "throne", + authentic: true, + authenticity_status: "duplicate", + endpoint_id: endpoint.id, + endpoint_identifier: endpoint.identifier, + event_id: eventId, + event_type: eventType, + received_at: new Date(context.receivedAt).toISOString() + })); + return { status: 204 }; + } + db.prepare( + "INSERT INTO throne_event_deliveries (event_id, endpoint_id, event_type, authentic, received_at, processed_at, status, details) VALUES (?, ?, ?, 1, ?, NULL, ?, NULL)" + ).run(eventId, endpoint.id, eventType, context.receivedAt, documented ? "accepted" : "skipped"); + if (!documented) { + return { status: 202, body: { accepted: true, message: "Unknown event logged." } }; + } + setImmediate(() => { + sendPlatformMessages({ + db, + payload, + endpoint, + receivedAt: context.receivedAt, + clients + }).catch((error) => { + log("error", "Throne platform delivery failed", { + eventId, + message: error?.message || String(error) + }); + }); + }); + return { status: 202, body: { accepted: true } }; +} + +function verifyThroneSignature({ + rawBody, + timestamp, + signature, + publicKey = THRONE_PUBLIC_KEY, + nowMs = Date.now() +}) { + if (!timestamp || !signature) { + return { authentic: false, status: "missing_signature", httpStatus: 401 }; + } + if (!/^\d+$/.test(timestamp)) { + return { authentic: false, status: "bad_timestamp", httpStatus: 400 }; + } + if (!/^[0-9a-f]{128}$/i.test(signature)) { + return { authentic: false, status: "bad_signature_length", httpStatus: 400 }; + } + if (Math.abs(nowMs - Number(timestamp) * 1000) > REPLAY_WINDOW_SECONDS * 1000) { + return { authentic: false, status: "replay_window", httpStatus: 401 }; + } + const signatureBuffer = Buffer.from(signature, "hex"); + if (signatureBuffer.length !== 64) { + return { authentic: false, status: "bad_signature_length", httpStatus: 400 }; + } + const message = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]); + try { + const authentic = crypto.verify(null, message, publicKey, signatureBuffer); + return { + authentic, + status: authentic ? "valid" : "invalid", + httpStatus: authentic ? 204 : 401 + }; + } catch { + return { authentic: false, status: "invalid", httpStatus: 401 }; + } +} + +async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients }) { + const eventId = payload.event_id; + const tokens = buildTokens({ payload, endpoint, receivedAt }); + const rows = db.prepare( + "SELECT t.platform, t.template, d.destination_id FROM throne_event_message_templates t " + + "JOIN throne_platform_destinations d ON d.platform = t.platform " + + "WHERE t.event_type = ? AND t.enabled = 1 AND d.enabled = 1" + ).all(payload.event_type); + const tasks = rows.map(async (row) => { + const message = sanitizePlatformText(renderTemplate(row.template, tokens)); + if (!message.trim()) { + throw new Error(`Rendered ${row.platform} message is empty.`); + } + return sendToPlatform({ + platform: row.platform, + destinationId: row.destination_id, + message, + clients + }); + }); + const results = await Promise.allSettled(tasks); + const details = rows.map((row, index) => ({ + platform: row.platform, + success: results[index]?.status === "fulfilled", + error: results[index]?.status === "rejected" + ? results[index].reason?.message || String(results[index].reason) + : null + })); + const failed = details.filter((entry) => !entry.success); + const status = !details.length + ? "skipped" + : failed.length + ? (failed.length === details.length ? "failed" : "partial") + : "sent"; + db.prepare( + "UPDATE throne_event_deliveries SET processed_at = ?, status = ?, details = ? WHERE event_id = ?" + ).run( + Date.now(), + status, + JSON.stringify(details), + eventId + ); + for (const failure of failed) { + log("warn", "Throne destination send failed", { + eventId, + platform: failure.platform, + message: failure.error + }); + } +} + +async function sendToPlatform({ platform, destinationId, message, clients }) { + if (platform === "discord") { + const validation = await validateDiscordChannel(clients.discordClient, destinationId); + if (!validation.valid || !validation.channel) { + throw new Error(validation.message); + } + return validation.channel.send({ content: message, allowedMentions: { parse: [] } }); + } + if (platform === "twitch") { + if (!clients.twitchClient || typeof clients.twitchClient.say !== "function") { + throw new Error("Twitch send capability is unavailable."); + } + const channels = typeof clients.twitchClient.getChannels === "function" + ? clients.twitchClient.getChannels() + : []; + const channel = channels?.[0]; + if (!channel) { + throw new Error("No connected Twitch channel is available."); + } + return clients.twitchClient.say(channel, message); + } + if (platform === "youtube") { + if (!clients.youtubeClient || typeof clients.youtubeClient.sendMessage !== "function") { + throw new Error("YouTube send capability is unavailable."); + } + return clients.youtubeClient.sendMessage(message); + } + throw new Error(`${platform} sending is not supported by Lumi.`); +} + +function buildTokens({ payload, endpoint, receivedAt }) { + const data = payload.data || {}; + const currency = (data.currency || "").toString().toUpperCase(); + const priceDisplay = formatMoney(data.price, currency); + const amountDisplay = formatMoney(data.amount, currency); + const useAmount = payload.event_type === "contribution_purchased"; + const value = useAmount ? data.amount : data.price; + const valueDisplay = useAmount ? amountDisplay : priceDisplay; + const tokens = { + contract_version: payload.contract_version ?? "", + event_id: payload.event_id ?? "", + event_type: payload.event_type ?? "", + webhook_identifier: endpoint.identifier, + webhook_uuid: endpoint.uuid, + received_at: String(receivedAt), + received_at_iso: new Date(receivedAt).toISOString(), + creator_id: data.creator_id ?? "", + creator_username: data.creator_username ?? "", + gifter_username: data.gifter_username ?? "", + message: data.message ?? "", + item_name: data.item_name ?? "", + item_thumbnail_url: data.item_thumbnail_url ?? "", + price: data.price ?? "", + amount: data.amount ?? "", + currency, + is_surprise_gift: data.is_surprise_gift ?? "", + price_display: priceDisplay, + amount_display: amountDisplay, + value: value ?? "", + value_display: valueDisplay, + payload_json: JSON.stringify(payload) + }; + for (const [key, value] of Object.entries(data)) { + tokens[`data.${key}`] = value ?? ""; + } + return tokens; +} + +function renderTemplate(template, tokens) { + return (template || "").replace(/\{([^{}]+)\}/g, (full, key) => + Object.prototype.hasOwnProperty.call(tokens, key.trim()) + ? String(tokens[key.trim()]) + : full + ); +} + +function formatMoney(value, currency) { + const amount = Number(value); + if (!Number.isFinite(amount)) { + return ""; + } + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD" + }).format(amount / 100); + } catch { + return `${(amount / 100).toFixed(2)} ${currency}`.trim(); + } +} + +function sanitizePlatformText(value) { + return (value || "") + .toString() + .replace(/@everyone/gi, "@\u200beveryone") + .replace(/@here/gi, "@\u200bhere") + .replace(/<@&?\d+>/g, "[mention]") + .replace(/<#\d+>/g, "[channel]") + .slice(0, 1800); +} + +function isValidEnvelope(payload) { + return payload && + typeof payload.contract_version === "string" && + typeof payload.event_id === "string" && + payload.event_id.length > 0 && + typeof payload.event_type === "string" && + payload.event_type.length > 0 && + payload.data && + typeof payload.data === "object" && + !Array.isArray(payload.data); +} + +function writePayloadLog({ + endpoint, + context, + payload, + eventId, + eventType, + verification, + authenticityStatus +}) { + const logEventType = context.jsonError + ? "invalid" + : EVENT_TYPES.includes(eventType) + ? eventType + : "unknown"; + log("info", `Webhook - Throne - ${logEventType}`, JSON.stringify({ + provider: "throne", + authentic: verification.authentic, + authenticity_status: authenticityStatus, + endpoint_id: endpoint.id, + endpoint_identifier: endpoint.identifier, + event_id: eventId, + event_type: eventType, + received_at: new Date(context.receivedAt).toISOString(), + payload: payload || null, + raw_body_preview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null, + signature_prefix: (headerValue(context.headers, "x-signature-ed25519") || "").slice(0, 12) + })); +} + +function buildPayloadPreview({ receivedAt, eventType, eventId, authentic, status }) { + return JSON.stringify({ + received_at: new Date(receivedAt).toISOString(), + event_type: eventType, + event_id: eventId, + authentic, + status + }); +} + +function publishDebug(item) { + const now = Date.now(); + for (const [id, session] of debugSessions.entries()) { + if (now - session.createdAt > DEBUG_MAX_MS || now - session.lastPayloadAt > DEBUG_IDLE_MS) { + closeDebugSession(id, "expired"); + continue; + } + session.lastPayloadAt = now; + clearTimeout(session.idleTimer); + session.idleTimer = setTimeout( + () => closeDebugSession(id, "inactivity"), + DEBUG_IDLE_MS + ); + if (session.response && !session.response.writableEnded) { + session.response.write(`event: payload\ndata: ${JSON.stringify(item)}\n\n`); + } + } +} + +function closeDebugSession(id, reason) { + const session = debugSessions.get(id); + if (!session) { + return; + } + clearTimeout(session.expiryTimer); + clearTimeout(session.idleTimer); + if (session.response && !session.response.writableEnded) { + session.response.write(`event: close\ndata: ${JSON.stringify({ reason })}\n\n`); + session.response.end(); + } + debugSessions.delete(id); +} + +async function buildPlatformStatus({ settings, db, discordClient, twitchClient, youtubeClient }) { + const configured = new Map(getPlatformStatus().map((entry) => [entry.id, entry])); + const destinations = new Map(listDestinations(db).map((entry) => [entry.platform, entry])); + const discordDestination = destinations.get("discord"); + const discordValidation = discordDestination?.destination_id + ? await validateDiscordChannel(discordClient, discordDestination.destination_id) + : { valid: false, message: "No Discord channel selected." }; + return PLATFORMS.map((id) => { + const platform = configured.get(id); + let sendAvailable = false; + let diagnostic = "Integration unavailable."; + if (id === "discord") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && discordClient); + diagnostic = sendAvailable ? discordValidation.message : "Discord is disabled, unconfigured, or disconnected."; + } else if (id === "twitch") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && twitchClient?.say); + diagnostic = sendAvailable ? "Twitch send capability available." : "Twitch is disabled, unconfigured, or disconnected."; + } else if (id === "youtube") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && youtubeClient?.sendMessage); + diagnostic = sendAvailable ? "YouTube send capability available." : "YouTube is disabled, unconfigured, or has no active sender."; + } else { + diagnostic = "Kick sending is not implemented in Lumi."; + } + return { + id, + label: platform?.label || id.charAt(0).toUpperCase() + id.slice(1), + enabled: Boolean(platform?.enabled), + configured: Boolean(platform?.configured), + supported: Boolean(platform?.supported), + sendAvailable, + diagnostic, + destinationLabel: id === "discord" && discordValidation.channel + ? `${discordValidation.channel.guild?.name || "Discord"} - ${discordValidation.channel.name}` + : null + }; + }); +} + +function listEndpoints(db) { + return db.prepare( + "SELECT * FROM throne_webhook_endpoints ORDER BY created_at DESC" + ).all(); +} + +function getEndpoint(db, id) { + return db.prepare( + "SELECT * FROM throne_webhook_endpoints WHERE id = ?" + ).get(id); +} + +function listTemplates(db) { + return db.prepare( + "SELECT * FROM throne_event_message_templates ORDER BY event_type, platform" + ).all(); +} + +function listDestinations(db) { + return db.prepare( + "SELECT * FROM throne_platform_destinations ORDER BY platform" + ).all(); +} + +function getPublicBaseUrl(req, settings) { + const configured = (settings?.getSetting?.("public_base_url", "") || "").toString().trim(); + if (configured) { + return { + value: configured.replace(/\/+$/, ""), + warning: configured.startsWith("https://") + ? null + : "Configured public base URL is not HTTPS. Throne production webhooks require HTTPS." + }; + } + const forwardedProto = req.get("x-forwarded-proto"); + const protocol = forwardedProto ? forwardedProto.split(",")[0].trim() : req.protocol; + const value = `${protocol}://${req.get("host")}`; + return { + value, + warning: value.startsWith("https://") + ? null + : "No HTTPS public base URL is configured. This URL is best-effort; Throne production webhooks require HTTPS." + }; +} + +async function validateDiscordChannel(client, channelId) { + if (!client) { + return { valid: false, message: "Discord client is unavailable." }; + } + if (!channelId) { + return { valid: false, message: "Select a Discord text channel." }; + } + const channel = client.channels?.cache?.get(channelId) || + (typeof client.channels?.fetch === "function" + ? await client.channels.fetch(channelId).catch(() => null) + : null); + if (!channel || channel.isThread?.() || !(channel.type === "GUILD_TEXT" || channel.type === 0)) { + return { valid: false, message: "Discord destination must be a regular guild text channel." }; + } + const member = channel.guild?.members?.cache?.get(client.user?.id) || + (typeof channel.guild?.members?.fetch === "function" && client.user?.id + ? await channel.guild.members.fetch(client.user.id).catch(() => null) + : null); + const permissions = member ? channel.permissionsFor(member) : null; + if (permissions && !hasDiscordPermission(permissions, "VIEW_CHANNEL", "ViewChannel")) { + return { valid: false, message: "The bot cannot view the selected Discord channel.", channel }; + } + if (permissions && !hasDiscordPermission(permissions, "SEND_MESSAGES", "SendMessages")) { + return { valid: false, message: "The bot cannot send messages in the selected Discord channel.", channel }; + } + return { valid: true, message: "Discord channel is valid.", channel }; +} + +function hasDiscordPermission(permissions, legacyName, modernName) { + const value = + discord.Permissions?.FLAGS?.[legacyName] || + discord.PermissionFlagsBits?.[modernName] || + legacyName; + try { + return Boolean(permissions.has(value)); + } catch { + return false; + } +} + +function getDiscordChannels(client) { + const channels = []; + if (!client?.guilds?.cache) { + return channels; + } + for (const guild of client.guilds.cache.values()) { + guild.channels?.cache?.forEach((channel) => { + if (!channel.isThread?.() && (channel.type === "GUILD_TEXT" || channel.type === 0)) { + channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` }); + } + }); + } + return channels.sort((a, b) => a.label.localeCompare(b.label)); +} + +function ensureSidebarNavItem(settings) { + if (!settings?.getSetting || !settings?.setSetting) { + return; + } + const navId = "plugins_throne_wishlist"; + const raw = settings.getSetting("nav_structure", null); + if (!raw) { + return; + } + let structure = raw; + if (typeof structure === "string") { + try { + structure = JSON.parse(structure); + } catch { + return; + } + } + if (!structure?.enabled || !Array.isArray(structure.sections)) { + return; + } + for (const section of structure.sections) { + if (Array.isArray(section.items)) { + section.items = section.items.filter((item) => item !== navId); + } + } + let pluginsSection = structure.sections.find((section) => section.id === "plugins"); + if (!pluginsSection) { + pluginsSection = { id: "plugins", label: "Plugins", icon: "blocks", items: [] }; + structure.sections.push(pluginsSection); + } + pluginsSection.items = Array.isArray(pluginsSection.items) ? pluginsSection.items : []; + pluginsSection.items.push(navId); + settings.setSetting("nav_structure", structure); +} + +function findUnknownPlaceholders(template) { + const unknown = []; + for (const match of (template || "").matchAll(/\{([^{}]+)\}/g)) { + const key = match[1].trim(); + if (!PLACEHOLDERS.has(key) && !unknown.includes(`{${key}}`)) { + unknown.push(`{${key}}`); + } + } + return unknown; +} + +function headerValue(headers, name) { + const value = headers?.[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : (value || "").toString(); +} + +function normalizeArray(value) { + if (Array.isArray(value)) { + return value; + } + return value ? [value] : []; +} + +function formatTimestamp(value) { + return value ? new Date(value).toLocaleString() : "Never"; +} + +function isAdmin(user) { + return Boolean(user?.isAdmin); +} + +function renderDenied(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "Administrator access is required." + }); +} + +module.exports.verifyThroneSignature = verifyThroneSignature; +module.exports.handleIncoming = handleIncoming; +module.exports.buildTokens = buildTokens; +module.exports.renderTemplate = renderTemplate; +module.exports.getDiscordChannels = getDiscordChannels; diff --git a/plugins/throne_wishlist/plugin.json b/plugins/throne_wishlist/plugin.json new file mode 100644 index 0000000..83e2de8 --- /dev/null +++ b/plugins/throne_wishlist/plugin.json @@ -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" +} diff --git a/plugins/throne_wishlist/public/admin.css b/plugins/throne_wishlist/public/admin.css new file mode 100644 index 0000000..2b8b5b8 --- /dev/null +++ b/plugins/throne_wishlist/public/admin.css @@ -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; + } +} diff --git a/plugins/throne_wishlist/public/admin.js b/plugins/throne_wishlist/public/admin.js new file mode 100644 index 0000000..1bd0959 --- /dev/null +++ b/plugins/throne_wishlist/public/admin.js @@ -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(); + }); +})(); diff --git a/plugins/throne_wishlist/public/debug-modal.js b/plugins/throne_wishlist/public/debug-modal.js new file mode 100644 index 0000000..c2c136e --- /dev/null +++ b/plugins/throne_wishlist/public/debug-modal.js @@ -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); +})(); diff --git a/plugins/throne_wishlist/views/admin.ejs b/plugins/throne_wishlist/views/admin.ejs new file mode 100644 index 0000000..c9593a5 --- /dev/null +++ b/plugins/throne_wishlist/views/admin.ejs @@ -0,0 +1,227 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +<% 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])); %> + +
+
+

Throne Wishlist

+

Verified wishlist events and cross-platform notifications.

+
+ +
+ +
+
+
+

Diagnostics

+

Core availability and public delivery readiness.

+
+
+
+
Webhook framework<%= webhookAvailable ? "Available" : "Unavailable" %>
+
Public base URL<%= publicBaseUrl %>
+ <% platformStatus.forEach((platform) => { %> +
<%= platform.label %><%= platform.sendAvailable ? "Ready" : "Unavailable" %><%= platform.diagnostic %>
+ <% }) %> +
+ <% if (publicBaseUrlWarning) { %> +
<%= publicBaseUrlWarning %>
+ <% } %> + <% if (!webhookAvailable) { %> +
Install and restart with the core webhook framework patch before creating endpoints.
+ <% } %> +
+ +
+
+
+

Endpoints

+

Create multiple subscriber URLs and paste them into Throne.

+
+
+
+
+ + +
+ +
+ + <% if (!endpoints.length) { %> +

No Throne endpoints configured.

+ <% } else { %> +
+ + + + + + + + + + + + <% endpoints.forEach((endpoint) => { %> + + + + + + + + <% }) %> + +
IdentifierUUIDLinkLast payloadActions
<%= endpoint.identifier %><%= endpoint.uuid %> +
+ + +
+ +
+ <% if (endpoint.last_payload_preview) { %> + <% let preview = {}; try { preview = JSON.parse(endpoint.last_payload_preview); } catch {} %> + <%= preview.event_type || "unknown" %> + <%= preview.event_id || "No event id" %> + <%= preview.authentic ? "Authentic" : "Rejected" %> · <%= formatTimestamp(endpoint.last_payload_at) %> + <% } else { %> + Never + <% } %> + +
+ + +
+
+
+ <% } %> +
+ +
+
+
+

Platform Destinations

+

Only destinations with a working Lumi send capability can be enabled.

+
+
+
+ <% const availablePlatforms = platformStatus.filter((platform) => platform.sendAvailable); %> + <% if (!availablePlatforms.length) { %> +

No active platform sender is currently available.

+ <% } %> + <% availablePlatforms.forEach((platform) => { const destination = destinationMap.get(platform.id); %> +
+ <%= platform.label %> + + <% if (platform.id === "discord") { %> +
+ + +
+ <% } %> + <%= platform.diagnostic %> +
+ <% }) %> +
+ +
+
+
+ +
+
+
+

Event Messages

+

One message per event and platform. Unknown placeholders remain unchanged.

+
+
+
+ Available placeholders +
+ <% placeholders.forEach((placeholder) => { %>{<%= placeholder %>} <% }) %> +
+
+
+ <% eventTypes.forEach((eventType) => { %> +
+

<%= eventType %>

+
+ <% if (!activePlatforms.length) { %> +

No active platform templates are available.

+ <% } %> + <% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %> +
+ + +
+ <%= status?.label || platform %> + +
+ + + +
+ <% }) %> +
+
+ <% }) %> +
+
+ + + + + + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/scripts/verify-webhooks.js b/scripts/verify-webhooks.js new file mode 100644 index 0000000..8f9d0b0 --- /dev/null +++ b/scripts/verify-webhooks.js @@ -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; +}); diff --git a/src/main.js b/src/main.js index 3b4a5fc..6cc4b31 100644 --- a/src/main.js +++ b/src/main.js @@ -49,7 +49,7 @@ async function main() { const app = createWebServer({ discordClient, - loadPlugins: (appInstance, web) => { + loadPlugins: (appInstance, web, webhooks) => { loadEnabled({ app: appInstance, discordClient, @@ -57,6 +57,7 @@ async function main() { youtubeClient, settings: settingsApi, web, + webhooks, commandRouter }); } diff --git a/src/services/plugins.js b/src/services/plugins.js index 18d8622..32d02b8 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -96,6 +96,7 @@ function loadEnabled({ youtubeClient, settings, web, + webhooks, commandRouter }) { const installed = scanPluginDirectories(); @@ -126,6 +127,7 @@ function loadEnabled({ youtubeClient, settings, web, + webhooks, db, plugin, commandRouter diff --git a/src/services/webhooks.js b/src/services/webhooks.js new file mode 100644 index 0000000..867de6f --- /dev/null +++ b/src/services/webhooks.js @@ -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 +}; diff --git a/src/services/youtube.js b/src/services/youtube.js index 5a2c876..bbc9bd8 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -28,6 +28,13 @@ async function startYouTubeBot({ commandRouter } = {}) { stopped: false, 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; diff --git a/src/web/server.js b/src/web/server.js index 784556b..a84c8f4 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -33,6 +33,7 @@ const { getTopCommandOptions } = require("../services/top"); const { log, listLogs } = require("../services/logger"); +const { createWebhookService } = require("../services/webhooks"); const { getPlatformStatus, getEnabledPlatformIds, @@ -1729,6 +1730,11 @@ async function verifyYouTubeSettings(settings) { function createWebServer({ loadPlugins, discordClient }) { const app = express(); + const webhooks = createWebhookService(); + if (!global.lumiFrameworks) { + global.lumiFrameworks = {}; + } + global.lumiFrameworks.webhooks = webhooks; const assetVersion = Date.now().toString(); const sessionStore = new BetterSqlite3Store({ client: db @@ -1736,6 +1742,7 @@ function createWebServer({ loadPlugins, discordClient }) { app.set("view engine", "ejs"); app.set("views", path.join(__dirname, "views")); + app.use("/webhooks", webhooks.router); const originalAppRender = app.render.bind(app); app.render = (view, options, callback) => { @@ -4423,7 +4430,7 @@ function createWebServer({ loadPlugins, discordClient }) { }); if (typeof loadPlugins === "function") { - loadPlugins(app, web); + loadPlugins(app, web, webhooks); } return app; diff --git a/updates/lumi-core-webhook-framework-patch-v0.1.0.zip b/updates/lumi-core-webhook-framework-patch-v0.1.0.zip new file mode 100644 index 0000000..5503696 Binary files /dev/null and b/updates/lumi-core-webhook-framework-patch-v0.1.0.zip differ diff --git a/updates/lumi-plugin-throne_wishlist-v0.1.0.zip b/updates/lumi-plugin-throne_wishlist-v0.1.0.zip new file mode 100644 index 0000000..6bc74a8 Binary files /dev/null and b/updates/lumi-plugin-throne_wishlist-v0.1.0.zip differ