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
+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);