const crypto = require("crypto"); const path = require("path"); const PLUGIN_ID = "birthday"; const TIMER_KEY = Symbol.for("lumi.birthday.interval"); const ALLOWED_PRIVACY = new Set(["public", "limited", "private"]); const ALLOWED_PLACEHOLDERS = new Set([ "username", "display_name", "pronoun", "pronoun_subject", "pronoun_object", "pronoun_possessive", "birthday", "birthday_day", "birthday_day_text", "birthday_month", "time_until_birthday", "days_until_birthday", "months_until_birthday", "age_before", "age_after", "birthday_weekday", "gift_amount", "gift_amount_text", "currency_name" ]); const DEFAULT_FULL = [ "Happy birthday, {display_name}! {display_name} is turning {age_after} today.", "Everyone wish {display_name} a happy birthday! From {age_before} to {age_after} - hope today is amazing." ]; const DEFAULT_PARTIAL = [ "Happy birthday, {display_name}! Hope today is full of good vibes and cake.", "Everyone wish {display_name} a happy birthday today!" ]; const DEFAULTS = { enabled: "1", announcement_channel_id: "", timezone: "UTC", leap_day_policy: "feb28", gift_mode: "automatic", gift_amount: "0", birthday_check_interval_minutes: "60", response_templates: JSON.stringify({ fullYear: DEFAULT_FULL.map((text) => newTemplate(text)), partialYear: DEFAULT_PARTIAL.map((text) => newTemplate(text)) }) }; module.exports = { id: PLUGIN_ID, init({ web, db, settings, commandRouter, discordClient }) { ensureTables(db); ensureDefaults(db); registerCommands({ db, settings, commandRouter }); const router = web.createRouter(); router.get("/", async (req, res) => { const user = req.session.user || null; if (!canModerate(user)) { return renderDenied(res); } const config = getConfig(db); const diagnostics = await buildDiagnostics(discordClient, config); res.render(path.join(__dirname, "views", "birthday-admin.ejs"), { title: "Birthdays", config, diagnostics, textChannels: getTextChannels(discordClient), isAdmin: Boolean(user?.isAdmin), allowedPlaceholders: Array.from(ALLOWED_PLACEHOLDERS).sort(), previewTemplate, formatDateTime }); }); router.post("/settings", async (req, res) => { const user = req.session.user || null; if (!canModerate(user)) { return renderDenied(res); } const config = getConfig(db); const next = { enabled: req.body.enabled === "on" ? "1" : "0", announcement_channel_id: (req.body.announcement_channel_id || "").trim(), timezone: (req.body.timezone || "UTC").trim(), leap_day_policy: req.body.leap_day_policy === "mar1" ? "mar1" : "feb28", gift_mode: req.body.gift_mode === "manual" ? "manual" : "automatic", birthday_check_interval_minutes: String(clampInt(req.body.birthday_check_interval_minutes, 5, 1440, 60)), gift_amount: config.gift_amount, response_templates: JSON.stringify(config.response_templates) }; if (!isValidTimezone(next.timezone)) { req.session.flash = { type: "error", message: "Timezone must be a valid IANA timezone." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } if (next.announcement_channel_id) { const channel = await validateTextChannel(discordClient, next.announcement_channel_id); if (!channel.valid) { req.session.flash = { type: "error", message: channel.message }; return res.redirect(`/plugins/${PLUGIN_ID}`); } } if (user?.isAdmin) { next.gift_amount = String(Math.max(0, parseInt(req.body.gift_amount || "0", 10) || 0)); } else if ((req.body.gift_amount || "").trim() !== String(config.gift_amount)) { req.session.flash = { type: "error", message: "Only admins can change the birthday gift amount." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } saveConfig(db, next); restartScheduler({ db, discordClient }); req.session.flash = { type: "success", message: "Birthday settings saved." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/templates/create", (req, res) => { if (!canModerate(req.session.user)) { return renderDenied(res); } const pool = normalizePool(req.body.pool); const text = (req.body.text || "").trim(); const error = validateTemplate(text); if (error) { req.session.flash = { type: "error", message: error }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const config = getConfig(db); config.response_templates[pool].push(newTemplate(text)); saveConfig(db, serializeConfig(config)); req.session.flash = { type: "success", message: "Template added." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/templates/:id/update", (req, res) => { if (!canModerate(req.session.user)) { return renderDenied(res); } const pool = normalizePool(req.body.pool); const text = (req.body.text || "").trim(); const error = validateTemplate(text); if (error) { req.session.flash = { type: "error", message: error }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const config = getConfig(db); const template = config.response_templates[pool].find((item) => item.id === req.params.id); if (!template) { req.session.flash = { type: "error", message: "Template not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } template.text = text; template.enabled = req.body.enabled === "on"; template.updatedAt = Date.now(); saveConfig(db, serializeConfig(config)); req.session.flash = { type: "success", message: "Template updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/templates/:id/duplicate", (req, res) => { if (!canModerate(req.session.user)) { return renderDenied(res); } const pool = normalizePool(req.body.pool); const config = getConfig(db); const source = config.response_templates[pool].find((item) => item.id === req.params.id); if (source) { config.response_templates[pool].push(newTemplate(source.text)); saveConfig(db, serializeConfig(config)); } req.session.flash = { type: "success", message: "Template duplicated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/templates/:id/remove", (req, res) => { if (!canModerate(req.session.user)) { return renderDenied(res); } const pool = normalizePool(req.body.pool); const config = getConfig(db); config.response_templates[pool] = config.response_templates[pool].filter((item) => item.id !== req.params.id); saveConfig(db, serializeConfig(config)); req.session.flash = { type: "success", message: "Template removed." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/profile", (req, res) => { const user = req.session.user || null; if (!user?.id) { return renderDenied(res); } const parsed = parseBirthdayInput(req.body.birthday || ""); if (!parsed.ok) { req.session.flash = { type: "error", message: parsed.message }; return res.redirect("/profile"); } const privacy = normalizePrivacy(req.body.privacy); upsertBirthday(db, user.id, { ...parsed, privacy }); req.session.flash = { type: "success", message: `Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.` }; res.redirect("/profile"); }); router.post("/profile/unset", (req, res) => { const user = req.session.user || null; if (!user?.id) { return renderDenied(res); } deleteBirthday(db, user.id); req.session.flash = { type: "success", message: "Birthday removed." }; res.redirect("/profile"); }); router.get("/u/:username", (req, res) => { const viewer = req.session.user || null; const target = findUser(db, req.params.username); if (!target) { return res.status(404).render("error", { title: "Birthday not found", message: "No matching Lumi user was found." }); } const birthday = getBirthday(db, target.id); const canView = birthday && canViewBirthday({ viewer, ownerId: target.id, privacy: birthday.privacy, commandContext: false }); res.render(path.join(__dirname, "views", "profile-birthday.ejs"), { title: `${target.internal_username} Birthday`, target, birthday, canView, viewer, formatBirthday, formatBirthdayDateOnly, publicUrl: `/plugins/${PLUGIN_ID}/u/${encodeURIComponent(target.internal_username)}` }); }); web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Birthdays", role: "mod", section: "plugins" }); ensureSidebarNavItem(settings); if (typeof web.addProfileSection === "function") { web.addProfileSection({ id: PLUGIN_ID, label: "Birthday", view: path.join(__dirname, "views", "profile-birthday.ejs"), order: 45, locals: { profileSection: true, getBirthday: (userId) => getBirthday(db, userId), formatBirthday, formatBirthdayDateOnly } }); } restartScheduler({ db, discordClient }); } }; function ensureTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS birthday_profiles ( user_id TEXT PRIMARY KEY REFERENCES user_profiles(id) ON DELETE CASCADE, year INTEGER NULL, month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), day INTEGER NOT NULL CHECK(day BETWEEN 1 AND 31), privacy TEXT NOT NULL DEFAULT 'limited' CHECK(privacy IN ('public','limited','private')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS birthday_deliveries ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, delivery_key TEXT NOT NULL, delivery_type TEXT NOT NULL CHECK(delivery_type IN ('announcement','gift')), status TEXT NOT NULL CHECK(status IN ('sent','skipped','failed','claimed')), details TEXT NULL, created_at INTEGER NOT NULL, UNIQUE(user_id, delivery_key, delivery_type) ); CREATE INDEX IF NOT EXISTS birthday_profiles_month_day_idx ON birthday_profiles(month, day); CREATE INDEX IF NOT EXISTS birthday_deliveries_key_idx ON birthday_deliveries(delivery_key, delivery_type); `); } function ensureDefaults(db) { const now = Date.now(); for (const [key, value] of Object.entries(DEFAULTS)) { db.prepare( "INSERT OR IGNORE INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?)" ).run(PLUGIN_ID, key, value, now); } } function getConfig(db) { const rows = db.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?").all(PLUGIN_ID); const raw = { ...DEFAULTS }; for (const row of rows) { raw[row.key] = row.value; } let templates; try { templates = JSON.parse(raw.response_templates); } catch { templates = {}; } return { enabled: raw.enabled === "1", announcement_channel_id: raw.announcement_channel_id || "", timezone: isValidTimezone(raw.timezone) ? raw.timezone : "UTC", leap_day_policy: raw.leap_day_policy === "mar1" ? "mar1" : "feb28", gift_mode: raw.gift_mode === "manual" ? "manual" : "automatic", gift_amount: Math.max(0, parseInt(raw.gift_amount || "0", 10) || 0), birthday_check_interval_minutes: clampInt(raw.birthday_check_interval_minutes, 5, 1440, 60), response_templates: normalizeTemplates(templates) }; } function serializeConfig(config) { return { enabled: config.enabled ? "1" : "0", announcement_channel_id: config.announcement_channel_id || "", timezone: config.timezone || "UTC", leap_day_policy: config.leap_day_policy === "mar1" ? "mar1" : "feb28", gift_mode: config.gift_mode === "manual" ? "manual" : "automatic", gift_amount: String(Math.max(0, parseInt(config.gift_amount || "0", 10) || 0)), birthday_check_interval_minutes: String(clampInt(config.birthday_check_interval_minutes, 5, 1440, 60)), response_templates: JSON.stringify(normalizeTemplates(config.response_templates)) }; } function saveConfig(db, values) { const now = Date.now(); for (const [key, value] of Object.entries(values)) { db.prepare( "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" ).run(PLUGIN_ID, key, String(value), now); } } function normalizeTemplates(value) { return { fullYear: normalizeTemplateList(value?.fullYear, DEFAULT_FULL), partialYear: normalizeTemplateList(value?.partialYear, DEFAULT_PARTIAL) }; } function normalizeTemplateList(items, defaults) { const source = Array.isArray(items) && items.length ? items : defaults.map((text) => newTemplate(text)); return source .map((item) => ({ id: item?.id || crypto.randomUUID(), text: (typeof item === "string" ? item : item?.text || "").toString(), enabled: item?.enabled !== false, createdAt: Number(item?.createdAt) || Date.now(), updatedAt: Number(item?.updatedAt) || Date.now() })) .filter((item) => item.text.trim()); } function newTemplate(text) { const now = Date.now(); return { id: crypto.randomUUID(), text, enabled: true, createdAt: now, updatedAt: now }; } function registerCommands({ db, settings, commandRouter }) { if (!commandRouter) { return; } commandRouter.registerCommands(PLUGIN_ID, [ { id: "birthday", triggers: ["birthday", "bday"], platforms: ["discord", "twitch", "youtube"], handler: (ctx) => handleBirthdayCommand({ ctx, db, settings }) } ]); } async function handleBirthdayCommand({ ctx, db, settings }) { const prefix = settings?.getSetting ? settings.getSetting("command_prefix", "!") : "!"; const sub = (ctx.args[0] || "").toLowerCase(); if (!sub) { const own = getBirthday(db, ctx.user.id); const suffix = own ? ` Your birthday is ${formatBirthday(own)} (${own.privacy}).` : ""; await ctx.reply(`Usage: ${prefix}birthday set YYYY/MM/DD, ${prefix}birthday set MM/DD, ${prefix}birthday unset, ${prefix}birthday , or ${prefix}birthday claim.${suffix}`); return true; } if (sub === "set") { const input = ctx.args[1] || ""; if (!input) { await ctx.reply("Use YYYY/MM/DD for a full birthday or MM/DD if you do not want to store the year. Year is optional but recommended for age-based messages."); return true; } const parsed = parseBirthdayInput(input); if (!parsed.ok) { await ctx.reply(parsed.message); return true; } const existing = getBirthday(db, ctx.user.id); const privacy = existing?.privacy || "limited"; upsertBirthday(db, ctx.user.id, { ...parsed, privacy }); await ctx.reply(`Stored birthday: ${formatBirthday(parsed)}. Privacy: ${privacy}.`); return true; } if (sub === "unset") { deleteBirthday(db, ctx.user.id); await ctx.reply("Birthday removed."); return true; } if (sub === "claim") { await ctx.reply(await claimGift(db, ctx.user.id)); return true; } const targetText = ctx.argsText.trim(); const target = findUser(db, targetText); if (!target) { await ctx.reply("No matching Lumi user was found."); return true; } const birthday = getBirthday(db, target.id); if (!birthday) { await ctx.reply("No birthday on file for that user."); return true; } if (!canViewBirthday({ viewer: { id: ctx.user.id }, ownerId: target.id, privacy: birthday.privacy, commandContext: true })) { await ctx.reply("That birthday is private."); return true; } await ctx.reply(`${target.internal_username}'s birthday is ${formatBirthdayDateOnly(birthday)}.`); return true; } function parseBirthdayInput(input) { const text = (input || "").trim(); const full = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/); const partial = text.match(/^(\d{1,2})\/(\d{1,2})$/); const nowYear = new Date().getUTCFullYear(); if (full) { const year = parseInt(full[1], 10); const month = parseInt(full[2], 10); const day = parseInt(full[3], 10); if (year < 1900 || year > nowYear) { return { ok: false, message: `Year must be between 1900 and ${nowYear}.` }; } if (!isValidDate(year, month, day)) { return { ok: false, message: "Birthday must be a real calendar date in YYYY/MM/DD format." }; } return { ok: true, year, month, day }; } if (partial) { const month = parseInt(partial[1], 10); const day = parseInt(partial[2], 10); if (!isValidMonthDay(month, day)) { return { ok: false, message: "Birthday must be a real calendar date in MM/DD format." }; } return { ok: true, year: null, month, day }; } return { ok: false, message: "Use slash-delimited YYYY/MM/DD or MM/DD. DD/MM and dash-separated dates are not accepted." }; } function isValidDate(year, month, day) { const date = new Date(Date.UTC(year, month - 1, day)); return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; } function isValidMonthDay(month, day) { return month === 2 && day === 29 ? true : isValidDate(2000, month, day); } function upsertBirthday(db, userId, birthday) { const now = Date.now(); db.prepare( "INSERT INTO birthday_profiles (user_id, year, month, day, privacy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET year = excluded.year, month = excluded.month, day = excluded.day, privacy = excluded.privacy, updated_at = excluded.updated_at" ).run(userId, birthday.year || null, birthday.month, birthday.day, normalizePrivacy(birthday.privacy), now, now); } function deleteBirthday(db, userId) { db.prepare("DELETE FROM birthday_profiles WHERE user_id = ?").run(userId); } function getBirthday(db, userId) { return userId ? db.prepare("SELECT * FROM birthday_profiles WHERE user_id = ?").get(userId) : null; } function findUser(db, text) { const raw = (text || "").trim(); if (!raw) { return null; } const mention = raw.match(/^<@!?(\d+)>$/); if (mention) { const row = db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE i.provider = 'discord' AND i.provider_user_id = ?").get(mention[1]); if (row) return row; } const cleaned = raw.replace(/^@/, ""); return db.prepare("SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?) LIMIT 1").get(cleaned) || db.prepare("SELECT p.id, p.internal_username FROM user_profiles p JOIN user_identities i ON i.user_id = p.id WHERE lower(i.display_name) = lower(?) OR i.provider_user_id = ? LIMIT 1").get(cleaned, cleaned); } function canViewBirthday({ viewer, ownerId, privacy, commandContext }) { if (viewer?.id && viewer.id === ownerId) { return true; } if (viewer?.isAdmin || viewer?.isMod) { return true; } if (privacy === "public") { return true; } if (privacy === "limited") { return commandContext ? Boolean(viewer?.id) : Boolean(viewer?.id); } return false; } function normalizePrivacy(value) { return ALLOWED_PRIVACY.has(value) ? value : "limited"; } function restartScheduler({ db, discordClient }) { if (global[TIMER_KEY]) { clearInterval(global[TIMER_KEY]); global[TIMER_KEY] = null; } const config = getConfig(db); if (!config.enabled) { return; } checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); global[TIMER_KEY] = setInterval(() => { checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); }, config.birthday_check_interval_minutes * 60 * 1000); } async function checkBirthdays({ db, discordClient }) { const config = getConfig(db); if (!config.enabled) { return; } const today = getZonedDateParts(config.timezone); const effectiveDates = getEffectiveDates(today, config.leap_day_policy); const channelResult = config.announcement_channel_id ? await validateTextChannel(discordClient, config.announcement_channel_id) : { valid: false, message: "No birthday channel configured." }; const birthdays = db.prepare( "SELECT b.*, p.internal_username FROM birthday_profiles b JOIN user_profiles p ON p.id = b.user_id WHERE " + effectiveDates.map(() => "(b.month = ? AND b.day = ?)").join(" OR ") ).all(...effectiveDates.flatMap((date) => [date.month, date.day])); for (const birthday of birthdays) { const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`; const announceReserved = reserveDelivery(db, birthday.user_id, deliveryKey, "announcement", channelResult.valid ? "sent" : "skipped", channelResult.message); if (!announceReserved) { continue; } let gift = null; if (config.gift_mode === "automatic") { gift = grantGiftOnce(db, birthday.user_id, deliveryKey, "automatic"); } if (!channelResult.valid || !channelResult.channel) { continue; } const message = buildBirthdayMessage({ db, config, birthday, today, gift }); await channelResult.channel.send({ content: message, allowedMentions: { parse: [] } }); } } function reserveDelivery(db, userId, deliveryKey, type, status, details) { try { db.prepare( "INSERT INTO birthday_deliveries (id, user_id, delivery_key, delivery_type, status, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ).run(crypto.randomUUID(), userId, deliveryKey, type, status, details || null, Date.now()); return true; } catch { return false; } } function grantGiftOnce(db, userId, deliveryKey, mode) { const config = getConfig(db); const amount = config.gift_amount; const framework = global.lumiFrameworks?.echonomy; if (!amount || !framework || typeof framework.addBalance !== "function") { return null; } if (!reserveDelivery(db, userId, deliveryKey, "gift", mode === "manual" ? "claimed" : "sent", "Gift reserved.")) { return null; } try { framework.addBalance({ userId, amount, note: "Birthday Gift", meta: { source: "birthday", deliveryKey, mode }, allowFrozen: false }); return { amount, currencyName: getCurrencyName(framework) }; } catch (error) { db.prepare("UPDATE birthday_deliveries SET status = 'failed', details = ? WHERE user_id = ? AND delivery_key = ? AND delivery_type = 'gift'") .run(error?.message || String(error), userId, deliveryKey); return null; } } async function claimGift(db, userId) { const config = getConfig(db); if (config.gift_mode !== "manual") { return "Birthday gifts are automatic right now."; } if (!config.gift_amount) { return "Birthday gifts are not configured right now."; } const birthday = getBirthday(db, userId); if (!birthday) { return "You do not have a birthday on file."; } const today = getZonedDateParts(config.timezone); if (!isBirthdayToday(birthday, today, config.leap_day_policy)) { return "Birthday gifts can only be claimed on your birthday."; } const deliveryKey = `${today.year}-${pad2(today.month)}-${pad2(today.day)}`; const gift = grantGiftOnce(db, userId, deliveryKey, "manual"); return gift ? `Birthday gift claimed: ${gift.amount}.` : "Birthday gift is unavailable or was already claimed."; } function buildBirthdayMessage({ db, config, birthday, today, gift }) { const profile = db.prepare("SELECT internal_username FROM user_profiles WHERE id = ?").get(birthday.user_id); const tokens = buildTokens({ db, profile, birthday, today, gift }); const pool = birthday.year ? config.response_templates.fullYear : config.response_templates.partialYear; const eligible = pool.filter((item) => item.enabled && templateUsable(item.text, tokens)); const template = eligible.length ? eligible[Math.floor(Math.random() * eligible.length)].text : "Happy birthday, {display_name}!"; return renderTemplate(template, tokens); } function buildTokens({ db, profile, birthday, today, gift }) { const upcoming = getUpcomingBirthday(birthday, today); const pronouns = getPronouns(db, birthday.user_id); const ageAfter = birthday.year ? upcoming.year - birthday.year : null; const daysUntil = daysBetween(today, upcoming); const currencyName = gift?.currencyName || "coins"; return { username: profile?.internal_username || "Lumi user", display_name: profile?.internal_username || "Lumi user", pronoun: pronouns.value, pronoun_subject: pronouns.subject, pronoun_object: pronouns.object, pronoun_possessive: pronouns.possessive, birthday: formatBirthdayDateOnly(birthday), birthday_day: String(birthday.day), birthday_day_text: ordinal(birthday.day), birthday_month: monthName(birthday.month), time_until_birthday: daysUntil === 0 ? "today" : `${daysUntil} day(s)`, days_until_birthday: String(daysUntil), months_until_birthday: String(Math.floor(daysUntil / 30)), age_before: ageAfter === null ? null : String(ageAfter - 1), age_after: ageAfter === null ? null : String(ageAfter), birthday_weekday: weekdayName(upcoming.year, upcoming.month, upcoming.day), gift_amount: gift ? String(gift.amount) : null, gift_amount_text: gift ? `${gift.amount} ${currencyName}` : null, currency_name: gift ? currencyName : null }; } function templateUsable(text, tokens) { for (const match of text.matchAll(/\{([^{}]+)\}/g)) { const key = match[1].trim(); if (!Object.prototype.hasOwnProperty.call(tokens, key) || tokens[key] === null || tokens[key] === undefined) { return false; } } return true; } function renderTemplate(text, tokens) { return text.replace(/\{([^{}]+)\}/g, (full, key) => { const value = tokens[key.trim()]; return value === null || value === undefined ? full : String(value); }); } function validateTemplate(text) { if (!text) { return "Template text is required."; } if (text.length > 1800) { return "Template text is too long."; } for (const match of text.matchAll(/\{([^{}]+)\}/g)) { const key = match[1].trim(); if (!ALLOWED_PLACEHOLDERS.has(key)) { return `Unknown placeholder: {${key}}.`; } } return null; } function previewTemplate(text) { return renderTemplate(text, { username: "lumi_user", display_name: "Lumi User", pronoun: "they/them", pronoun_subject: "they", pronoun_object: "them", pronoun_possessive: "their", birthday: "April 17", birthday_day: "17", birthday_day_text: "17th", birthday_month: "April", time_until_birthday: "2 months, 5 days", days_until_birthday: "66", months_until_birthday: "2", age_before: "30", age_after: "31", birthday_weekday: "Friday", gift_amount: "100", gift_amount_text: "100 coins", currency_name: "coins" }); } function getPronouns(db, userId) { try { const row = db.prepare("SELECT pronoun_set, subject_pronoun FROM welcome_message_pronouns WHERE user_id = ?").get(userId); return pronounParts(row?.pronoun_set || row?.subject_pronoun); } catch { return pronounParts(null); } } function pronounParts(value) { const raw = (value || "they/them").toString().toLowerCase(); if (raw.startsWith("he/")) return { value: raw, subject: "he", object: "him", possessive: "his" }; if (raw.startsWith("she/")) return { value: raw, subject: "she", object: "her", possessive: "her" }; if (raw.startsWith("it/")) return { value: raw, subject: "it", object: "it", possessive: "its" }; return { value: raw || "they/them", subject: "they", object: "them", possessive: "their" }; } function getZonedDateParts(timezone) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date()); const get = (type) => parseInt(parts.find((part) => part.type === type)?.value, 10); return { year: get("year"), month: get("month"), day: get("day") }; } function getEffectiveDates(today, leapPolicy) { const dates = [{ month: today.month, day: today.day }]; if (!isLeapYear(today.year)) { if (leapPolicy === "mar1" && today.month === 3 && today.day === 1) { dates.push({ month: 2, day: 29 }); } if (leapPolicy !== "mar1" && today.month === 2 && today.day === 28) { dates.push({ month: 2, day: 29 }); } } return dates; } function isBirthdayToday(birthday, today, leapPolicy) { return getEffectiveDates(today, leapPolicy).some((date) => date.month === birthday.month && date.day === birthday.day); } function getUpcomingBirthday(birthday, today) { let year = today.year; let month = birthday.month; let day = birthday.day; if (month === 2 && day === 29 && !isLeapYear(year)) { day = 28; } if (month < today.month || (month === today.month && day < today.day)) { year += 1; if (birthday.month === 2 && birthday.day === 29 && !isLeapYear(year)) { month = 2; day = 28; } } return { year, month, day }; } function daysBetween(from, to) { const start = Date.UTC(from.year, from.month - 1, from.day); const end = Date.UTC(to.year, to.month - 1, to.day); return Math.max(0, Math.round((end - start) / 86400000)); } function formatBirthday(birthday) { const base = `${monthName(birthday.month)} ${birthday.day}`; return birthday.year ? `${base}, ${birthday.year}` : `${base} (year not set)`; } function formatBirthdayDateOnly(birthday) { return `${monthName(birthday.month)} ${birthday.day}`; } function monthName(month) { return new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month - 1, 1))); } function weekdayName(year, month, day) { return new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" }).format(new Date(Date.UTC(year, month - 1, day))); } function ordinal(day) { const mod10 = day % 10; const mod100 = day % 100; if (mod10 === 1 && mod100 !== 11) return `${day}st`; if (mod10 === 2 && mod100 !== 12) return `${day}nd`; if (mod10 === 3 && mod100 !== 13) return `${day}rd`; return `${day}th`; } function isLeapYear(year) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } function isValidTimezone(timezone) { try { new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date()); return true; } catch { return false; } } function normalizePool(pool) { return pool === "partialYear" ? "partialYear" : "fullYear"; } function clampInt(value, min, max, fallback) { const parsed = parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); } function pad2(value) { return String(value).padStart(2, "0"); } function getCurrencyName(framework) { try { const config = typeof framework.getConfig === "function" ? framework.getConfig() : null; return config?.currency?.plural || config?.currency?.name || "coins"; } catch { return "coins"; } } function canModerate(user) { return Boolean(user?.isAdmin || user?.isMod); } function ensureSidebarNavItem(settings) { if (!settings?.getSetting || !settings?.setSetting) { return; } const navId = "plugins_birthday"; 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 renderDenied(res) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } async function buildDiagnostics(discordClient, config) { const channel = config.announcement_channel_id ? await validateTextChannel(discordClient, config.announcement_channel_id) : { valid: false, message: "No announcement channel configured." }; return { discordAvailable: Boolean(discordClient), discordReady: Boolean(discordClient?.readyAt), channel, echonomyAvailable: Boolean(global.lumiFrameworks?.echonomy?.addBalance), currentDate: getZonedDateParts(config.timezone) }; } function getTextChannels(discordClient) { const channels = []; if (!discordClient?.guilds?.cache) { return channels; } for (const guild of discordClient.guilds.cache.values()) { guild.channels?.cache?.forEach((channel) => { if (isRegularTextChannel(channel)) { channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` }); } }); } return channels.sort((a, b) => a.label.localeCompare(b.label)); } async function validateTextChannel(discordClient, channelId) { if (!discordClient) { return { valid: false, message: "Discord client is unavailable." }; } const channel = discordClient.channels?.cache?.get(channelId) || (typeof discordClient.channels?.fetch === "function" ? await discordClient.channels.fetch(channelId).catch(() => null) : null); if (!channel) { return { valid: false, message: "Configured channel was not found." }; } if (!isRegularTextChannel(channel)) { return { valid: false, message: "Birthday channel must be a regular text channel." }; } return { valid: true, message: "Channel is valid.", channel }; } function isRegularTextChannel(channel) { if (!channel || channel.isThread?.()) return false; return channel.type === "GUILD_TEXT" || channel.type === 0; } function formatDateTime(value) { return value ? new Date(value).toLocaleString() : ""; }