Add /ban-role moderation command and improve module loading

This commit is contained in:
2026-09-27 01:04:44 +02:00
parent 65a281dbcb
commit a7f8263931
13 changed files with 959 additions and 56 deletions
+22
View File
@@ -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** 3. **Moderation Tools**
- [x] Add commands to mute, kick, and temp_ban users. - [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. - [ ] Create an auto-moderation feature to delete spam or inappropriate content.
- [x] Implement a warning system with a strike counter. - [x] Implement a warning system with a strike counter.
@@ -102,3 +103,24 @@ Recommended env vars:
- `DISCORD_CLIENT_SECRET` - `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URI` - `DISCORD_REDIRECT_URI`
- `MONGODB_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.
+225
View File
@@ -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);
}
},
};
+2 -2
View File
@@ -7,7 +7,7 @@ module.exports = {
category: 'Utility', category: 'Utility',
async execute(interaction) { async execute(interaction) {
const commandDetails = interaction.client.commands const commandDetails = interaction.client.commands
.map(cmd => ({ .map((cmd) => ({
name: cmd.data.name, name: cmd.data.name,
description: cmd.data.description, description: cmd.data.description,
category: cmd.category ?? 'Other', category: cmd.category ?? 'Other',
@@ -26,7 +26,7 @@ module.exports = {
.map((category) => ({ .map((category) => ({
name: category, name: category,
value: grouped[category] value: grouped[category]
.map(command => `• **/${command.name}** — ${command.description}`) .map((command) => `- **/${command.name}**: ${command.description}`)
.join('\n'), .join('\n'),
})); }));
+26 -10
View File
@@ -6,6 +6,7 @@ const express = require('express');
const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings');
const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions');
const { CONFIG_PATH, getConfig, getConfigValue } = require('./config'); const { CONFIG_PATH, getConfig, getConfigValue } = require('./config');
const { loadCommandModules } = require('./loaders/commands');
const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const DEFAULT_SESSION_CLEANUP_MS = 60 * 60 * 1000; const DEFAULT_SESSION_CLEANUP_MS = 60 * 60 * 1000;
const MAX_REASON_LENGTH = 500; const MAX_REASON_LENGTH = 500;
@@ -66,7 +67,6 @@ console.log('[oauth config]', {
const DISCORD_API_BASE = 'https://discord.com/api'; const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2'; 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 GUILD_CACHE_TTL_MS = 5 * 60 * 1000;
const BOT_GUILD_CACHE_TTL_MS = 60 * 1000; const BOT_GUILD_CACHE_TTL_MS = 60 * 1000;
const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.'; const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.';
@@ -407,18 +407,34 @@ const isHttpsRequest = (req) => {
}; };
const loadCommands = () => { const loadCommands = () => {
if (!fs.existsSync(COMMANDS_PATH)) { const commandsPath = path.join(__dirname, 'commands');
return []; const { commands, duplicateCommands, invalidCommands, loadErrors } = loadCommandModules(commandsPath);
if (invalidCommands.length > 0) {
console.warn('Skipping invalid commands while building dashboard command list.', {
invalidCommands,
});
} }
try { if (duplicateCommands.length > 0) {
const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8'); console.error('Duplicate command definitions detected while building dashboard command list.', {
const parsed = JSON.parse(raw); duplicateCommands,
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : []; });
} catch (error) {
console.error('Failed to load commands.json', {message: error.message});
return [];
} }
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) => { const canManageGuild = (guild) => {
+30 -2
View File
@@ -1,15 +1,43 @@
const path = require('node:path'); const path = require('node:path');
const { REST, Routes } = require('discord.js'); const { REST, Routes } = require('discord.js');
const { clientId, token } = require('./config.json'); const { getConfigValue } = require('./config');
const { loadCommandModules } = require('./loaders/commands'); const { loadCommandModules } = require('./loaders/commands');
const commandsPath = path.join(__dirname, '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 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) { for (const filePath of invalidCommands) {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); 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); const rest = new REST().setToken(token);
(async () => { (async () => {
+34 -2
View File
@@ -14,9 +14,18 @@ process.on('uncaughtException', (error) => {
const client = new Client({ intents: [GatewayIntentBits.Guilds] }); const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.commands = new Collection(); client.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands'); 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 eventsPath = path.join(__dirname, 'events');
const eventModules = loadEventModules(eventsPath); const {
events: eventModules,
invalidEvents,
loadErrors: eventLoadErrors,
} = loadEventModules(eventsPath);
for (const command of commands) { for (const command of commands) {
client.commands.set(command.data.name, command); 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.`); 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) { for (const { event } of eventModules) {
if (event.once) { if (event.once) {
client.once(event.name, (...args) => event.execute(...args)); 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'); const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken');
if (!token) { if (!token) {
console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.'); console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.');
+53 -17
View File
@@ -4,38 +4,74 @@ const path = require('node:path');
const isJavaScriptFile = (file) => file.endsWith('.js'); const isJavaScriptFile = (file) => file.endsWith('.js');
const getCommandFilePaths = (commandsPath) => { const getCommandFilePaths = (commandsPath) => {
const commandFolders = fs const commandFilePaths = [];
.readdirSync(commandsPath, { withFileTypes: true }) const entries = fs.readdirSync(commandsPath, { withFileTypes: true });
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
return commandFolders.flatMap((folder) => { for (const entry of entries) {
const folderPath = path.join(commandsPath, folder); const entryPath = path.join(commandsPath, entry.name);
return fs if (entry.isDirectory()) {
.readdirSync(folderPath) commandFilePaths.push(...getCommandFilePaths(entryPath));
.filter(isJavaScriptFile) continue;
.map((file) => path.join(folderPath, file)); }
}); if (entry.isFile() && isJavaScriptFile(entry.name)) {
commandFilePaths.push(entryPath);
}
}
return commandFilePaths.sort((left, right) => left.localeCompare(right));
}; };
const loadCommandModules = (commandsPath) => { const loadCommandModules = (commandsPath) => {
const commandFiles = getCommandFilePaths(commandsPath); const commandFiles = getCommandFilePaths(commandsPath);
const commands = []; const commands = [];
const invalidCommands = []; const invalidCommands = [];
const duplicateCommands = [];
const loadErrors = [];
const commandNames = new Map();
for (const filePath of commandFiles) { for (const filePath of commandFiles) {
const command = require(filePath); let command;
try {
delete require.cache[require.resolve(filePath)];
command = require(filePath);
} catch (error) {
loadErrors.push({ filePath, error });
continue;
}
if ('data' in command && 'execute' in command) { const commandName = command?.data?.name;
commands.push(command); const isValidCommand =
} else { typeof commandName === 'string' &&
commandName.length > 0 &&
typeof command.execute === 'function';
if (!isValidCommand) {
invalidCommands.push(filePath); invalidCommands.push(filePath);
} continue;
} }
return { commands, invalidCommands }; if (commandNames.has(commandName)) {
duplicateCommands.push({
commandName,
filePath,
existingFilePath: commandNames.get(commandName),
});
continue;
}
commandNames.set(commandName, filePath);
commands.push(command);
}
return {
commands,
duplicateCommands,
invalidCommands,
loadErrors,
};
}; };
module.exports = { module.exports = {
getCommandFilePaths,
loadCommandModules, loadCommandModules,
}; };
+29 -4
View File
@@ -7,16 +7,41 @@ const getEventFilePaths = (eventsPath) =>
fs fs
.readdirSync(eventsPath) .readdirSync(eventsPath)
.filter(isJavaScriptFile) .filter(isJavaScriptFile)
.sort((left, right) => left.localeCompare(right))
.map((file) => path.join(eventsPath, file)); .map((file) => path.join(eventsPath, file));
const loadEventModules = (eventsPath) => { const loadEventModules = (eventsPath) => {
const eventFiles = getEventFilePaths(eventsPath); const eventFiles = getEventFilePaths(eventsPath);
return eventFiles.map((filePath) => ({ const events = [];
filePath, const invalidEvents = [];
event: require(filePath), 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 = { module.exports = {
getEventFilePaths,
loadEventModules, loadEventModules,
}; };
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "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": [], "keywords": [],
"author": "", "author": "",
+85 -15
View File
@@ -88,7 +88,7 @@
</div> </div>
<div class="callout" id="botInactiveCallout" hidden> <div class="callout" id="botInactiveCallout" hidden>
<strong>Bot not added yet.</strong> <strong>Bot not added yet.</strong>
<p class="muted">The bot isn’t in this server yet. Invite it to configure settings.</p> <p class="muted">The bot is not in this server yet. Invite it to configure settings.</p>
<p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p> <p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p>
</div> </div>
<div class="field"> <div class="field">
@@ -115,13 +115,53 @@
<div class="panel__header panel__header--split"> <div class="panel__header panel__header--split">
<div> <div>
<h2>Command permissions</h2> <h2>Command permissions</h2>
<p class="muted">Restrict commands to roles or specific users.</p> <p class="muted">Review and edit restrictions faster with search, filters, and live summaries.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button> <button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
<button class="button" id="copyFromGuild">Copy from another guild</button> <button class="button" id="copyFromGuild">Copy from another guild</button>
</div> </div>
</div> </div>
<div class="stats-grid">
<article class="stat-card">
<span class="stat-card__label">Commands</span>
<strong class="stat-card__value" id="permissionTotalCount">0</strong>
<span class="muted">Available moderation commands</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Restricted</span>
<strong class="stat-card__value" id="permissionRestrictedCount">0</strong>
<span class="muted">Commands limited to specific roles or users</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Open</span>
<strong class="stat-card__value" id="permissionOpenCount">0</strong>
<span class="muted">Commands currently available to everyone</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Unsaved edits</span>
<strong class="stat-card__value" id="permissionChangedCount">0</strong>
<span class="muted">Cards changed since the last save</span>
</article>
</div>
<div class="toolbar">
<div class="field toolbar__field">
<label for="commandSearch">Find command</label>
<input type="search" id="commandSearch" placeholder="Search by command name" />
</div>
<div class="field toolbar__field">
<label for="commandFilter">Show</label>
<select id="commandFilter">
<option value="all">All commands</option>
<option value="restricted">Restricted only</option>
<option value="open">Open only</option>
<option value="changed">Changed only</option>
</select>
</div>
</div>
<div class="field"> <div class="field">
<span> <span>
Add command Add command
@@ -132,48 +172,78 @@
<button class="button" id="addCommandButton">Add</button> <button class="button" id="addCommandButton">Add</button>
</div> </div>
</div> </div>
<div class="command-list" id="commandsList"></div> <div class="command-list" id="commandsList"></div>
<div class="empty-state" id="commandsEmpty" hidden> <div class="empty-state" id="commandsEmpty" hidden>
No commands configured yet. Add one or invite the bot to sync commands. No commands match the current filter. Try clearing the search or add a custom command override.
</div> </div>
</section> </section>
<section class="panel" id="warningsPanel" hidden> <section class="panel" id="warningsPanel" hidden>
<div class="warnings-layout"> <div class="warnings-layout">
<div class="warnings-pane"> <div class="warnings-pane">
<div class="panel__header">
<div>
<h2>User lookup</h2> <h2>User lookup</h2>
<p class="muted">Search members or paste an ID.</p> <p class="muted">Search members or paste an ID, then manage all warnings from one target card.</p>
</div>
</div>
<div class="field"> <div class="field">
<label for="warningsUserSearch">Find member</label> <label for="warningsUserSearch">Find member</label>
<input type="search" id="warningsUserSearch" placeholder="Type a username" /> <input type="search" id="warningsUserSearch" placeholder="Type at least 2 characters" />
<span class="muted" id="warningsSearchStatus">Start typing to search this guild.</span>
</div> </div>
<div class="member-list" id="warningsUserResults"></div> <div class="member-list" id="warningsUserResults"></div>
<p class="muted" id="warningsSelectedUser">No user selected.</p>
<div class="field"> <div class="field">
<label for="warningsUserInput">User ID</label> <label for="warningsUserInput">Manual user ID</label>
<input type="text" id="warningsUserInput" placeholder="123456789" /> <input type="text" id="warningsUserInput" placeholder="123456789" />
</div> </div>
<div class="selected-user-card" id="warningsSelectedCard">
<span class="selected-user-card__label">Current target</span>
<strong id="warningsSelectedUser">No user selected.</strong>
<span class="muted" id="warningsSelectedHint">Pick a member from search results or paste a Discord user ID.</span>
</div>
<div class="button-row"> <div class="button-row">
<button class="button" id="fetchWarningsButton">Fetch warnings</button> <button class="button" id="fetchWarningsButton">Load history</button>
<button class="button button--ghost" id="clearWarningsButton">Clear warnings</button> <button class="button button--ghost" id="clearWarningsButton">Clear warnings</button>
</div> </div>
<div class="spinner" id="warningsSpinner" hidden></div> <div class="spinner" id="warningsSpinner" hidden></div>
</div> </div>
<div class="warnings-pane"> <div class="warnings-pane">
<div class="panel__header panel__header--split">
<div>
<h2>Warnings</h2> <h2>Warnings</h2>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div> <p class="muted">See warning history and add new entries for the selected target.</p>
</div>
<div class="warning-status-pill" id="warningsHistoryState">No history loaded</div>
</div>
<div class="stats-grid stats-grid--compact">
<article class="stat-card stat-card--compact">
<span class="stat-card__label">Total warnings</span>
<strong class="stat-card__value" id="warningsTotalCount">0</strong>
</article>
<article class="stat-card stat-card--compact">
<span class="stat-card__label">Latest warning</span>
<strong class="stat-card__value stat-card__value--small" id="warningsLatestDate">None</strong>
</article>
</div>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found for this user.</div>
<ul id="warningsList"></ul> <ul id="warningsList"></ul>
<div class="divider"></div> <div class="divider"></div>
<h3>Add warning</h3> <h3>Add warning</h3>
<div class="field"> <p class="muted">The warning will be added to the currently selected target.</p>
<label for="newWarningUserInput">User ID</label>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</div>
<div class="field"> <div class="field">
<label for="warningReasonInput">Reason</label> <label for="warningReasonInput">Reason</label>
<textarea id="warningReasonInput" placeholder="Reason for warning" maxlength="200"></textarea> <textarea
id="warningReasonInput"
placeholder="Describe what happened and why the warning is being issued."
maxlength="500"
></textarea>
<div class="field__meta"> <div class="field__meta">
<span class="muted" id="warningCharCount">0/200</span> <span class="muted" id="warningCharCount">0/500</span>
<span class="muted" id="warningStatus"></span> <span class="muted" id="warningStatus"></span>
</div> </div>
</div> </div>
+129
View File
@@ -552,6 +552,61 @@ textarea:focus-visible,
flex-wrap: wrap; flex-wrap: wrap;
} }
.toolbar {
display: grid;
grid-template-columns: minmax(240px, 1fr) 220px;
gap: 14px;
margin-bottom: 18px;
}
.toolbar__field {
margin-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.stats-grid--compact {
margin-top: 4px;
margin-bottom: 16px;
}
.stat-card {
padding: 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(21, 31, 39, 0.96), rgba(17, 26, 34, 0.9));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.stat-card--compact {
padding: 12px;
}
.stat-card__label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.stat-card__value {
display: block;
font-size: 1.8rem;
line-height: 1.1;
margin-bottom: 8px;
}
.stat-card__value--small {
font-size: 1rem;
}
.command-list { .command-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -578,6 +633,46 @@ textarea:focus-visible,
gap: 12px; gap: 12px;
} }
.command-card__title {
display: flex;
flex-direction: column;
gap: 6px;
}
.command-card__summary {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mini-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.76rem;
background: rgba(11, 17, 19, 0.25);
}
.mini-badge--accent {
border-color: rgba(44, 185, 198, 0.45);
color: #ccf7fb;
}
.mini-badge--warn {
border-color: rgba(244, 184, 96, 0.45);
color: #ffe8b1;
}
.mini-badge--ok {
border-color: rgba(51, 209, 122, 0.45);
color: #d6f5de;
}
.command-card__body { .command-card__body {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -673,6 +768,7 @@ textarea:focus-visible,
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 10px; padding: 10px;
margin-bottom: 10px; margin-bottom: 10px;
list-style: none;
} }
.warning-item__meta { .warning-item__meta {
@@ -697,6 +793,35 @@ textarea:focus-visible,
padding: 14px; padding: 14px;
} }
.selected-user-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(44, 185, 198, 0.25);
background: linear-gradient(180deg, rgba(44, 185, 198, 0.08), rgba(17, 26, 34, 0.9));
margin-bottom: 16px;
}
.selected-user-card__label {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.warning-status-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: rgba(17, 26, 34, 0.8);
font-size: 0.8rem;
}
.divider { .divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
@@ -869,4 +994,8 @@ strong {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.toolbar {
grid-template-columns: 1fr;
}
} }
+264
View File
@@ -0,0 +1,264 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { Collection, MessageFlags, PermissionFlagsBits } = require('discord.js');
const records = require('../services/moderationRecords');
const permissions = require('../services/permissions');
const makeMember = (id, { position = 1, hasRole = true, bannable = true, canBan = true } = {}) => ({
id,
bannable,
permissions: { has: permission => permission === PermissionFlagsBits.BanMembers && canBan },
roles: {
cache: new Collection(hasRole ? [['role', {}]] : []),
highest: { position, comparePositionTo(other) { return this.position - other.position; } },
},
});
const createHarness = (t, options = {}) => {
const members = options.members || [makeMember('target')];
const moderator = makeMember('moderator', { position: 10, canBan: options.moderatorCanBan !== false });
const bot = makeMember('bot', { position: 20, canBan: options.botCanBan !== false });
const savedRecords = [];
const bans = [];
const banAttempts = [];
const edits = [];
const replies = [];
const listCalls = [];
const fetchCalls = [];
let filter;
t.mock.method(console, 'error', () => {});
t.mock.method(records, 'recordModerationAction', async (record) => {
savedRecords.push(record);
if (options.logFailure) throw new Error('Database unavailable');
});
t.mock.method(permissions, 'isCommandAllowed', async () => options.allowed !== false);
delete require.cache[require.resolve('../commands/moderation/ban_role')];
const command = require('../commands/moderation/ban_role');
const confirmation = {
customId: `ban-role:${options.choice || 'confirm'}:interaction`,
update: async (payload) => {
edits.push(payload);
if (options.onConfirm) options.onConfirm({ members, moderator, bot });
},
editReply: async (payload) => { edits.push(payload); },
};
const message = {
awaitMessageComponent: async (collectorOptions) => {
assert.equal(bans.length, 0, 'Bans must wait for confirmation');
assert.equal(collectorOptions.time, 60_000);
filter = collectorOptions.filter;
if (options.onWait) await options.onWait({ command, interaction });
if (options.timeout) throw new Error('Collector timed out');
return confirmation;
},
};
const guild = {
id: 'guild',
ownerId: options.ownerId || 'owner',
roles: { fetch: async () => {} },
members: {
fetchMe: async () => bot,
fetch: async (fetchOptions) => {
assert.equal(fetchOptions.force, true);
fetchCalls.push(fetchOptions.user);
if (fetchOptions.user === moderator.id) return moderator;
const failure = options.fetchFailures?.[fetchOptions.user];
if (failure) throw failure;
return members.find(member => member.id === fetchOptions.user);
},
list: async (listOptions) => {
listCalls.push(listOptions);
if (options.listFailureAt === listCalls.length) throw new Error('Listing failed');
const page = options.pages ? options.pages[listCalls.length - 1] : members;
return new Collection(page.map(member => [member.id, member]));
},
},
bans: {
create: async (id, banOptions) => {
banAttempts.push(id);
if (options.banFailures?.includes(id)) throw new Error('Ban rejected');
bans.push({ id, ...banOptions });
if (options.onBan) options.onBan({ id, moderator, bot });
},
},
};
const interaction = {
id: 'interaction',
guild: options.dm ? null : guild,
guildId: guild.id,
user: { id: moderator.id },
client: { user: { id: bot.id } },
options: {
getRole: () => ({ id: options.roleId || 'role' }),
getString: () => options.reason ?? 'Raid accounts',
},
reply: async (payload) => { replies.push(payload); },
deferReply: async (payload) => { assert.equal(payload.flags, MessageFlags.Ephemeral); },
editReply: async (payload) => { edits.push(payload); return message; },
};
return {
command, interaction, bans, banAttempts, savedRecords, edits, replies, listCalls, fetchCalls,
run: () => command.execute(interaction),
get filter() { return filter; },
get result() { return edits.at(-1)?.content || replies.at(-1)?.content; },
};
};
test('command registration requires a role, reason, and Ban Members permission', (t) => {
const { command } = createHarness(t);
const data = command.data.toJSON();
assert.equal(data.name, 'ban-role');
assert.equal(data.dm_permission, false);
assert.equal(data.default_member_permissions, PermissionFlagsBits.BanMembers.toString());
assert.deepEqual(data.options.map(option => [option.name, option.required]), [['role', true], ['reason', true]]);
});
for (const [description, options, expected] of [
['direct messages', { dm: true }, /inside a server/],
['everyone', { roleId: 'guild' }, /@everyone/],
['blank reasons', { reason: ' ' }, /provide a reason/],
['moderators without Ban Members', { moderatorCanBan: false }, /You need the Ban Members/],
['bots without Ban Members', { botCanBan: false }, /I need the Ban Members/],
]) {
test(`rejects ${description} without listing or banning members`, async (t) => {
const harness = createHarness(t, options);
await harness.run();
assert.match(harness.result, expected);
assert.equal(harness.listCalls.length, 0);
assert.equal(harness.bans.length, 0);
});
}
test('bans only eligible role members and records successful bans', async (t) => {
const harness = createHarness(t, { members: [
makeMember('eligible'),
makeMember('other-role', { hasRole: false }),
makeMember('moderator'),
makeMember('bot'),
makeMember('owner'),
makeMember('peer', { position: 10 }),
makeMember('higher', { position: 11 }),
makeMember('unbannable', { bannable: false }),
] });
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['eligible']);
assert.equal(harness.bans[0].deleteMessageSeconds, 0);
assert.match(harness.bans[0].reason, /moderator: Raid accounts/);
assert.deepEqual(harness.savedRecords[0], {
guildId: 'guild', userId: 'eligible', action: 'ban', moderatorId: 'moderator', reason: 'Raid accounts',
metadata: { command: 'ban-role', roleId: 'role' },
stateUpdates: { isBanned: true, bannedUntil: null },
});
assert.match(harness.result, /Banned: 1\. Skipped: 6\. Failed: 0/);
assert.equal(harness.filter({ user: { id: 'stranger' }, customId: 'ban-role:confirm:interaction' }), false);
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'different-action' }), false);
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'ban-role:confirm:interaction' }), true);
});
test('server owner bypasses only the moderator hierarchy', async (t) => {
const harness = createHarness(t, { ownerId: 'moderator', members: [
makeMember('above-owner', { position: 11 }),
makeMember('above-bot', { position: 21, bannable: false }),
] });
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['above-owner']);
});
for (const [description, options, expected] of [
['cancellation', { choice: 'cancel' }, /Cancelled/],
['timeout', { timeout: true }, /expired/],
['empty roles', { members: [] }, /No eligible members/],
['protected members only', { members: [makeMember('owner')] }, /Skipped: 1/],
['revoked dashboard permission', { allowed: false }, /no longer have permission/],
['revoked Discord permission', { onConfirm: ({ moderator }) => { moderator.permissions.has = () => false; } }, /You need the Ban Members/],
]) {
test(`${description} produces no bans`, async (t) => {
const harness = createHarness(t, options);
await harness.run();
assert.equal(harness.bans.length, 0);
assert.equal(harness.savedRecords.length, 0);
assert.match(harness.result, expected);
assert.equal(harness.edits.at(-1)?.components?.length || 0, 0);
});
}
test('discovers matching members beyond the first 1000 members', async (t) => {
const firstPage = Array.from({ length: 1000 }, (_, id) => makeMember(String(id), { hasRole: false }));
const target = makeMember('1000');
const harness = createHarness(t, { members: [target], pages: [firstPage, [target]] });
await harness.run();
assert.deepEqual(harness.listCalls, [
{ limit: 1000, after: undefined, cache: false },
{ limit: 1000, after: '999', cache: false },
]);
assert.deepEqual(harness.bans.map(ban => ban.id), ['1000']);
});
test('incomplete member discovery never offers confirmation or bans a partial list', async (t) => {
const page = Array.from({ length: 1000 }, (_, id) => makeMember(String(id)));
const harness = createHarness(t, { pages: [page], listFailureAt: 2 });
await harness.run();
assert.equal(harness.bans.length, 0);
assert.equal(harness.filter, undefined);
assert.match(harness.result, /Could not load all server members/);
});
test('rechecks role membership and hierarchy and excludes members added after the preview', async (t) => {
const harness = createHarness(t, {
members: [makeMember('removed'), makeMember('promoted'), makeMember('left'), makeMember('remaining')],
fetchFailures: { left: { code: 10007 } },
onConfirm: ({ members }) => {
members[0].roles.cache.clear();
members[1].roles.highest.position = 15;
members.push(makeMember('new'));
},
});
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['remaining']);
assert.match(harness.result, /Banned: 1\. Skipped: 3\. Failed: 0/);
});
test('continues after fetch, ban, and record failures while reporting correct totals', async (t) => {
const harness = createHarness(t, {
members: [makeMember('unavailable'), makeMember('denied'), makeMember('success'), makeMember('success2')],
fetchFailures: { unavailable: new Error('Network failed') },
banFailures: ['denied'],
logFailure: true,
});
await harness.run();
assert.deepEqual(harness.banAttempts, ['denied', 'success', 'success2']);
assert.deepEqual(harness.savedRecords.map(record => record.userId), ['success', 'success2']);
assert.match(harness.result, /Banned: 2\. Skipped: 0\. Failed: 2/);
assert.match(harness.result, /could not be saved for 2 successful bans/);
});
test('stops and preserves partial results if moderator permissions change mid-batch', async (t) => {
const harness = createHarness(t, {
members: [makeMember('first'), makeMember('second')],
onBan: ({ moderator }) => { moderator.permissions.has = () => false; },
});
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['first']);
assert.match(harness.result, /Role ban stopped/);
assert.match(harness.result, /Banned: 1/);
});
test('prevents overlapping role bans and releases the server after cancellation', async (t) => {
let overlapChecks = 0;
const harness = createHarness(t, {
choice: 'cancel',
onWait: async ({ command, interaction }) => {
overlapChecks++;
await command.execute({
...interaction,
reply: async (payload) => assert.match(payload.content, /already being previewed or processed/),
});
},
});
await harness.run();
await harness.run();
assert.equal(overlapChecks, 2);
assert.equal(harness.bans.length, 0);
});
+55
View File
@@ -0,0 +1,55 @@
const path = require('node:path');
const { loadCommandModules } = require('../loaders/commands');
const { loadEventModules } = require('../loaders/events');
const { createDashboardApp } = require('../dashboardServer');
const commandsPath = path.join(__dirname, '..', 'commands');
const eventsPath = path.join(__dirname, '..', 'events');
const commandResult = loadCommandModules(commandsPath);
const eventResult = loadEventModules(eventsPath);
let hasFailures = false;
const printFailures = (heading, values) => {
if (!values || values.length === 0) {
return;
}
hasFailures = true;
console.error(heading);
for (const value of values) {
console.error(`- ${value}`);
}
};
printFailures('Invalid command modules:', commandResult.invalidCommands);
printFailures(
'Duplicate command names:',
commandResult.duplicateCommands.map(
({ commandName, filePath, existingFilePath }) =>
`${commandName}: ${filePath} conflicts with ${existingFilePath}`,
),
);
printFailures(
'Command load errors:',
commandResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
);
printFailures('Invalid event modules:', eventResult.invalidEvents);
printFailures(
'Event load errors:',
eventResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
);
if (typeof createDashboardApp !== 'function') {
hasFailures = true;
console.error('Dashboard app factory is not exported correctly.');
}
if (!hasFailures) {
console.log(
`Verification passed: ${commandResult.commands.length} commands and ${eventResult.events.length} events loaded successfully.`,
);
}
process.exit(hasFailures ? 1 : 0);