From 6af21664e8c80e08c488123fde72853c29a43659 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 30 May 2026 22:06:58 +0200 Subject: [PATCH] Add welcome messages plugin and update controls --- .gitignore | 2 + plugins/welcome_messages/README.md | 22 + plugins/welcome_messages/index.js | 724 ++++++++++++++++++ plugins/welcome_messages/plugin.json | 7 + .../views/message-section.ejs | 73 ++ .../views/profile-pronouns.ejs | 15 + plugins/welcome_messages/views/settings.ejs | 101 +++ src/services/discord.js | 5 + src/web/views/admin-settings.ejs | 22 + src/web/views/admin-updates.ejs | 13 + 10 files changed, 984 insertions(+) create mode 100644 plugins/welcome_messages/README.md create mode 100644 plugins/welcome_messages/index.js create mode 100644 plugins/welcome_messages/plugin.json create mode 100644 plugins/welcome_messages/views/message-section.ejs create mode 100644 plugins/welcome_messages/views/profile-pronouns.ejs create mode 100644 plugins/welcome_messages/views/settings.ejs 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 @@ +
+

<%= title %>

+
+ +
+ + +

Preview uses sample values after saving.

+
+
+ + +
+
+ +
+
+ + <% if (!messages.length) { %> +

No messages configured.

+ <% } else { %> +
+ <% messages.forEach((message) => { %> +
+
+ +
+ + +
+
+ + +
+
+

Preview: <%= previewMessage(message.text) %>

+
+
+ <% if (!message.archived) { %> + + <% } %> +
+
+
+
+ + +
+ <% if (message.archived) { %> +
+ + +
+ <% } else { %> +
+ + +
+ <% } %> +
+
+ <% }) %> +
+ <% } %> +
diff --git a/plugins/welcome_messages/views/profile-pronouns.ejs b/plugins/welcome_messages/views/profile-pronouns.ejs new file mode 100644 index 0000000..873dd06 --- /dev/null +++ b/plugins/welcome_messages/views/profile-pronouns.ejs @@ -0,0 +1,15 @@ +<% const currentPronouns = getPronouns(user.id); %> +
+
+ + +

Unsafe or unknown values are normalized to they/them.

+
+
+ +
+
diff --git a/plugins/welcome_messages/views/settings.ejs b/plugins/welcome_messages/views/settings.ejs new file mode 100644 index 0000000..eb52cad --- /dev/null +++ b/plugins/welcome_messages/views/settings.ejs @@ -0,0 +1,101 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + +
+
+
+

Welcome Messages

+

Send randomized Discord welcome messages when members join.

+
+
+
+ Allowed placeholders +

+ <% allowedPlaceholders.forEach((name) => { %> + {<%= name %>} + <% }) %> +

+
+
+ +
+

Diagnostics

+
+ + + + + + + + + + + + + + + +
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 %>
+
+
+ +
+

Delivery

+ <% if (isAdmin) { %> +
+
+ + +
+
+ + +
+
+ + <% if (textChannels.length) { %> + + <% } else { %> + + <% } %> +

Only regular text channels are accepted.

+
+
+ +
+
+ <% } else { %> +

Posting is <%= config.enabled ? "enabled" : "disabled" %>.

+

Welcome-back messages are <%= config.welcomeBackEnabled ? "enabled" : "disabled" %>.

+

Only admins can change delivery settings or the welcome channel.

+ <% } %> +
+ +<%- include("./message-section", { + title: "Welcome messages", + pool: "welcomeMessages", + messages: config.welcomeMessages, + previewMessage +}) %> + +<%- include("./message-section", { + title: "Welcome-back messages", + pool: "welcomeBackMessages", + messages: config.welcomeBackMessages, + previewMessage +}) %> + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/src/services/discord.js b/src/services/discord.js index 0090143..57e9dd6 100644 --- a/src/services/discord.js +++ b/src/services/discord.js @@ -19,6 +19,7 @@ async function startBot({ commandRouter } = {}) { const intents = [ resolveIntent("Guilds", "GUILDS"), resolveIntent("GuildMessages", "GUILD_MESSAGES"), + resolveIntent("GuildMembers", "GUILD_MEMBERS"), resolveIntent("MessageContent", "MESSAGE_CONTENT"), resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"), resolveIntent("GuildPresences", "GUILD_PRESENCES") @@ -28,6 +29,10 @@ async function startBot({ commandRouter } = {}) { if (intents.length) { options.intents = intents; } + console.log("Discord bot starting with intents", { + intents, + guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS")) + }); if (Partials?.Channel) { options.partials = [Partials.Channel]; } diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index fb9f613..b6535c3 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -35,6 +35,28 @@ +
+
+ + + +
+

Git update checks use the configured remote and branch.

+

Platform Integration

diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index f053fe1..f1dc436 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -5,6 +5,19 @@

Rollback is handled from Safe Mode if something breaks.

+
+

Git updates

+

Check or pull updates from the remote and branch configured in Settings.

+
+
+ +
+
+ +
+
+
+

Upload bot update