Lumi/plugins/birthday/index.js
2026-06-11 06:35:43 +02:00

965 lines
34 KiB
JavaScript

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 <user>, 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() : "";
}