Lumi/plugins/welcome_messages/index.js
2026-05-30 22:07:04 +02:00

725 lines
23 KiB
JavaScript

const crypto = require("crypto");
const path = require("path");
const discord = require("discord.js");
const { Permissions } = discord;
const { ensureUserForIdentity } = require("../../src/services/users");
const { log } = require("../../src/services/logger");
const PLUGIN_ID = "welcome_messages";
const CONFIG_KEY = "config";
const ALLOWED_PLACEHOLDERS = new Set([
"username",
"displayname",
"pronoun",
"guildmembers"
]);
const LISTENER_KEY = Symbol.for("lumi.welcome_messages.guildMemberAdd");
const DEFAULT_WELCOME_MESSAGES = [
"Welcome {displayname}! Everyone say hi - {pronoun} just became member #{guildmembers}.",
"A wild {displayname} appeared. Welcome to the server!",
"Glad you made it, {displayname}. Make yourself at home!",
"Welcome in, {displayname}! The server now has {guildmembers} members."
];
const DEFAULT_WELCOME_BACK_MESSAGES = [
"Welcome back, {displayname}! Good to see {pronoun} again.",
"Look who's back: {displayname}! Hope you've been well.",
"Welcome back to the server, {displayname}. We kept the lights on for you.",
"Hey {displayname}, welcome back!"
];
const PRONOUN_SETS = [
{ value: "they/them", label: "they/them", subject: "they" },
{ value: "he/him", label: "he/him", subject: "he" },
{ value: "she/her", label: "she/her", subject: "she" },
{ value: "it/its", label: "it/its", subject: "it" },
{ value: "he/they", label: "he/they", subject: "he" },
{ value: "she/they", label: "she/they", subject: "she" },
{ value: "they/he", label: "they/he", subject: "they" },
{ value: "they/she", label: "they/she", subject: "they" },
{ value: "any/all", label: "any/all", subject: "they" },
{ value: "use name", label: "use name", subject: "they" }
];
module.exports = {
id: PLUGIN_ID,
init({ web, db, discordClient, settings }) {
ensureTables(db);
ensureConfig(db);
log("info", "Welcome Messages plugin initialized");
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.channelId);
res.render(path.join(__dirname, "views", "settings.ejs"), {
title: "Welcome Messages",
config,
diagnostics,
textChannels: getTextChannels(discordClient),
isAdmin: Boolean(user?.isAdmin),
canModerate: true,
allowedPlaceholders: Array.from(ALLOWED_PLACEHOLDERS),
pronounSets: PRONOUN_SETS,
previewMessage
});
});
router.post("/settings", async (req, res) => {
if (!req.session.user?.isAdmin) {
return renderDenied(res);
}
const config = getConfig(db);
const channelId = (req.body.channel_id || "").trim();
if (channelId) {
const validation = await validateTextChannel(discordClient, channelId);
if (!validation.valid) {
req.session.flash = { type: "error", message: validation.message };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
}
config.enabled = req.body.enabled === "on";
config.welcomeBackEnabled = req.body.welcome_back_enabled === "on";
config.channelId = channelId;
saveConfig(db, config);
req.session.flash = { type: "success", message: "Welcome settings saved." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/messages/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[pool].push(newMessage(text, req.body.enabled !== "off"));
saveConfig(db, config);
req.session.flash = { type: "success", message: "Message added." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/messages/: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 message = config[pool].find((item) => item.id === req.params.id);
if (!message) {
req.session.flash = { type: "error", message: "Message not found." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
message.text = text;
message.enabled = req.body.enabled === "on";
message.updatedAt = Date.now();
saveConfig(db, config);
req.session.flash = { type: "success", message: "Message updated." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/messages/: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[pool].find((item) => item.id === req.params.id);
if (!source) {
req.session.flash = { type: "error", message: "Message not found." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
config[pool].push(newMessage(source.text, source.enabled));
saveConfig(db, config);
req.session.flash = { type: "success", message: "Message duplicated." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/messages/:id/archive", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const config = getConfig(db);
const message = config[pool].find((item) => item.id === req.params.id);
if (message) {
message.archived = true;
message.enabled = false;
message.updatedAt = Date.now();
saveConfig(db, config);
}
req.session.flash = { type: "success", message: "Message archived." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/messages/:id/restore", (req, res) => {
if (!canModerate(req.session.user)) {
return renderDenied(res);
}
const pool = normalizePool(req.body.pool);
const config = getConfig(db);
const message = config[pool].find((item) => item.id === req.params.id);
if (message) {
message.archived = false;
message.updatedAt = Date.now();
saveConfig(db, config);
}
req.session.flash = { type: "success", message: "Message restored." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/profile/pronouns", (req, res) => {
const user = req.session.user || null;
if (!user?.id) {
return renderDenied(res);
}
const pronouns = normalizePronounSet(req.body.pronoun_set);
savePronouns(db, user.id, pronouns);
req.session.flash = { type: "success", message: "Pronouns saved." };
res.redirect("/profile");
});
web.mount(`/plugins/${PLUGIN_ID}`, router, {
label: "Welcome Messages",
role: "mod",
section: "plugins"
});
ensureSidebarNavItem(settings);
if (typeof web.addProfileSection === "function") {
web.addProfileSection({
id: PLUGIN_ID,
label: "Pronouns",
view: path.join(__dirname, "views", "profile-pronouns.ejs"),
order: 40,
locals: {
pronounSets: PRONOUN_SETS,
getPronouns: (userId) => getStoredPronouns(db, userId)
}
});
}
attachDiscordListener(discordClient, db);
logStartupDiagnostics(discordClient);
}
};
function ensureTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS welcome_message_pronouns (
user_id TEXT PRIMARY KEY,
pronoun_set TEXT NOT NULL,
subject_pronoun TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
function ensureConfig(db) {
const existing = db
.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?")
.get(PLUGIN_ID, CONFIG_KEY);
if (!existing) {
saveConfig(db, normalizeConfig({}));
}
}
function getConfig(db) {
const row = db
.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?")
.get(PLUGIN_ID, CONFIG_KEY);
if (!row) {
return normalizeConfig({});
}
try {
return normalizeConfig(JSON.parse(row.value));
} catch {
return normalizeConfig({});
}
}
function saveConfig(db, config) {
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, CONFIG_KEY, JSON.stringify(normalizeConfig(config)), Date.now());
}
function normalizeConfig(config) {
return {
enabled: config?.enabled !== false,
channelId: (config?.channelId || "").toString().trim(),
welcomeBackEnabled: Boolean(config?.welcomeBackEnabled),
welcomeMessages: normalizeMessages(config?.welcomeMessages, DEFAULT_WELCOME_MESSAGES),
welcomeBackMessages: normalizeMessages(
config?.welcomeBackMessages,
DEFAULT_WELCOME_BACK_MESSAGES
)
};
}
function normalizeMessages(messages, defaults) {
const source = Array.isArray(messages) && messages.length
? messages
: defaults.map((text) => ({ text, enabled: true }));
return source
.map((item) => {
const text = typeof item === "string" ? item : item?.text;
const now = Date.now();
return {
id: item?.id || crypto.randomUUID(),
text: (text || "").toString(),
enabled: item?.enabled !== false,
archived: Boolean(item?.archived),
createdAt: Number(item?.createdAt) || now,
updatedAt: Number(item?.updatedAt) || now
};
})
.filter((item) => item.text.trim());
}
function newMessage(text, enabled) {
const now = Date.now();
return {
id: crypto.randomUUID(),
text,
enabled: Boolean(enabled),
archived: false,
createdAt: now,
updatedAt: now
};
}
function normalizePool(pool) {
return pool === "welcomeBackMessages" ? "welcomeBackMessages" : "welcomeMessages";
}
function validateTemplate(text) {
if (!text) {
return "Message text is required.";
}
if (text.length > 1800) {
return "Message text is too long.";
}
const matches = text.matchAll(/\{([^{}]+)\}/g);
for (const match of matches) {
const name = match[1].trim().toLowerCase();
if (!ALLOWED_PLACEHOLDERS.has(name)) {
return `Unknown placeholder: {${match[1]}}.`;
}
}
return null;
}
function canModerate(user) {
return Boolean(user?.isAdmin || user?.isMod);
}
function renderDenied(res) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
function ensureSidebarNavItem(settings) {
if (!settings?.getSetting || !settings?.setSetting) {
return;
}
const navId = "plugins_welcome_messages";
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;
}
if (structure.sections.some((section) => Array.isArray(section.items) && section.items.includes(navId))) {
return;
}
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.concat(navId)
: [navId];
settings.setSetting("nav_structure", structure);
}
function attachDiscordListener(discordClient, db) {
if (!discordClient || discordClient[LISTENER_KEY]) {
if (!discordClient) {
log("warn", "Welcome Messages listener not attached", {
reason: "Discord client is unavailable"
});
}
return;
}
discordClient[LISTENER_KEY] = true;
discordClient.on("guildMemberAdd", (member) => {
handleMemberJoin(member, db).catch((error) => {
log("error", "Welcome Messages join handler failed", error);
});
});
log("info", "Welcome Messages listener attached", {
event: "guildMemberAdd",
memberIntent: detectGuildMembersIntent(discordClient)
});
}
async function handleMemberJoin(member, db) {
const config = getConfig(db);
if (!config.enabled || !config.channelId) {
log("debug", "Welcome Messages skipped join", {
reason: !config.enabled ? "Plugin posting disabled" : "No welcome channel configured",
guildId: member?.guild?.id || null,
userId: member?.user?.id || member?.id || null
});
return;
}
const discordId = member?.user?.id || member?.id;
if (!discordId) {
log("warn", "Welcome Messages skipped join", {
reason: "Missing Discord user id",
guildId: member?.guild?.id || null
});
return;
}
const existingIdentity = db
.prepare(
"SELECT user_id FROM user_identities WHERE provider = 'discord' AND provider_user_id = ?"
)
.get(discordId);
const returning = Boolean(existingIdentity?.user_id);
const profile = ensureDiscordIdentity(member);
const channelResult = await validateTextChannel(member.client, config.channelId);
if (!channelResult.valid || !channelResult.channel) {
log("warn", "Welcome Messages skipped join", {
reason: channelResult.message,
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId
});
return;
}
const pool =
returning && config.welcomeBackEnabled
? config.welcomeBackMessages
: config.welcomeMessages;
const message = chooseMessage(pool) || chooseMessage(config.welcomeMessages);
if (!message) {
log("warn", "Welcome Messages skipped join", {
reason: "No enabled message templates",
guildId: member?.guild?.id || null,
userId: discordId
});
return;
}
const pronoun = getSubjectPronoun(db, profile?.id);
const rendered = renderTemplate(message.text, member, pronoun);
try {
await channelResult.channel.send({
content: rendered,
allowedMentions: { parse: [] }
});
log("info", "Welcome Messages sent welcome message", {
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId,
returning,
pool: returning && config.welcomeBackEnabled ? "welcomeBackMessages" : "welcomeMessages",
messageId: message.id
});
} catch (error) {
log("error", "Welcome Messages failed to send message", {
guildId: member?.guild?.id || null,
userId: discordId,
channelId: config.channelId,
error: error?.stack || error?.message || String(error)
});
}
}
function ensureDiscordIdentity(member) {
const user = member.user || {};
const displayName =
member.displayName || user.globalName || user.username || user.tag || "Discord User";
const avatar =
typeof user.displayAvatarURL === "function"
? user.displayAvatarURL({ format: "png", size: 128 })
: null;
return ensureUserForIdentity({
provider: "discord",
providerUserId: user.id || member.id,
displayName,
avatar,
fallbackName: user.username || user.tag || "discord-user"
});
}
function chooseMessage(messages) {
const active = (messages || []).filter((item) => item.enabled && !item.archived);
if (!active.length) {
return null;
}
return active[Math.floor(Math.random() * active.length)];
}
function renderTemplate(template, member, pronoun) {
const user = member.user || {};
const username = sanitizeMentionText(user.username || user.tag || "Discord User");
const displayname = sanitizeMentionText(
member.displayName || user.globalName || user.username || user.tag || "Discord User"
);
const guildmembers =
member.guild?.memberCount || member.guild?.members?.cache?.size || "unknown";
const values = { username, displayname, pronoun, guildmembers: String(guildmembers) };
return template.replace(/\{([^{}]+)\}/g, (full, key) => {
const normalized = key.trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(values, normalized)
? values[normalized]
: full;
});
}
function previewMessage(text) {
return (text || "").replace(/\{([^{}]+)\}/g, (full, key) => {
const values = {
username: "lumi_user",
displayname: "Lumi User",
pronoun: "they",
guildmembers: "123"
};
return values[key.trim().toLowerCase()] || full;
});
}
function sanitizeMentionText(value) {
return (value || "")
.toString()
.replace(/@everyone/gi, "@\u200beveryone")
.replace(/@here/gi, "@\u200bhere")
.replace(/<@&?\d+>/g, "[mention]")
.replace(/<#\d+>/g, "[channel]");
}
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 = await resolveChannel(discordClient, channelId);
if (!channel) {
return { valid: false, message: "Configured channel was not found." };
}
if (!isRegularTextChannel(channel)) {
return { valid: false, message: "Welcome channel must be a regular text channel." };
}
const botMember = await resolveBotMember(channel.guild, discordClient);
const permissions = botMember ? channel.permissionsFor(botMember) : null;
const canView = hasPermission(permissions, "VIEW_CHANNEL");
const canSend = hasPermission(permissions, "SEND_MESSAGES");
if (permissions && !canView) {
return { valid: false, message: "Bot cannot view the configured channel.", channel };
}
if (permissions && !canSend) {
return { valid: false, message: "Bot cannot send messages in the configured channel.", channel };
}
return { valid: true, message: "Channel is valid.", channel, canView, canSend };
}
function isRegularTextChannel(channel) {
if (!channel || channel.isThread?.()) {
return false;
}
const type = channel.type;
return type === "GUILD_TEXT" || type === 0;
}
async function resolveChannel(discordClient, channelId) {
if (!channelId) {
return null;
}
const cached = discordClient.channels?.cache?.get(channelId) || null;
if (cached) {
return cached;
}
if (typeof discordClient.channels?.fetch === "function") {
return discordClient.channels.fetch(channelId).catch(() => null);
}
return null;
}
async function resolveBotMember(guild, discordClient) {
if (!guild || !discordClient?.user?.id) {
return null;
}
return (
guild.members?.cache?.get(discordClient.user.id) ||
(typeof guild.members?.fetch === "function"
? await guild.members.fetch(discordClient.user.id).catch(() => null)
: null)
);
}
function hasPermission(permissions, flag) {
if (!permissions) {
return false;
}
const value = Permissions?.FLAGS?.[flag] || flag;
try {
return Boolean(permissions.has(value));
} catch {
return false;
}
}
async function buildDiagnostics(discordClient, channelId) {
const channelValidation = channelId
? await validateTextChannel(discordClient, channelId)
: { valid: false, message: "No welcome channel configured." };
return {
clientAvailable: Boolean(discordClient),
ready: Boolean(discordClient?.readyAt),
memberIntent: detectGuildMembersIntent(discordClient),
channel: channelValidation
};
}
function logStartupDiagnostics(discordClient) {
const memberIntent = detectGuildMembersIntent(discordClient);
const details = {
discordClientAvailable: Boolean(discordClient),
discordClientReady: Boolean(discordClient?.readyAt),
memberIntent
};
if (memberIntent === "configured") {
log("info", "Welcome Messages diagnostics passed", details);
return;
}
log("warn", "Welcome Messages diagnostics warning", {
...details,
message:
"Guild member join events require the runtime Discord client to start with GuildMembers/GUILD_MEMBERS and the Discord Developer Portal Server Members Intent enabled."
});
}
function detectGuildMembersIntent(discordClient) {
if (!discordClient) {
return "unknown";
}
const bitfield = resolveIntentBitfield(discordClient.options?.intents);
const guildMembersFlag = resolveGuildMembersFlag();
if (bitfield === null || guildMembersFlag === null) {
return "unknown";
}
return (bitfield & guildMembersFlag) !== 0n ? "configured" : "missing";
}
function resolveIntentBitfield(intents) {
if (intents === undefined || intents === null) {
return null;
}
if (typeof intents === "number" || typeof intents === "bigint") {
return BigInt(intents);
}
if (Array.isArray(intents)) {
return intents.reduce((acc, value) => {
const resolved = resolveIntentBitfield(value);
return resolved === null ? acc : acc | resolved;
}, 0n);
}
if (intents.bitfield !== undefined && intents.bitfield !== null) {
return resolveIntentBitfield(intents.bitfield);
}
return null;
}
function resolveGuildMembersFlag() {
const value =
discord.GatewayIntentBits?.GuildMembers ||
discord.IntentsBitField?.Flags?.GuildMembers ||
discord.Intents?.FLAGS?.GUILD_MEMBERS;
return value === undefined || value === null ? null : BigInt(value);
}
function normalizePronounSet(input) {
const raw = (input || "").toString().trim().toLowerCase();
const normalized = raw
.replace(/\s+/g, " ")
.replace(/\s*\/\s*/g, "/");
return PRONOUN_SETS.find((item) => item.value === normalized) || PRONOUN_SETS[0];
}
function savePronouns(db, userId, pronouns) {
const now = Date.now();
db.prepare(
"INSERT INTO welcome_message_pronouns (user_id, pronoun_set, subject_pronoun, created_at, updated_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(user_id) DO UPDATE SET pronoun_set = excluded.pronoun_set, subject_pronoun = excluded.subject_pronoun, updated_at = excluded.updated_at"
).run(userId, pronouns.value, pronouns.subject, now, now);
}
function getStoredPronouns(db, userId) {
const row = userId
? db.prepare("SELECT pronoun_set, subject_pronoun FROM welcome_message_pronouns WHERE user_id = ?").get(userId)
: null;
if (!row) {
return PRONOUN_SETS[0];
}
return normalizePronounSet(row.pronoun_set);
}
function getSubjectPronoun(db, userId) {
return getStoredPronouns(db, userId).subject || "they";
}