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 @@
The bot isn’t in this server yet. Invite it to configure settings.
+The bot is not in this server yet. Invite it to configure settings.
You can review saved settings, but changes will stay inactive until the bot joins.
Restrict commands to roles or specific users.
+Review and edit restrictions faster with search, filters, and live summaries.
Search members or paste an ID.
+Search members or paste an ID, then manage all warnings from one target card.
+No user selected.
See warning history and add new entries for the selected target.
+The warning will be added to the currently selected target.