Lumi/plugins/throne_wishlist/index.js
2026-06-25 14:10:04 +02:00

1099 lines
37 KiB
JavaScript

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 placeholderService = require("../../src/services/placeholders");
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,
placeholders = placeholderService,
db,
settings,
discordClient,
twitchClient,
youtubeClient
}) {
ensureTables(db);
ensureDefaults(db);
registerPlaceholderSupport(placeholders);
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);
const placeholderValidation = placeholders.validateTemplate({
fieldId: "plugin.throne_wishlist.message_template",
template,
outputAudience: "user",
user: req.session.user
});
if (!placeholderValidation.ok) {
req.session.flash = {
type: "error",
message: `Template contains unavailable or unauthorized placeholder(s): ${
placeholderValidation.errors.map((error) => error.token || error.id || error.reason).slice(0, 5).join(", ")
}.`
};
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
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 registerPlaceholderSupport(placeholders) {
if (!placeholders?.registerFieldPolicy || !placeholders?.registerPlaceholders) return;
placeholders.registerFieldPolicy({
field_id: "plugin.throne_wishlist.message_template",
label: "Throne Wishlist event message",
field_type: "chat_message",
output_audience: "user",
min_editor_role: "admin",
allowed_namespaces: ["plugin.throne_wishlist"],
max_sensitivity: "public_safe"
});
placeholders.registerPlaceholders(Array.from(PLACEHOLDERS).sort().map((placeholder) => ({
id: `plugin.throne_wishlist.${placeholder}`,
namespace: "plugin.throne_wishlist",
label: humanizePlaceholderLabel(placeholder),
description: `Throne event value: ${placeholder}.`,
value_type: placeholder === "payload_json" ? "json" : "string",
sensitivity: placeholder === "payload_json" ? "admin" : "public_safe",
min_editor_role: "admin",
min_viewer_role: "user",
allowed_field_types: ["chat_message"],
plugin_id: PLUGIN_ID,
aliases: [placeholder],
resolver: ({ runtimeContext }) => {
const tokens = runtimeContext?.throneTokens || runtimeContext?.tokens || {};
return tokens[placeholder] ?? "";
}
})));
}
function humanizePlaceholderLabel(value) {
return String(value || "")
.split(".")
.pop()
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
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(await 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;
}
async function renderTemplate(template, tokens) {
const rendered = await placeholderService.renderTemplate({
fieldId: "plugin.throne_wishlist.message_template",
template,
outputAudience: "user",
user: null,
runtimeContext: {
runtime: true,
throneTokens: tokens
}
});
return (rendered.rendered || "").replace(/\{([^{}]+)\}/g, (full, key) => {
const normalizedKey = String(key || "").trim();
return Object.prototype.hasOwnProperty.call(tokens, normalizedKey)
? String(tokens[normalizedKey])
: 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(/\{\{\s*([^{}]+?)\s*\}\}|\{([^{}]+)\}/g)) {
const full = match[0];
const key = normalizeTemplatePlaceholderKey(match[1] || match[2] || "");
if (!PLACEHOLDERS.has(key) && !unknown.includes(full)) {
unknown.push(full);
}
}
return unknown;
}
function normalizeTemplatePlaceholderKey(value) {
const key = String(value || "").trim();
const prefix = "plugin.throne_wishlist.";
return key.startsWith(prefix) ? key.slice(prefix.length) : key;
}
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;