Add welcome messages plugin and update controls

This commit is contained in:
Franz Rolfsvaag 2026-05-30 22:06:58 +02:00
parent f877e4f084
commit 6af21664e8
10 changed files with 984 additions and 0 deletions

2
.gitignore vendored
View File

@ -10,3 +10,5 @@ updates/
*.sqlite *.sqlite
*.sqlite-* *.sqlite-*
npm-debug.log npm-debug.log
security-audit-*.json
security-audit-*.md

View File

@ -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.

View File

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

View File

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

View File

@ -0,0 +1,73 @@
<section class="card">
<h2><%= title %></h2>
<form method="post" action="/plugins/welcome_messages/messages/create" class="form-grid">
<input type="hidden" name="pool" value="<%= pool %>" />
<div class="field full">
<label>New message</label>
<textarea name="text" rows="3" placeholder="Welcome {displayname}!"></textarea>
<p class="hint">Preview uses sample values after saving.</p>
</div>
<div class="field">
<label>Enabled</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" checked />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">On</span>
</label>
</div>
<div class="field full">
<button type="submit" class="button">Add message</button>
</div>
</form>
<% if (!messages.length) { %>
<p>No messages configured.</p>
<% } else { %>
<div class="form-grid">
<% messages.forEach((message) => { %>
<div class="card">
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/update" class="form-grid">
<input type="hidden" name="pool" value="<%= pool %>" />
<div class="field full">
<label>Message</label>
<textarea name="text" rows="3"><%= message.text %></textarea>
</div>
<div class="field">
<label>Status</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= message.enabled && !message.archived ? "checked" : "" %> <%= message.archived ? "disabled" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= message.archived ? "Archived" : message.enabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field full">
<p class="hint">Preview: <%= previewMessage(message.text) %></p>
</div>
<div class="field full">
<% if (!message.archived) { %>
<button type="submit" class="button">Save</button>
<% } %>
</div>
</form>
<div class="inline-actions">
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/duplicate" class="inline-form">
<input type="hidden" name="pool" value="<%= pool %>" />
<button type="submit" class="button subtle">Duplicate</button>
</form>
<% if (message.archived) { %>
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/restore" class="inline-form">
<input type="hidden" name="pool" value="<%= pool %>" />
<button type="submit" class="button">Restore</button>
</form>
<% } else { %>
<form method="post" action="/plugins/welcome_messages/messages/<%= message.id %>/archive" class="inline-form">
<input type="hidden" name="pool" value="<%= pool %>" />
<button type="submit" class="button danger">Archive</button>
</form>
<% } %>
</div>
</div>
<% }) %>
</div>
<% } %>
</section>

View File

@ -0,0 +1,15 @@
<% const currentPronouns = getPronouns(user.id); %>
<form method="post" action="/plugins/welcome_messages/profile/pronouns" class="form-grid">
<div class="field">
<label>Pronouns for welcome messages</label>
<select name="pronoun_set">
<% pronounSets.forEach((item) => { %>
<option value="<%= item.value %>" <%= item.value === currentPronouns.value ? "selected" : "" %>><%= item.label %></option>
<% }) %>
</select>
<p class="hint">Unsafe or unknown values are normalized to they/them.</p>
</div>
<div class="field full">
<button type="submit" class="button subtle">Save pronouns</button>
</div>
</form>

View File

