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