diff --git a/.gitignore b/.gitignore
index 2b6c109..87bed15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ updates/
*.sqlite
*.sqlite-*
npm-debug.log
+security-audit-*.json
+security-audit-*.md
diff --git a/plugins/welcome_messages/README.md b/plugins/welcome_messages/README.md
new file mode 100644
index 0000000..4455e70
--- /dev/null
+++ b/plugins/welcome_messages/README.md
@@ -0,0 +1,22 @@
+# Welcome Messages
+
+Standalone Lumi plugin for randomized Discord welcome and welcome-back messages.
+
+## Install
+
+Upload `lumi-plugin-welcome_messages-v0.1.0.zip` through **Admin -> Plugins** or
+**Admin -> Updates**. The zip root must contain `plugin.json` and `index.js`.
+
+## Discord intent requirement
+
+This plugin listens for `guildMemberAdd`. Lumi's Discord client must start with
+the Discord Server Members intent (`GuildMembers` / `GUILD_MEMBERS`), and the
+Server Members Intent must be enabled in the Discord Developer Portal. If that
+intent is missing, the plugin loads and the WebUI shows diagnostics, but join
+events will not fire.
+
+## Placeholders
+
+Templates may use only `{username}`, `{displayname}`, `{pronoun}`, and
+`{guildmembers}`. User-controlled names are sanitized and messages are sent with
+mentions disabled.
diff --git a/plugins/welcome_messages/index.js b/plugins/welcome_messages/index.js
new file mode 100644
index 0000000..f461070
--- /dev/null
+++ b/plugins/welcome_messages/index.js
@@ -0,0 +1,724 @@
+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";
+}
diff --git a/plugins/welcome_messages/plugin.json b/plugins/welcome_messages/plugin.json
new file mode 100644
index 0000000..a16c8e9
--- /dev/null
+++ b/plugins/welcome_messages/plugin.json
@@ -0,0 +1,7 @@
+{
+ "id": "welcome_messages",
+ "name": "Welcome Messages",
+ "version": "0.1.0",
+ "description": "Randomized Discord welcome and welcome-back messages with safe pronoun preferences.",
+ "main": "index.js"
+}
diff --git a/plugins/welcome_messages/views/message-section.ejs b/plugins/welcome_messages/views/message-section.ejs
new file mode 100644
index 0000000..101a301
--- /dev/null
+++ b/plugins/welcome_messages/views/message-section.ejs
@@ -0,0 +1,73 @@
+ No messages configured.<%= title %>
+
+
+ <% if (!messages.length) { %>
+
Send randomized Discord welcome messages when members join.
+
+ <% allowedPlaceholders.forEach((name) => { %>
+ {<%= name %>}
+ <% }) %>
+
| Discord client | +<%= diagnostics.clientAvailable ? (diagnostics.ready ? "Ready" : "Available, not ready") : "Unavailable" %> | +
|---|---|
| Guild members intent | +<%= diagnostics.memberIntent %><% if (diagnostics.memberIntent !== "configured") { %> - join events may not fire<% } %> | +
| Welcome channel | +<%= diagnostics.channel.message %> | +
Posting is <%= config.enabled ? "enabled" : "disabled" %>.
+Welcome-back messages are <%= config.welcomeBackEnabled ? "enabled" : "disabled" %>.
+Only admins can change delivery settings or the welcome channel.
+ <% } %> +Git update checks use the configured remote and branch.
+Rollback is handled from Safe Mode if something breaks.
+Check or pull updates from the remote and branch configured in Settings.
+