const { db } = require("./db"); const { getSetting } = require("./settings"); const { hasAccess } = require("./rbac"); const ROLE_LEVELS = Object.freeze({ public: 0, user: 1, mod: 2, moderator: 2, admin: 3, internal: 4 }); const SENSITIVITY_LEVELS = Object.freeze({ public_safe: 0, user: 1, moderator: 2, admin: 3, internal: 4, secret_never_render: 99 }); const VALUE_TYPES = new Set(["string", "number", "boolean", "url", "json", "date"]); const placeholders = new Map(); const fieldPolicies = new Map(); function normalizeRole(value, fallback = "user") { const role = String(value || fallback).trim().toLowerCase(); if (role === "moderator") return "mod"; return Object.prototype.hasOwnProperty.call(ROLE_LEVELS, role) ? role : fallback; } function roleLevel(value) { return ROLE_LEVELS[normalizeRole(value, "public")] ?? 0; } function normalizeSensitivity(value, fallback = "public_safe") { const sensitivity = String(value || fallback).trim().toLowerCase(); return Object.prototype.hasOwnProperty.call(SENSITIVITY_LEVELS, sensitivity) ? sensitivity : fallback; } function sensitivityLevel(value) { return SENSITIVITY_LEVELS[normalizeSensitivity(value)] ?? 0; } function normalizeId(value) { return String(value || "") .trim() .replace(/^\{\{\s*|\s*\}\}$/g, "") .replace(/[^A-Za-z0-9_.-]/g, "_") .replace(/_{2,}/g, "_") .replace(/^\.|\.$/g, ""); } function tokenFor(id) { return `{{${normalizeId(id)}}}`; } function namespaceFor(id) { const parts = normalizeId(id).split(".").filter(Boolean); return parts.length > 1 ? parts.slice(0, -1).join(".") : parts[0] || ""; } function normalizeStringList(value) { return (Array.isArray(value) ? value : [value]) .flatMap((item) => String(item || "").split(/[,\n]/)) .map((item) => item.trim()) .filter(Boolean); } function normalizeDefinition(definition = {}) { const id = normalizeId(definition.id || definition.token); if (!id) { throw new Error("Placeholder id is required."); } const sensitivity = normalizeSensitivity(definition.sensitivity); if (sensitivity === "secret_never_render") { throw new Error("secret_never_render placeholders must not be registered."); } const namespace = normalizeId(definition.namespace || namespaceFor(id)); const aliases = normalizeStringList(definition.aliases || []) .map(normalizeId) .filter((alias) => alias && alias !== id); return { id, token: tokenFor(id), namespace, aliases, label: String(definition.label || id).trim(), description: String(definition.description || "").trim(), value_type: VALUE_TYPES.has(definition.value_type) ? definition.value_type : "string", sensitivity, min_editor_role: normalizeRole(definition.min_editor_role || "user"), min_viewer_role: normalizeRole(definition.min_viewer_role || "user"), allowed_field_types: normalizeStringList(definition.allowed_field_types || []), cache_ttl_seconds: Math.max(0, Number(definition.cache_ttl_seconds) || 0), group: String(definition.group || namespace || "General").trim(), example: definition.example === undefined ? null : String(definition.example), plugin_id: definition.plugin_id ? String(definition.plugin_id).trim() : null, resolver: typeof definition.resolver === "function" ? definition.resolver : () => "", available: typeof definition.available === "function" ? definition.available : null }; } function normalizePolicy(policy = {}) { const field_id = normalizeId(policy.field_id); if (!field_id) { throw new Error("Placeholder field policy id is required."); } return { field_id, label: String(policy.label || field_id).trim(), field_type: String(policy.field_type || "text").trim(), output_audience: normalizeRole(policy.output_audience || "user"), min_editor_role: normalizeRole(policy.min_editor_role || "user"), allowed_namespaces: normalizeStringList(policy.allowed_namespaces || []), allowed_placeholder_ids: normalizeStringList(policy.allowed_placeholder_ids || []).map(normalizeId), max_sensitivity: normalizeSensitivity(policy.max_sensitivity || "public_safe"), description: String(policy.description || "").trim() }; } function registerPlaceholder(definition) { const normalized = normalizeDefinition(definition); placeholders.set(normalized.id, normalized); return normalized; } function registerPlaceholders(definitions = []) { return definitions.map(registerPlaceholder); } function unregisterPlaceholder(id) { return placeholders.delete(normalizeId(id)); } function unregisterNamespace(namespace) { const normalized = normalizeId(namespace); for (const id of Array.from(placeholders.keys())) { const definition = placeholders.get(id); if (id === normalized || id.startsWith(`${normalized}.`) || definition?.namespace === normalized || definition?.namespace?.startsWith(`${normalized}.`)) { placeholders.delete(id); } } } function registerFieldPolicy(policy) { const normalized = normalizePolicy(policy); fieldPolicies.set(normalized.field_id, normalized); return normalized; } function getFieldPolicy(fieldId) { return fieldPolicies.get(normalizeId(fieldId)) || null; } function isPluginEnabled(pluginId) { if (!pluginId) return true; try { const row = db.prepare("SELECT enabled FROM plugins WHERE id = ?").get(pluginId); return !row || Boolean(row.enabled); } catch { return true; } } function userRole(user) { if (user?.isAdmin) return "admin"; if (user?.isMod) return "mod"; return user ? "user" : "public"; } function roleAllows(user, role) { const normalized = normalizeRole(role, "user"); if (normalized === "user") return Boolean(user); return hasAccess(user, normalized); } function namespaceAllowed(definition, policy) { if (policy.allowed_placeholder_ids.includes(definition.id)) return true; if (!policy.allowed_namespaces.length) return true; return policy.allowed_namespaces.some((namespace) => { const normalized = normalizeId(namespace); return definition.id === normalized || definition.namespace === normalized || definition.namespace.startsWith(`${normalized}.`) || definition.id.startsWith(`${normalized}.`); }); } function fieldTypeAllowed(definition, policy) { return !definition.allowed_field_types.length || definition.allowed_field_types.includes(policy.field_type); } function availabilityAllows(definition, context) { if (!isPluginEnabled(definition.plugin_id)) return false; if (!definition.available) return true; try { return Boolean(definition.available(context)); } catch { return false; } } function checkPlaceholderAccess(definition, policy, { user, outputAudience, runtimeContext } = {}) { if (!definition || !policy) { return { allowed: false, reason: "unknown_placeholder" }; } const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience); if (definition.sensitivity === "secret_never_render") { return { allowed: false, reason: "secret_never_render" }; } const runtimeOnly = Boolean(runtimeContext?.runtime); if (!runtimeOnly && (!roleAllows(user, policy.min_editor_role) || !roleAllows(user, definition.min_editor_role))) { return { allowed: false, reason: "editor_role_forbidden" }; } if (roleLevel(audience) < roleLevel(definition.min_viewer_role)) { return { allowed: false, reason: "viewer_role_forbidden" }; } if (sensitivityLevel(definition.sensitivity) > sensitivityLevel(policy.max_sensitivity)) { return { allowed: false, reason: "sensitivity_forbidden" }; } if (!namespaceAllowed(definition, policy)) { return { allowed: false, reason: "namespace_forbidden" }; } if (!fieldTypeAllowed(definition, policy)) { return { allowed: false, reason: "field_type_forbidden" }; } if (!availabilityAllows(definition, { user, policy, outputAudience: audience, runtimeContext })) { return { allowed: false, reason: "placeholder_unavailable" }; } return { allowed: true, reason: "allowed" }; } function findDefinition(token) { const id = normalizeId(token); if (placeholders.has(id)) return placeholders.get(id); for (const definition of placeholders.values()) { if (definition.aliases.includes(id)) return definition; } return null; } function parsePlaceholders(template) { const found = []; const matcher = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g; let match = null; while ((match = matcher.exec(String(template || "")))) { found.push({ token: match[0], id: normalizeId(match[1]), index: match.index }); } return found; } function catalog({ fieldId, user, outputAudience, runtimeContext } = {}) { const policy = getFieldPolicy(fieldId); if (!policy) return { policy: null, placeholders: [] }; const audience = normalizeRole(outputAudience || policy.output_audience, policy.output_audience); const available = Array.from(placeholders.values()) .filter((definition) => checkPlaceholderAccess(definition, policy, { user, outputAudience: audience, runtimeContext }).allowed) .map((definition) => ({ id: definition.id, token: definition.token, namespace: definition.namespace, label: definition.label, description: definition.description, value_type: definition.value_type, sensitivity: definition.sensitivity, group: definition.group, example: definition.sensitivity === "public_safe" ? definition.example : null })) .sort((a, b) => a.token.localeCompare(b.token)); return { policy: { field_id: policy.field_id, label: policy.label, field_type: policy.field_type, output_audience: audience }, placeholders: available }; } function validateTemplate({ fieldId, template, user, outputAudience, runtimeContext } = {}) { const policy = getFieldPolicy(fieldId); const errors = []; if (!policy) { return { ok: false, errors: [{ token: "", reason: "unknown_field_policy" }] }; } for (const token of parsePlaceholders(template)) { const definition = findDefinition(token.id); const access = checkPlaceholderAccess(definition, policy, { user, outputAudience, runtimeContext }); if (!access.allowed) { errors.push({ token: token.token, id: token.id, reason: access.reason }); } } return { ok: errors.length === 0, errors }; } async function renderTemplate({ fieldId, template, user, outputAudience, runtimeContext, fallback = "[unavailable]" } = {}) { const policy = getFieldPolicy(fieldId); if (!policy) { return { ok: false, rendered: String(template || ""), errors: [{ token: "", reason: "unknown_field_policy" }] }; } const errors = []; const rendered = await replaceAsync(String(template || ""), /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, async (match, rawId) => { const id = normalizeId(rawId); const definition = findDefinition(id); const access = checkPlaceholderAccess(definition, policy, { user, outputAudience, runtimeContext }); if (!access.allowed) { errors.push({ token: match, id, reason: access.reason }); return fallback; } try { const value = await withTimeout(Promise.resolve(definition.resolver({ user, policy, outputAudience: normalizeRole(outputAudience || policy.output_audience, policy.output_audience), runtimeContext, token: match, id })), runtimeContext?.placeholder_timeout_ms); return stringifyResolvedValue(value); } catch (error) { errors.push({ token: match, id, reason: error?.code === "EPLACEHOLDERTIMEOUT" ? "resolver_timeout" : "resolver_failed" }); return fallback; } }); return { ok: errors.length === 0, rendered, errors }; } function stringifyResolvedValue(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return JSON.stringify(value); } function replaceAsync(value, matcher, replacer) { const source = String(value || ""); const flags = matcher.flags.includes("g") ? matcher.flags : `${matcher.flags}g`; const expression = new RegExp(matcher.source, flags); const matches = [...source.matchAll(expression)]; return matches.reduce(async (pending, match) => { const state = await pending; const replacement = await replacer(...match, match.index, source, match.groups); return { output: `${state.output}${source.slice(state.cursor, match.index)}${replacement}`, cursor: match.index + match[0].length }; }, Promise.resolve({ output: "", cursor: 0 })).then((state) => `${state.output}${source.slice(state.cursor)}`); } function withTimeout(promise, requestedMs) { const timeoutMs = Math.max(50, Math.min(Number(requestedMs) || 1500, 10000)); let timer = null; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { const error = new Error("Placeholder resolver timed out."); error.code = "EPLACEHOLDERTIMEOUT"; reject(error); }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } function registerCorePlaceholders() { registerFieldPolicy({ field_id: "core.custom_commands.static_response", label: "Custom command response", field_type: "command_response", output_audience: "user", min_editor_role: "mod", allowed_namespaces: ["core.main", "core.command", "custom", "user.public"], max_sensitivity: "public_safe" }); registerPlaceholders([ { id: "core.main.bot_name", namespace: "core.main", label: "Bot name", description: "Current Lumi site/bot display name.", value_type: "string", sensitivity: "public_safe", min_editor_role: "user", min_viewer_role: "user", allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"], example: "Lumi", resolver: () => getSetting("site_title", "Lumi") }, { id: "core.main.command_prefix", namespace: "core.main", label: "Command prefix", description: "Configured chat command prefix.", value_type: "string", sensitivity: "public_safe", min_editor_role: "user", min_viewer_role: "user", allowed_field_types: ["chat_message", "command_response", "admin_template", "okf_markdown"], example: "!", resolver: () => getSetting("command_prefix", "!") }, { id: "core.command.rng", namespace: "core.command", label: "Random Reply number", description: "The number rolled for the current Random Reply command. Blank for other messages.", value_type: "number", sensitivity: "public_safe", min_editor_role: "mod", min_viewer_role: "user", allowed_field_types: ["command_response"], example: "42", resolver: ({ runtimeContext }) => runtimeContext?.command?.rng ?? "" }, { id: "user.public.display_name", namespace: "user.public", label: "Viewer display name", description: "Display name of the user who triggered the message, when available.", value_type: "string", sensitivity: "public_safe", min_editor_role: "user", min_viewer_role: "user", allowed_field_types: ["chat_message", "command_response", "okf_markdown"], resolver: ({ runtimeContext }) => runtimeContext?.user?.displayName || runtimeContext?.user?.username || runtimeContext?.ctx?.user?.displayName || runtimeContext?.ctx?.user?.username || "" } ]); registerCustomPlaceholders(getSetting("custom_placeholders", [])); } function validateCustomPlaceholders(value) { const rows = Array.isArray(value) ? value : []; const errors = []; const definitions = []; const names = new Set(); if (rows.length > 50) errors.push("You can save at most 50 custom placeholders."); rows.slice(0, 50).forEach((row, index) => { const name = String(row?.name || "").trim().toLowerCase().replace(/^custom\./, ""); const rawValue = String(row?.value ?? ""); const visibility = ["user", "mod", "admin"].includes(row?.visibility) ? row.visibility : ""; const description = String(row?.description || "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, 240); if (!/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/.test(name)) { errors.push(`Custom placeholder ${index + 1} needs a lowercase name such as stream.title.`); return; } if (!visibility) { errors.push(`Choose who may receive custom.${name}.`); return; } if (/(?:^|[._])(?:api_?key|client_?secret|credential|password|private_?key|secret|token)(?:$|[._])/.test(name)) { errors.push(`custom.${name} looks like a secret. Passwords, tokens, credentials, and API keys must not be placeholders.`); return; } if (names.has(name)) { errors.push(`Custom placeholder custom.${name} is listed more than once.`); return; } if (rawValue.length > 2000) { errors.push(`Custom placeholder custom.${name} is longer than 2,000 characters.`); return; } names.add(name); definitions.push({ name, value: rawValue.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 2000), visibility, description }); }); return { ok: errors.length === 0, errors, definitions }; } function registerCustomPlaceholders(value) { unregisterNamespace("custom"); const validation = validateCustomPlaceholders(value); if (!validation.ok) return validation; const roles = { user: { viewer: "user", sensitivity: "public_safe" }, mod: { viewer: "mod", sensitivity: "moderator" }, admin: { viewer: "admin", sensitivity: "admin" } }; registerPlaceholders(validation.definitions.map((definition) => ({ id: `custom.${definition.name}`, namespace: "custom", label: definition.name.replace(/[._-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()), description: definition.description || `Admin-managed custom value: custom.${definition.name}`, value_type: "string", sensitivity: roles[definition.visibility].sensitivity, min_editor_role: definition.visibility === "user" ? "mod" : definition.visibility, min_viewer_role: roles[definition.visibility].viewer, allowed_field_types: ["command_response", "admin_template", "okf_markdown"], group: "Custom", example: definition.visibility === "user" ? definition.value.slice(0, 120) : null, resolver: () => definition.value }))); return validation; } function registerPlatformPlaceholders({ discordClient, getTwitchClient, getYouTubeClient } = {}) { registerPlaceholders([ ...discordPlatformPlaceholders(discordClient), ...twitchPlatformPlaceholders(getTwitchClient), ...youtubePlatformPlaceholders(getYouTubeClient) ]); } function platformPlaceholder(base) { return { namespace: base.namespace, sensitivity: base.sensitivity || "public_safe", min_editor_role: base.min_editor_role || "user", min_viewer_role: base.min_viewer_role || "user", allowed_field_types: base.allowed_field_types || ["okf_markdown", "admin_template"], group: base.group, ...base }; } function discordPlatformPlaceholders(discordClient) { const namespace = "platform.discord.guild"; const group = "Discord guild"; const guildValue = async (reader) => { const guild = await resolveDiscordGuild(discordClient); if (!guild) return ""; try { return reader(guild); } catch { return ""; } }; return [ platformPlaceholder({ id: `${namespace}.data_status`, namespace, group, label: "Discord data status", description: "Whether Discord values come from the connected runtime cache or are unavailable.", example: "live_cache", resolver: () => discordDataStatus(discordClient) }), platformPlaceholder({ id: `${namespace}.name`, namespace, group, label: "Discord server name", description: "Name of the configured Discord server.", example: "Cozy Carnage", resolver: () => guildValue((guild) => guild.name || "") }), platformPlaceholder({ id: `${namespace}.member_count`, namespace, group, label: "Discord member count", description: "Current member count reported by the Discord server cache/API.", value_type: "number", example: "128", resolver: () => guildValue((guild) => guild.memberCount ?? guild.approximateMemberCount ?? "") }), platformPlaceholder({ id: `${namespace}.created_at`, namespace, group, label: "Discord server created at", description: "ISO datetime when the configured Discord server was created.", value_type: "date", example: "2024-01-01T12:00:00.000Z", resolver: () => guildValue((guild) => guild.createdAt?.toISOString?.() || (guild.createdTimestamp ? new Date(guild.createdTimestamp).toISOString() : "")) }), platformPlaceholder({ id: `${namespace}.text_channel_count`, namespace, group, label: "Discord text channel count", description: "Number of text-like channels currently cached for the configured Discord server.", value_type: "number", example: "12", resolver: () => guildValue((guild) => countDiscordChannels(guild, "text")) }), platformPlaceholder({ id: `${namespace}.voice_channel_count`, namespace, group, label: "Discord voice channel count", description: "Number of voice/stage channels currently cached for the configured Discord server.", value_type: "number", example: "4", resolver: () => guildValue((guild) => countDiscordChannels(guild, "voice")) }), platformPlaceholder({ id: `${namespace}.channel_count`, namespace, group, label: "Discord channel count", description: "Total number of channels currently cached for the configured Discord server.", value_type: "number", example: "18", resolver: () => guildValue((guild) => collectionSize(guild.channels?.cache)) }), platformPlaceholder({ id: `${namespace}.role_count`, namespace, group, label: "Discord role count", description: "Number of roles currently cached for the configured Discord server.", value_type: "number", example: "8", resolver: () => guildValue((guild) => collectionSize(guild.roles?.cache)) }), platformPlaceholder({ id: `${namespace}.emoji_count`, namespace, group, label: "Discord emoji count", description: "Number of custom emojis currently cached for the configured Discord server.", value_type: "number", example: "24", resolver: () => guildValue((guild) => collectionSize(guild.emojis?.cache)) }), platformPlaceholder({ id: `${namespace}.boost_count`, namespace, group, label: "Discord boost count", description: "Premium subscription count reported by Discord when available.", value_type: "number", example: "3", resolver: () => guildValue((guild) => guild.premiumSubscriptionCount ?? "") }) ]; } function twitchPlatformPlaceholders(getTwitchClient) { const namespace = "platform.twitch.channel"; const group = "Twitch channel"; return [ platformPlaceholder({ id: `${namespace}.data_status`, namespace, group, label: "Twitch data status", description: "Whether Twitch values come from a connected chat runtime, configured local values, or are unavailable.", example: "connected", resolver: () => getTwitchClient?.() ? "connected" : twitchChannels().length ? "configured_not_connected" : "unavailable" }), platformPlaceholder({ id: `${namespace}.primary_name`, namespace, group, label: "Twitch primary channel", description: "First configured Twitch channel name.", example: "cozycarnage", resolver: () => firstTwitchChannel() }), platformPlaceholder({ id: `${namespace}.configured_count`, namespace, group, label: "Twitch configured channel count", description: "Number of configured Twitch channels.", value_type: "number", example: "1", resolver: () => twitchChannels().length }), platformPlaceholder({ id: `${namespace}.bot_username`, namespace, group, label: "Twitch bot username", description: "Configured Twitch bot username.", example: "lumi_bot", resolver: () => getSetting("twitch_bot_username", "") || "" }), platformPlaceholder({ id: `${namespace}.connected`, namespace, group, label: "Twitch bot connected", description: "Whether the Twitch chat client is currently connected.", value_type: "boolean", example: "true", resolver: () => Boolean(getTwitchClient?.()) }) ]; } function youtubePlatformPlaceholders(getYouTubeClient) { const namespace = "platform.youtube.channel"; const group = "YouTube channel"; return [ platformPlaceholder({ id: `${namespace}.data_status`, namespace, group, label: "YouTube data status", description: "Whether YouTube values come from the connected runtime, configured local values, or are unavailable.", example: "connected", resolver: () => getYouTubeClient?.() ? "connected" : getSetting("youtube_bot_channel_id", "") ? "configured_not_connected" : "unavailable" }), platformPlaceholder({ id: `${namespace}.id`, namespace, group, label: "YouTube channel ID", description: "Configured or hydrated YouTube bot channel ID.", example: "UC...", resolver: () => getYouTubeClient?.()?.channelId || getSetting("youtube_bot_channel_id", "") || "" }), platformPlaceholder({ id: `${namespace}.name`, namespace, group, label: "YouTube channel name", description: "Hydrated YouTube bot channel name when the integration is connected.", example: "Cozy Carnage", resolver: () => getYouTubeClient?.()?.channelName || "" }), platformPlaceholder({ id: `${namespace}.live_chat_active`, namespace, group, label: "YouTube live chat active", description: "Whether Lumi currently has an active YouTube live chat ID.", value_type: "boolean", example: "false", resolver: () => Boolean(getYouTubeClient?.()?.liveChatId) }), platformPlaceholder({ id: `${namespace}.connected`, namespace, group, label: "YouTube bot connected", description: "Whether the YouTube chat integration runtime is currently active.", value_type: "boolean", example: "true", resolver: () => Boolean(getYouTubeClient?.()) }) ]; } async function resolveDiscordGuild(discordClient) { const guildId = getSetting("discord_guild_id", ""); if (!discordClient || !guildId) return null; return discordClient.guilds?.cache?.get?.(guildId) || null; } function discordDataStatus(discordClient) { const guildId = getSetting("discord_guild_id", ""); if (!guildId) return "unavailable"; return discordClient?.guilds?.cache?.get?.(guildId) ? "live_cache" : "configured_not_connected"; } function countDiscordChannels(guild, kind) { const channels = Array.from(guild.channels?.cache?.values?.() || []); return channels.filter((channel) => { const type = channel?.type; if (kind === "voice") { return type === 2 || type === 13 || /voice|stage/i.test(String(type)); } return [0, 5, 10, 11, 12, 15, 16].includes(type) || /text|news|announcement|forum|media|thread/i.test(String(type)); }).length; } function collectionSize(collection) { if (!collection) return ""; if (typeof collection.size === "number") return collection.size; if (Array.isArray(collection)) return collection.length; return ""; } function twitchChannels() { return String(getSetting("twitch_channels", "") || "") .split(/[,\s]+/) .map((entry) => entry.trim().replace(/^#/, "")) .filter(Boolean); } function firstTwitchChannel() { return twitchChannels()[0] || ""; } module.exports = { catalog, checkPlaceholderAccess, getFieldPolicy, parsePlaceholders, registerCorePlaceholders, registerCustomPlaceholders, registerFieldPolicy, registerPlaceholder, registerPlaceholders, registerPlatformPlaceholders, renderTemplate, unregisterNamespace, validateCustomPlaceholders, unregisterPlaceholder, validateTemplate, _internals: { normalizeId, normalizePolicy, normalizeDefinition, placeholders, fieldPolicies } };