Lumi/src/services/placeholders.js
2026-07-17 22:10:00 +02:00

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
}
};