Add /ban-role moderation command and improve module loading
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user