From a7f82639317eba0d696c9e0e15f1b754fb581f0a Mon Sep 17 00:00:00 2001 From: fretuks Date: Sun, 27 Sep 2026 01:04:44 +0200 Subject: [PATCH] Add `/ban-role` moderation command and improve module loading --- README.md | 22 +++ commands/moderation/ban_role.js | 225 +++++++++++++++++++++++++++ commands/utility/help.js | 4 +- dashboardServer.js | 36 +++-- deploy-commands.js | 32 +++- index.js | 36 ++++- loaders/commands.js | 72 ++++++--- loaders/events.js | 33 +++- package.json | 3 +- public/dashboard.html | 104 +++++++++++-- public/styles.css | 129 ++++++++++++++++ scripts/ban-role.test.js | 264 ++++++++++++++++++++++++++++++++ scripts/verify.js | 55 +++++++ 13 files changed, 959 insertions(+), 56 deletions(-) create mode 100644 commands/moderation/ban_role.js create mode 100644 scripts/ban-role.test.js create mode 100644 scripts/verify.js diff --git a/README.md b/README.md index b556b8a..40e9a4e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Im going to make a To-do List for the features i want to implement for my Bot. 3. **Moderation Tools** - [x] Add commands to mute, kick, and temp_ban users. + - [x] Add `/ban-role` to ban members with a selected role after confirmation. - [ ] Create an auto-moderation feature to delete spam or inappropriate content. - [x] Implement a warning system with a strike counter. @@ -102,3 +103,24 @@ Recommended env vars: - `DISCORD_CLIENT_SECRET` - `DISCORD_REDIRECT_URI` - `MONGODB_URI` + +### **Banning members by role** + +Use `/ban-role role:@Role reason:Reason for the bans`. The bot privately previews the +number of eligible members and asks you to confirm within 60 seconds. Bans are permanent +and existing messages are kept. Both you and the bot need **Ban Members** permission. +The command skips you, the server owner, the bot itself, and members whose highest role +is equal to or above yours or the bot's (the server owner bypasses their own hierarchy check). +`@everyone` cannot be selected. Only members included in the preview can be banned; +their role membership and hierarchy are checked again before each ban. + +Enable **Server Members Intent** under **Bot > Privileged Gateway Intents** in the +[Discord Developer Portal](https://discord.com/developers/applications) so the bot can +[list all guild members](https://docs.discord.com/developers/resources/guild#list-guild-members). +Member discovery uses paginated REST requests, so no Gateway intent change is needed in `index.js`. +Run `npm run deploy:commands` and restart the bot after installing the command. + +The result reports successful bans, skipped members, failed requests, and any moderation +record failures separately. Long batches stop after about 14 minutes and report how many +members remain; run the command again to preview and continue. `/ban-role` also appears +automatically in `/help` and the dashboard's moderation permission settings. diff --git a/commands/moderation/ban_role.js b/commands/moderation/ban_role.js new file mode 100644 index 0000000..be9ea15 --- /dev/null +++ b/commands/moderation/ban_role.js @@ -0,0 +1,225 @@ +const { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + MessageFlags, + PermissionFlagsBits, + SlashCommandBuilder, +} = require('discord.js'); +const { recordModerationAction } = require('../../services/moderationRecords'); +const { isCommandAllowed } = require('../../services/permissions'); + +const activeGuilds = new Set(); + +const fetchModerator = async (interaction) => { + const [moderator, bot] = await Promise.all([ + interaction.guild.members.fetch({ user: interaction.user.id, force: true }), + interaction.guild.members.fetchMe({ force: true }), + ]); + if (!moderator.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('You need the Ban Members permission to use this command.'); + } + if (!bot.permissions.has(PermissionFlagsBits.BanMembers)) { + throw new Error('I need the Ban Members permission to use this command.'); + } + return moderator; +}; + +const canBan = (member, moderator, interaction) => + member.id !== interaction.user.id && + member.id !== interaction.client.user.id && + member.id !== interaction.guild.ownerId && + member.bannable && + (moderator.id === interaction.guild.ownerId || + moderator.roles.highest.comparePositionTo(member.roles.highest) > 0); + +module.exports = { + mod: true, + permissionGroup: 'moderation', + category: 'Moderation', + data: new SlashCommandBuilder() + .setName('ban-role') + .setDescription('Permanently ban members with a role after confirming a preview.') + .setDMPermission(false) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addRoleOption(option => option + .setName('role') + .setDescription('The role whose members should be banned') + .setRequired(true)) + .addStringOption(option => option + .setName('reason') + .setDescription('The reason for banning these members') + .setMinLength(1) + .setMaxLength(400) + .setRequired(true)), + async execute(interaction) { + const guild = interaction.guild; + const reject = (content) => interaction.reply({ content, flags: MessageFlags.Ephemeral }); + if (!guild) { + return reject('This command can only be used inside a server.'); + } + const role = interaction.options.getRole('role'); + const reason = interaction.options.getString('reason').trim(); + if (role.id === guild.id) { + return reject('You cannot use this command with @everyone.'); + } + if (!reason) { + return reject('Please provide a reason for the bans.'); + } + if (activeGuilds.has(guild.id)) { + return reject('A role ban is already being previewed or processed in this server.'); + } + + activeGuilds.add(guild.id); + const counts = { banned: 0, skipped: 0, failed: 0, logFailed: 0 }; + let started = false; + let editReply = (payload) => interaction.editReply(payload); + const summary = () => `Banned: ${counts.banned}. Skipped: ${counts.skipped}. Failed: ${counts.failed}.` + + (counts.logFailed ? ` Moderation records could not be saved for ${counts.logFailed} successful bans.` : ''); + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await guild.roles.fetch(); + const moderator = await fetchModerator(interaction); + const candidateIds = []; + let after; + // REST pagination includes uncached members without requesting a full Gateway member cache. + do { + let page; + try { + page = await guild.members.list({ limit: 1000, after, cache: false }); + } catch (error) { + console.error('Failed to list members for role ban:', error); + return await editReply({ + content: 'Could not load all server members. Check that Server Members Intent is enabled ' + + 'in the Discord Developer Portal, then try again. No members were banned.', + }); + } + for (const member of page.values()) { + if (!member.roles.cache.has(role.id)) continue; + if (canBan(member, moderator, interaction)) candidateIds.push(member.id); + else counts.skipped++; + } + if (page.size < 1000) break; + after = page.lastKey(); + } while (true); + + if (!candidateIds.length) { + return await editReply({ + content: `No eligible members have <@&${role.id}>. Skipped: ${counts.skipped}.`, + allowedMentions: { parse: [] }, + }); + } + + const confirmId = `ban-role:confirm:${interaction.id}`; + const cancelId = `ban-role:cancel:${interaction.id}`; + const reply = await editReply({ + content: `Permanently ban **${candidateIds.length}** members with <@&${role.id}>?\n` + + `Skipped: ${counts.skipped} (yourself, the server owner, this bot, or members outside the role hierarchy).\n` + + `Reason: ${reason}\nExisting messages will be kept. Confirm within 60 seconds.`, + allowedMentions: { parse: [] }, + components: [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(confirmId).setLabel('Ban members').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId(cancelId).setLabel('Cancel').setStyle(ButtonStyle.Secondary), + )], + }); + + let confirmation; + try { + confirmation = await reply.awaitMessageComponent({ + componentType: ComponentType.Button, + time: 60_000, + filter: component => component.user.id === interaction.user.id && + [confirmId, cancelId].includes(component.customId), + }); + } catch (error) { + return await editReply({ content: 'Confirmation expired. No members were banned.', components: [] }); + } + await confirmation.update({ + content: confirmation.customId === cancelId ? 'Cancelled. No members were banned.' : 'Checking permissions and banning members...', + components: [], + }); + // Use the fresh component token for the final result of a potentially long batch. + editReply = (payload) => confirmation.editReply(payload); + if (confirmation.customId === cancelId) return; + + await guild.roles.fetch(); + const currentModerator = await fetchModerator(interaction); + const allowed = await isCommandAllowed({ + guildId: guild.id, + userId: interaction.user.id, + member: currentModerator, + commandName: 'ban-role', + permissionGroup: 'moderation', + }); + if (!allowed) { + return await editReply({ content: 'You no longer have permission to use this command. No members were banned.' }); + } + + started = true; + let processed = 0; + const deadline = Date.now() + 14 * 60_000; + for (const memberId of candidateIds) { + if (Date.now() >= deadline) break; + // Permissions and role membership may have changed since the preview. + const freshModerator = await fetchModerator(interaction); + let member; + try { + member = await guild.members.fetch({ user: memberId, force: true, cache: false }); + } catch (error) { + if (error.code === 10007) counts.skipped++; // Member left the server. + else { + counts.failed++; + console.error(`Failed to fetch role-ban member ${memberId}:`, error); + } + processed++; + continue; + } + processed++; + if (!member.roles.cache.has(role.id) || !canBan(member, freshModerator, interaction)) { + counts.skipped++; + continue; + } + try { + await guild.bans.create(memberId, { + reason: `Role ban (${role.id}) by ${interaction.user.id}: ${reason}`, + deleteMessageSeconds: 0, + }); + counts.banned++; + } catch (error) { + counts.failed++; + console.error(`Failed to ban member ${memberId} with role ${role.id}:`, error); + continue; + } + try { + await recordModerationAction({ + guildId: guild.id, + userId: memberId, + action: 'ban', + moderatorId: interaction.user.id, + reason, + metadata: { command: 'ban-role', roleId: role.id }, + stateUpdates: { isBanned: true, bannedUntil: null }, + }); + } catch (error) { + counts.logFailed++; + console.error(`Failed to record role ban for ${memberId}:`, error); + } + } + const remaining = candidateIds.length - processed; + return await editReply({ + content: `${summary()}` + (remaining ? ` Time limit reached; ${remaining} members were not processed. Run the command again to continue.` : ''), + components: [], + }); + } catch (error) { + console.error('Role ban stopped:', error); + return await editReply({ + content: `Role ban stopped: ${error.message}\n${started ? summary() + ' Remaining members were not processed.' : 'No members were banned.'}`, + components: [], + allowedMentions: { parse: [] }, + }); + } finally { + activeGuilds.delete(guild.id); + } + }, +}; diff --git a/commands/utility/help.js b/commands/utility/help.js index e1b63f1..c4503ea 100644 --- a/commands/utility/help.js +++ b/commands/utility/help.js @@ -7,7 +7,7 @@ module.exports = { category: 'Utility', async execute(interaction) { const commandDetails = interaction.client.commands - .map(cmd => ({ + .map((cmd) => ({ name: cmd.data.name, description: cmd.data.description, category: cmd.category ?? 'Other', @@ -26,7 +26,7 @@ module.exports = { .map((category) => ({ name: category, value: grouped[category] - .map(command => `• **/${command.name}** — ${command.description}`) + .map((command) => `- **/${command.name}**: ${command.description}`) .join('\n'), })); diff --git a/dashboardServer.js b/dashboardServer.js index 710bed4..7021a1e 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -6,6 +6,7 @@ const express = require('express'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const { CONFIG_PATH, getConfig, getConfigValue } = require('./config'); +const { loadCommandModules } = require('./loaders/commands'); const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_CLEANUP_MS = 60 * 60 * 1000; const MAX_REASON_LENGTH = 500; @@ -66,7 +67,6 @@ console.log('[oauth config]', { const DISCORD_API_BASE = 'https://discord.com/api'; const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2'; -const COMMANDS_PATH = path.join(__dirname, 'commands.json'); const GUILD_CACHE_TTL_MS = 5 * 60 * 1000; const BOT_GUILD_CACHE_TTL_MS = 60 * 1000; const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.'; @@ -407,18 +407,34 @@ const isHttpsRequest = (req) => { }; const loadCommands = () => { - if (!fs.existsSync(COMMANDS_PATH)) { - return []; + const commandsPath = path.join(__dirname, 'commands'); + const { commands, duplicateCommands, invalidCommands, loadErrors } = loadCommandModules(commandsPath); + + if (invalidCommands.length > 0) { + console.warn('Skipping invalid commands while building dashboard command list.', { + invalidCommands, + }); } - try { - const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : []; - } catch (error) { - console.error('Failed to load commands.json', {message: error.message}); - return []; + if (duplicateCommands.length > 0) { + console.error('Duplicate command definitions detected while building dashboard command list.', { + duplicateCommands, + }); } + + if (loadErrors.length > 0) { + console.error('Failed to load one or more commands for the dashboard.', { + loadErrors: loadErrors.map(({ filePath, error }) => ({ + filePath, + message: error.message, + })), + }); + } + + return commands + .filter((command) => command.permissionGroup === 'moderation') + .map((command) => command.data.name) + .sort((left, right) => left.localeCompare(right)); }; const canManageGuild = (guild) => { diff --git a/deploy-commands.js b/deploy-commands.js index d7edebb..0a8bb8e 100644 --- a/deploy-commands.js +++ b/deploy-commands.js @@ -1,15 +1,43 @@ const path = require('node:path'); const { REST, Routes } = require('discord.js'); -const { clientId, token } = require('./config.json'); +const { getConfigValue } = require('./config'); const { loadCommandModules } = require('./loaders/commands'); + const commandsPath = path.join(__dirname, 'commands'); -const { commands, invalidCommands } = loadCommandModules(commandsPath); +const { + commands, + duplicateCommands, + invalidCommands, + loadErrors, +} = loadCommandModules(commandsPath); const commandsData = commands.map((command) => command.data.toJSON()); +const clientId = getConfigValue('DISCORD_CLIENT_ID', 'discordClientId', 'clientId', 'appId'); +const token = getConfigValue('DISCORD_BOT_TOKEN', 'discordBotToken', 'token'); for (const filePath of invalidCommands) { console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); } +for (const { commandName, filePath, existingFilePath } of duplicateCommands) { + console.error( + `[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`, + ); +} + +for (const { filePath, error } of loadErrors) { + console.error(`[ERROR] Failed to load command at ${filePath}:`, error); +} + +if (!clientId || !token) { + console.error('Missing Discord client ID or bot token. Set env vars or config.json values.'); + process.exit(1); +} + +if (duplicateCommands.length > 0 || loadErrors.length > 0) { + console.error('Command deployment aborted because one or more command modules failed validation.'); + process.exit(1); +} + const rest = new REST().setToken(token); (async () => { diff --git a/index.js b/index.js index f818d43..1c83189 100644 --- a/index.js +++ b/index.js @@ -14,9 +14,18 @@ process.on('uncaughtException', (error) => { const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.commands = new Collection(); const commandsPath = path.join(__dirname, 'commands'); -const { commands, invalidCommands } = loadCommandModules(commandsPath); +const { + commands, + duplicateCommands, + invalidCommands, + loadErrors: commandLoadErrors, +} = loadCommandModules(commandsPath); const eventsPath = path.join(__dirname, 'events'); -const eventModules = loadEventModules(eventsPath); +const { + events: eventModules, + invalidEvents, + loadErrors: eventLoadErrors, +} = loadEventModules(eventsPath); for (const command of commands) { client.commands.set(command.data.name, command); @@ -26,6 +35,24 @@ for (const filePath of invalidCommands) { console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); } +for (const { commandName, filePath, existingFilePath } of duplicateCommands) { + console.error( + `[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`, + ); +} + +for (const { filePath, error } of commandLoadErrors) { + console.error(`[ERROR] Failed to load command at ${filePath}:`, error); +} + +for (const filePath of invalidEvents) { + console.log(`[WARNING] The event at ${filePath} is missing a required "name" or "execute" property.`); +} + +for (const { filePath, error } of eventLoadErrors) { + console.error(`[ERROR] Failed to load event at ${filePath}:`, error); +} + for (const { event } of eventModules) { if (event.once) { client.once(event.name, (...args) => event.execute(...args)); @@ -34,6 +61,11 @@ for (const { event } of eventModules) { } } +if (duplicateCommands.length > 0 || commandLoadErrors.length > 0 || eventLoadErrors.length > 0) { + console.error('Startup aborted because one or more modules failed validation.'); + process.exit(1); +} + const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken'); if (!token) { console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.'); diff --git a/loaders/commands.js b/loaders/commands.js index 94f0d35..2b51bbb 100644 --- a/loaders/commands.js +++ b/loaders/commands.js @@ -4,38 +4,74 @@ const path = require('node:path'); const isJavaScriptFile = (file) => file.endsWith('.js'); const getCommandFilePaths = (commandsPath) => { - const commandFolders = fs - .readdirSync(commandsPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); + const commandFilePaths = []; + const entries = fs.readdirSync(commandsPath, { withFileTypes: true }); - return commandFolders.flatMap((folder) => { - const folderPath = path.join(commandsPath, folder); - return fs - .readdirSync(folderPath) - .filter(isJavaScriptFile) - .map((file) => path.join(folderPath, file)); - }); + for (const entry of entries) { + const entryPath = path.join(commandsPath, entry.name); + if (entry.isDirectory()) { + commandFilePaths.push(...getCommandFilePaths(entryPath)); + continue; + } + if (entry.isFile() && isJavaScriptFile(entry.name)) { + commandFilePaths.push(entryPath); + } + } + + return commandFilePaths.sort((left, right) => left.localeCompare(right)); }; const loadCommandModules = (commandsPath) => { const commandFiles = getCommandFilePaths(commandsPath); const commands = []; const invalidCommands = []; + const duplicateCommands = []; + const loadErrors = []; + const commandNames = new Map(); for (const filePath of commandFiles) { - const command = require(filePath); - - if ('data' in command && 'execute' in command) { - commands.push(command); - } else { - invalidCommands.push(filePath); + let command; + try { + delete require.cache[require.resolve(filePath)]; + command = require(filePath); + } catch (error) { + loadErrors.push({ filePath, error }); + continue; } + + const commandName = command?.data?.name; + const isValidCommand = + typeof commandName === 'string' && + commandName.length > 0 && + typeof command.execute === 'function'; + + if (!isValidCommand) { + invalidCommands.push(filePath); + continue; + } + + if (commandNames.has(commandName)) { + duplicateCommands.push({ + commandName, + filePath, + existingFilePath: commandNames.get(commandName), + }); + continue; + } + + commandNames.set(commandName, filePath); + commands.push(command); } - return { commands, invalidCommands }; + return { + commands, + duplicateCommands, + invalidCommands, + loadErrors, + }; }; module.exports = { + getCommandFilePaths, loadCommandModules, }; diff --git a/loaders/events.js b/loaders/events.js index c91abd9..a55cb85 100644 --- a/loaders/events.js +++ b/loaders/events.js @@ -7,16 +7,41 @@ const getEventFilePaths = (eventsPath) => fs .readdirSync(eventsPath) .filter(isJavaScriptFile) + .sort((left, right) => left.localeCompare(right)) .map((file) => path.join(eventsPath, file)); const loadEventModules = (eventsPath) => { const eventFiles = getEventFilePaths(eventsPath); - return eventFiles.map((filePath) => ({ - filePath, - event: require(filePath), - })); + const events = []; + const invalidEvents = []; + const loadErrors = []; + + for (const filePath of eventFiles) { + let event; + try { + delete require.cache[require.resolve(filePath)]; + event = require(filePath); + } catch (error) { + loadErrors.push({ filePath, error }); + continue; + } + + if (typeof event?.name !== 'string' || typeof event?.execute !== 'function') { + invalidEvents.push(filePath); + continue; + } + + events.push({ filePath, event }); + } + + return { + events, + invalidEvents, + loadErrors, + }; }; module.exports = { + getEventFilePaths, loadEventModules, }; diff --git a/package.json b/package.json index afd5dba..a238e28 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "deploy:commands": "node deploy-commands.js", + "test": "node scripts/verify.js && node --test scripts/ban-role.test.js" }, "keywords": [], "author": "", diff --git a/public/dashboard.html b/public/dashboard.html index c6257ea..bbd46c4 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -88,7 +88,7 @@
@@ -115,13 +115,53 @@

Command permissions

-

Restrict commands to roles or specific users.

+

Review and edit restrictions faster with search, filters, and live summaries.

+ +
+
+ Commands + 0 + Available moderation commands +
+
+ Restricted + 0 + Commands limited to specific roles or users +
+
+ Open + 0 + Commands currently available to everyone +
+
+ Unsaved edits + 0 + Cards changed since the last save +
+
+ +
+
+ + +
+
+ + +
+
+
Add command @@ -132,48 +172,78 @@
+