@ -0,0 +1,101 @@
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<section class="card">
<div class="section-header">
<div>
<h1>Welcome Messages</h1>
<p class="command-subtitle">Send randomized Discord welcome messages when members join.</p>
</div>
</div>
<div class="callout">
<strong>Allowed placeholders</strong>
<p>
<% allowedPlaceholders.forEach((name) => { %>
<code>{<%= name %>}</code>
<% }) %>
</p>
</div>
</section>
<section class="card">
<h2>Diagnostics</h2>
<div class="table-wrap">
<table class="table">
<tbody>
<tr>
<th>Discord client</th>
<td><%= diagnostics.clientAvailable ? (diagnostics.ready ? "Ready" : "Available, not ready") : "Unavailable" %></td>
</tr>
<tr>
<th>Guild members intent</th>
<td><%= diagnostics.memberIntent %><% if (diagnostics.memberIntent !== "configured") { %> - join events may not fire<% } %></td>
</tr>
<tr>
<th>Welcome channel</th>
<td><%= diagnostics.channel.message %></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<h2>Delivery</h2>
<% if (isAdmin) { %>
<form method="post" action="/plugins/welcome_messages/settings" class="form-grid">
<div class="field">
<label>Posting enabled</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="enabled" <%= config.enabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= config.enabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field">
<label>Welcome-back messages</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="welcome_back_enabled" <%= config.welcomeBackEnabled ? "checked" : "" %> />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text"><%= config.welcomeBackEnabled ? "On" : "Off" %></span>
</label>
</div>
<div class="field full">
<label>Welcome channel</label>
<% if (textChannels.length) { %>
<select name="channel_id">
<option value="">Select a Discord text channel</option>
<% textChannels.forEach((channel) => { %>
<option value="<%= channel.id %>" <%= channel.id === config.channelId ? "selected" : "" %>><%= channel.label %></option>
<% }) %>
</select>
<% } else { %>
<input name="channel_id" value="<%= config.channelId %>" placeholder="Discord text channel ID" />
<% } %>
<p class="hint">Only regular text channels are accepted.</p>
</div>
<div class="field full">
<button type="submit" class="button">Save delivery settings</button>
</div>
</form>
<% } else { %>
<p>Posting is <strong><%= config.enabled ? "enabled" : "disabled" %></strong>.</p>
<p>Welcome-back messages are <strong><%= config.welcomeBackEnabled ? "enabled" : "disabled" %></strong>.</p>
<p class="hint">Only admins can change delivery settings or the welcome channel.</p>
<% } %>
</section>
<%- 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") %>

View File

@ -19,6 +19,7 @@ async function startBot({ commandRouter } = {}) {
const intents = [ const intents = [
resolveIntent("Guilds", "GUILDS"), resolveIntent("Guilds", "GUILDS"),
resolveIntent("GuildMessages", "GUILD_MESSAGES"), resolveIntent("GuildMessages", "GUILD_MESSAGES"),
resolveIntent("GuildMembers", "GUILD_MEMBERS"),
resolveIntent("MessageContent", "MESSAGE_CONTENT"), resolveIntent("MessageContent", "MESSAGE_CONTENT"),
resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"), resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"),
resolveIntent("GuildPresences", "GUILD_PRESENCES") resolveIntent("GuildPresences", "GUILD_PRESENCES")
@ -28,6 +29,10 @@ async function startBot({ commandRouter } = {}) {
if (intents.length) { if (intents.length) {
options.intents = intents; options.intents = intents;
} }
console.log("Discord bot starting with intents", {
intents,
guildMembers: Boolean(resolveIntent("GuildMembers", "GUILD_MEMBERS"))
});
if (Partials?.Channel) { if (Partials?.Channel) {
options.partials = [Partials.Channel]; options.partials = [Partials.Channel];
} }

View File

@ -35,6 +35,28 @@
<label>Git branch</label> <label>Git branch</label>
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" /> <input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
</div> </div>
<div class="field full">
<div class="inline-actions">
<button type="submit" class="button">Save settings</button>
<button
type="submit"
class="button subtle"
formaction="/admin/check-update"
formmethod="post"
>
Check for updates
</button>
<button
type="submit"
class="button subtle"
formaction="/admin/update"
formmethod="post"
>
Update from git
</button>
</div>
<p class="hint">Git update checks use the configured remote and branch.</p>
</div>
<div class="field full"> <div class="field full">
<h2>Platform Integration</h2> <h2>Platform Integration</h2>

View File

@ -5,6 +5,19 @@
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p> <p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
</section> </section>
<section class="card">
<h2>Git updates</h2>
<p>Check or pull updates from the remote and branch configured in Settings.</p>
<div class="inline-actions">
<form method="post" action="/admin/check-update" class="inline-form">
<button type="submit" class="button subtle">Check for updates</button>
</form>
<form method="post" action="/admin/update" class="inline-form">
<button type="submit" class="button">Update from git</button>
</form>
</div>
</section>
<section class="card"> <section class="card">
<h2>Upload bot update</h2> <h2>Upload bot update</h2>
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid"> <form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">