844 lines
28 KiB
JavaScript
844 lines
28 KiB
JavaScript
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
|
|
}
|
|
};
|