965 lines
34 KiB
JavaScript
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() : "";
|
|
}
|