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;