@@ -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.
|
||||||
|
|
||||||
@@ -90,3 +91,36 @@ Example document stored in the `discord.guild_permissions` collection:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Configuration**
|
||||||
|
Set secrets via environment variables, or copy `config.example.json` to `config.json` and fill in your own values.
|
||||||
|
|
||||||
|
Recommended env vars:
|
||||||
|
- `DISCORD_BOT_TOKEN`
|
||||||
|
- `DISCORD_CLIENT_ID`
|
||||||
|
- `DISCORD_CLIENT_SECRET`
|
||||||
|
- `DISCORD_REDIRECT_URI`
|
||||||
|
- `MONGODB_URI`
|
||||||
|
|
||||||
|
### **Banning members by role**
|
||||||
|
|
||||||
|
Use `/ban-role role:@Role reason:Reason for the bans`. The bot privately previews the
|
||||||
|
number of eligible members and asks you to confirm within 60 seconds. Bans are permanent
|
||||||
|
and existing messages are kept. Both you and the bot need **Ban Members** permission.
|
||||||
|
The command skips you, the server owner, the bot itself, and members whose highest role
|
||||||
|
is equal to or above yours or the bot's (the server owner bypasses their own hierarchy check).
|
||||||
|
`@everyone` cannot be selected. Only members included in the preview can be banned;
|
||||||
|
their role membership and hierarchy are checked again before each ban.
|
||||||
|
|
||||||
|
Enable **Server Members Intent** under **Bot > Privileged Gateway Intents** in the
|
||||||
|
[Discord Developer Portal](https://discord.com/developers/applications) so the bot can
|
||||||
|
[list all guild members](https://docs.discord.com/developers/resources/guild#list-guild-members).
|
||||||
|
Member discovery uses paginated REST requests, so no Gateway intent change is needed in `index.js`.
|
||||||
|
Run `npm run deploy:commands` and restart the bot after installing the command.
|
||||||
|
|
||||||
|
The result reports successful bans, skipped members, failed requests, and any moderation
|
||||||
|
record failures separately. Long batches stop after about 14 minutes and report how many
|
||||||
|
members remain; run the command again to preview and continue. `/ban-role` also appears
|
||||||
|
automatically in `/help` and the dashboard's moderation permission settings.
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ module.exports = {
|
|||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
const dbClient = await connectToDatabase();
|
const dbClient = await connectToDatabase();
|
||||||
const message = interaction.options.getString('message');
|
const message = interaction.options.getString('message');
|
||||||
|
if (message.length > 500) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'Message must be 500 characters or fewer.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const db = dbClient.db('discord');
|
const db = dbClient.db('discord');
|
||||||
const collection = db.collection('test');
|
const collection = db.collection('test');
|
||||||
await collection.insertOne({ userId: interaction.user.id, message: message });
|
await collection.insertOne({ userId: interaction.user.id, message: message });
|
||||||
|
|||||||
@@ -40,22 +40,21 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderId = interaction.user.id;
|
const senderId = interaction.user.id;
|
||||||
|
const debitResult = await collection.findOneAndUpdate(
|
||||||
|
{ userID: senderId, balance: { $gte: amount } },
|
||||||
|
{ $inc: { balance: -amount } },
|
||||||
|
{ returnDocument: 'after' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!debitResult.value) {
|
||||||
const senderData = await collection.findOne({ userID: senderId });
|
const senderData = await collection.findOne({ userID: senderId });
|
||||||
const senderBalance = senderData?.balance ?? 0;
|
const senderBalance = senderData?.balance ?? 0;
|
||||||
|
|
||||||
if (senderBalance < amount) {
|
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: `You only have **${senderBalance}** coins available.`,
|
content: `You only have **${senderBalance}** coins available.`,
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await collection.updateOne(
|
|
||||||
{ userID: senderId },
|
|
||||||
{ $inc: { balance: -amount } },
|
|
||||||
{ upsert: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
await collection.updateOne(
|
await collection.updateOne(
|
||||||
{ userID: target.id },
|
{ userID: target.id },
|
||||||
{ $inc: { balance: amount } },
|
{ $inc: { balance: amount } },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -18,6 +18,12 @@ module.exports = {
|
|||||||
.setDescription('The reason of the kick')),
|
.setDescription('The reason of the kick')),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
|
if (!interaction.guild) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'This command can only be used inside a server.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const user = interaction.options.getUser('target');
|
const user = interaction.options.getUser('target');
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ module.exports = {
|
|||||||
.setDescription('The reason of the mute')),
|
.setDescription('The reason of the mute')),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
|
if (!interaction.guild) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'This command can only be used inside a server.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const target = interaction.options.getUser('target');
|
const target = interaction.options.getUser('target');
|
||||||
const duration = interaction.options.getInteger('duration');
|
const duration = interaction.options.getInteger('duration');
|
||||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ module.exports = {
|
|||||||
.setRequired(true))),
|
.setRequired(true))),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
|
if (!interaction.guild) {
|
||||||
|
return interaction.reply({ content: 'This command can only be used inside a server.', ephemeral: true });
|
||||||
|
}
|
||||||
const target = interaction.options.getUser('target');
|
const target = interaction.options.getUser('target');
|
||||||
const role = interaction.options.getRole('role');
|
const role = interaction.options.getRole('role');
|
||||||
const action = interaction.options.getSubcommand();
|
const action = interaction.options.getSubcommand();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ module.exports = {
|
|||||||
option
|
option
|
||||||
.setName('durationms')
|
.setName('durationms')
|
||||||
.setDescription('Duration of the temporary ban (in milliseconds).')
|
.setDescription('Duration of the temporary ban (in milliseconds).')
|
||||||
|
.setMinValue(60_000)
|
||||||
|
.setMaxValue(2_592_000_000)
|
||||||
.setRequired(true))
|
.setRequired(true))
|
||||||
.addStringOption(option =>
|
.addStringOption(option =>
|
||||||
option
|
option
|
||||||
@@ -31,23 +33,41 @@ module.exports = {
|
|||||||
.setMinValue(0).setMaxValue(7)),
|
.setMinValue(0).setMaxValue(7)),
|
||||||
category: 'Moderation',
|
category: 'Moderation',
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
|
if (!interaction.guild) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'This command can only be used inside a server.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const user = interaction.options.getUser('target');
|
const user = interaction.options.getUser('target');
|
||||||
const reason = interaction.options.getString('reason');
|
const reason = interaction.options.getString('reason');
|
||||||
const durationms = interaction.options.getInteger('durationms');
|
const durationms = interaction.options.getInteger('durationms');
|
||||||
const deleteMessages = interaction.options.getInteger('delete-messages');
|
const deleteMessages = interaction.options.getInteger('delete-messages');
|
||||||
|
|
||||||
// If you want to parse time like "1d", "10m", etc., use ms package:
|
if (user.bot) {
|
||||||
let banUntil;
|
|
||||||
try {
|
|
||||||
banUntil = Date.now() + durationms;
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, handle error
|
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Invalid duration format. Please use something like "10m" or "1d".',
|
content: 'You cannot temp-ban a bot account.',
|
||||||
ephemeral: true
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'You cannot temp-ban yourself.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you want to parse time like "1d", "10m", etc., use ms package:
|
||||||
|
if (!Number.isFinite(durationms) || durationms <= 0) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'Invalid duration. Provide a positive number of milliseconds.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const banUntil = Date.now() + durationms;
|
||||||
|
|
||||||
|
|
||||||
// A function to send ephemeral messages
|
// A function to send ephemeral messages
|
||||||
async function sendEphemeral(message) {
|
async function sendEphemeral(message) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const { getGuildSettings } = require('../../services/guildSettings');
|
|||||||
const { recordModerationAction } = require('../../services/moderationRecords');
|
const { recordModerationAction } = require('../../services/moderationRecords');
|
||||||
const { connectToDatabase } = require('../../db');
|
const { connectToDatabase } = require('../../db');
|
||||||
|
|
||||||
|
const MAX_REASON_LENGTH = 500;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
permissionGroup: 'moderation',
|
permissionGroup: 'moderation',
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -31,6 +33,12 @@ module.exports = {
|
|||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (reason.length > MAX_REASON_LENGTH) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: `Reason must be ${MAX_REASON_LENGTH} characters or fewer.`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { warnings, strikeCount } = await addWarning({
|
const { warnings, strikeCount } = await addWarning({
|
||||||
guildId: interaction.guildId,
|
guildId: interaction.guildId,
|
||||||
|
|||||||
@@ -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'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"clientId": "YOUR_DISCORD_CLIENT_ID",
|
||||||
|
"clientSecret": "YOUR_DISCORD_CLIENT_SECRET",
|
||||||
|
"mongodb": "mongodb://127.0.0.1:27017/botdb",
|
||||||
|
"redirectUri": "http://localhost:3000/auth/discord/callback",
|
||||||
|
"dashboardInvitePermissions": "0",
|
||||||
|
"dashboardSessionTtlMs": "86400000",
|
||||||
|
"dashboardClientUrl": "http://localhost:3000",
|
||||||
|
"trustProxy": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||||
|
let cachedConfig = null;
|
||||||
|
|
||||||
|
const loadConfig = () => {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(CONFIG_PATH)) {
|
||||||
|
cachedConfig = {};
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
cachedConfig = parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse config.json', { message: error.message });
|
||||||
|
cachedConfig = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfig = () => loadConfig();
|
||||||
|
|
||||||
|
const getConfigValue = (envKey, ...configKeys) => {
|
||||||
|
const envValue = process.env[envKey];
|
||||||
|
if (typeof envValue === 'string' && envValue.trim() !== '') {
|
||||||
|
return envValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
for (const key of configKeys) {
|
||||||
|
const value = config?.[key];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_PATH,
|
||||||
|
getConfig,
|
||||||
|
getConfigValue,
|
||||||
|
};
|
||||||
+161
-46
@@ -5,39 +5,41 @@ const axios = require('axios');
|
|||||||
const express = require('express');
|
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 { 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 MAX_REASON_LENGTH = 500;
|
||||||
|
const MAX_MEMBER_QUERY_LENGTH = 64;
|
||||||
|
const JSON_BODY_LIMIT = '50kb';
|
||||||
const PERMISSION_BITS = {
|
const PERMISSION_BITS = {
|
||||||
ADMINISTRATOR: 0x8n,
|
ADMINISTRATOR: 0x8n,
|
||||||
MANAGE_GUILD: 0x20n,
|
MANAGE_GUILD: 0x20n,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfig = () => {
|
const config = getConfig();
|
||||||
const configPath = path.join(__dirname, 'config.json');
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
return require(configPath);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = loadConfig();
|
const DISCORD_CLIENT_ID = getConfigValue(
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
const DISCORD_CLIENT_ID =
|
'discordClientId',
|
||||||
process.env.DISCORD_CLIENT_ID ||
|
'clientId',
|
||||||
config.discordClientId ||
|
'appId',
|
||||||
config.clientId ||
|
);
|
||||||
config.appId;
|
const DISCORD_CLIENT_SECRET = getConfigValue(
|
||||||
const DISCORD_CLIENT_SECRET =
|
'DISCORD_CLIENT_SECRET',
|
||||||
process.env.DISCORD_CLIENT_SECRET ||
|
'discordClientSecret',
|
||||||
config.discordClientSecret ||
|
'clientSecret',
|
||||||
config.clientSecret;
|
);
|
||||||
const DISCORD_REDIRECT_URI =
|
const DISCORD_REDIRECT_URI = getConfigValue(
|
||||||
process.env.DISCORD_REDIRECT_URI ||
|
'DISCORD_REDIRECT_URI',
|
||||||
config.discordRedirectUri ||
|
'discordRedirectUri',
|
||||||
config.redirectUri;
|
'redirectUri',
|
||||||
const DISCORD_BOT_TOKEN =
|
);
|
||||||
process.env.DISCORD_BOT_TOKEN ||
|
const DISCORD_BOT_TOKEN = getConfigValue(
|
||||||
config.discordBotToken ||
|
'DISCORD_BOT_TOKEN',
|
||||||
config.token;
|
'discordBotToken',
|
||||||
|
'token',
|
||||||
|
);
|
||||||
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
|
const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt(
|
||||||
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
|
process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0',
|
||||||
10,
|
10,
|
||||||
@@ -50,18 +52,21 @@ const SESSION_TTL_MS = Number.parseInt(
|
|||||||
process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs,
|
process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
const TRUST_PROXY =
|
||||||
|
process.env.DASHBOARD_TRUST_PROXY === 'true' ||
|
||||||
|
process.env.DASHBOARD_TRUST_PROXY === '1' ||
|
||||||
|
Boolean(config.trustProxy);
|
||||||
|
|
||||||
console.log('[oauth config]', {
|
console.log('[oauth config]', {
|
||||||
DISCORD_CLIENT_ID_present: Boolean(DISCORD_CLIENT_ID && String(DISCORD_CLIENT_ID).trim()),
|
DISCORD_CLIENT_ID_present: Boolean(DISCORD_CLIENT_ID && String(DISCORD_CLIENT_ID).trim()),
|
||||||
DISCORD_CLIENT_SECRET_present: Boolean(DISCORD_CLIENT_SECRET && String(DISCORD_CLIENT_SECRET).trim()),
|
DISCORD_CLIENT_SECRET_present: Boolean(DISCORD_CLIENT_SECRET && String(DISCORD_CLIENT_SECRET).trim()),
|
||||||
DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null,
|
DISCORD_REDIRECT_URI: DISCORD_REDIRECT_URI || null,
|
||||||
configFilePath: path.join(__dirname, 'config.json'),
|
configFilePath: CONFIG_PATH,
|
||||||
configFileExists: fs.existsSync(path.join(__dirname, 'config.json')),
|
configFileExists: fs.existsSync(CONFIG_PATH),
|
||||||
});
|
});
|
||||||
|
|
||||||
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.';
|
||||||
@@ -72,6 +77,61 @@ const botGuildCache = {
|
|||||||
fetchedAt: 0,
|
fetchedAt: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('Unhandled promise rejection:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught exception:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanupExpiredSessions = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, session] of sessionStore.entries()) {
|
||||||
|
if (!session?.expiresAt || session.expiresAt <= now) {
|
||||||
|
sessionStore.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRateLimiter = ({ windowMs, max, keyFn }) => {
|
||||||
|
const hits = new Map();
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of hits.entries()) {
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
hits.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (req, res, next) => {
|
||||||
|
const key = keyFn(req);
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = hits.get(key);
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
hits.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (entry.count >= max) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
res.set('Retry-After', String(retryAfter));
|
||||||
|
return res.status(429).json({ error: 'RATE_LIMITED', retryAfterSeconds: retryAfter });
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureCsrf = (req, res, next) => {
|
||||||
|
const token = req.headers['x-csrf-token'];
|
||||||
|
if (!token || token !== req.session?.csrfToken) {
|
||||||
|
return res.status(403).json({ error: 'CSRF_INVALID', message: 'CSRF token missing or invalid.' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
const parseCookies = (headerValue) => {
|
const parseCookies = (headerValue) => {
|
||||||
const cookies = {};
|
const cookies = {};
|
||||||
if (!headerValue) {
|
if (!headerValue) {
|
||||||
@@ -115,13 +175,15 @@ const clearCookie = (res, name) => {
|
|||||||
const createSession = (payload) => {
|
const createSession = (payload) => {
|
||||||
const sessionId = crypto.randomBytes(32).toString('hex');
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS);
|
const expiresAt = Date.now() + (Number.isNaN(SESSION_TTL_MS) ? DEFAULT_SESSION_TTL_MS : SESSION_TTL_MS);
|
||||||
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
sessionStore.set(sessionId, {
|
sessionStore.set(sessionId, {
|
||||||
...payload,
|
...payload,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
csrfToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {sessionId, expiresAt};
|
return {sessionId, expiresAt, csrfToken};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSession = (sessionId) => {
|
const getSession = (sessionId) => {
|
||||||
@@ -345,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) => {
|
||||||
@@ -409,10 +487,34 @@ const ensureManageableGuild = async (req, res, next) => {
|
|||||||
const createDashboardApp = () => {
|
const createDashboardApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.disable('x-powered-by');
|
||||||
|
if (TRUST_PROXY) {
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
}
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.set('X-Frame-Options', 'DENY');
|
||||||
|
res.set('Referrer-Policy', 'no-referrer');
|
||||||
|
res.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.use(express.json({ limit: JSON_BODY_LIMIT }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
app.use(createRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 120,
|
||||||
|
keyFn: (req) => req.ip || 'unknown',
|
||||||
|
}));
|
||||||
|
|
||||||
app.get('/auth/discord', (req, res) => {
|
const authLimiter = createRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 20,
|
||||||
|
keyFn: (req) => req.ip || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(cleanupExpiredSessions, DEFAULT_SESSION_CLEANUP_MS).unref();
|
||||||
|
|
||||||
|
app.get('/auth/discord', authLimiter, (req, res) => {
|
||||||
if (!ensureOAuthConfig()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
@@ -428,7 +530,7 @@ const createDashboardApp = () => {
|
|||||||
return res.redirect(buildAuthorizationUrl(state));
|
return res.redirect(buildAuthorizationUrl(state));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/discord/callback', async (req, res) => {
|
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
|
||||||
if (!ensureOAuthConfig()) {
|
if (!ensureOAuthConfig()) {
|
||||||
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
|
||||||
}
|
}
|
||||||
@@ -467,7 +569,7 @@ const createDashboardApp = () => {
|
|||||||
return res.redirect('/dashboard.html');
|
return res.redirect('/dashboard.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({user});
|
return res.status(200).json({user, csrfToken: session.csrfToken});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Discord OAuth failed', {
|
console.error('Discord OAuth failed', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
@@ -482,7 +584,7 @@ const createDashboardApp = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/logout', ensureSession, (req, res) => {
|
app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => {
|
||||||
destroySession(req.sessionId);
|
destroySession(req.sessionId);
|
||||||
clearCookie(res, 'dashboard_session');
|
clearCookie(res, 'dashboard_session');
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
@@ -492,6 +594,7 @@ const createDashboardApp = () => {
|
|||||||
return res.json({
|
return res.json({
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
expiresAt: req.session.expiresAt,
|
expiresAt: req.session.expiresAt,
|
||||||
|
csrfToken: req.session.csrfToken,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -554,8 +657,12 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/permissions',
|
'/api/guilds/:guildId/permissions',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (!req.body || typeof req.body !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid permissions payload' });
|
||||||
|
}
|
||||||
const permissions = await updateGuildPermissions({
|
const permissions = await updateGuildPermissions({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
permissions: req.body || {},
|
permissions: req.body || {},
|
||||||
@@ -605,6 +712,9 @@ const createDashboardApp = () => {
|
|||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
return res.json({members: []});
|
return res.json({members: []});
|
||||||
}
|
}
|
||||||
|
if (query.length > MAX_MEMBER_QUERY_LENGTH) {
|
||||||
|
return res.status(400).json({ error: 'Query too long' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
|
const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query);
|
||||||
const normalized = (members || [])
|
const normalized = (members || [])
|
||||||
@@ -679,17 +789,21 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const {reason} = req.body || {};
|
const {reason} = req.body || {};
|
||||||
if (!reason) {
|
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
||||||
return res.status(400).json({error: 'Reason is required'});
|
return res.status(400).json({error: 'Reason is required'});
|
||||||
}
|
}
|
||||||
|
if (reason.length > MAX_REASON_LENGTH) {
|
||||||
|
return res.status(400).json({error: `Reason must be ${MAX_REASON_LENGTH} characters or fewer`});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await addWarning({
|
const result = await addWarning({
|
||||||
guildId: req.params.guildId,
|
guildId: req.params.guildId,
|
||||||
userId: req.params.userId,
|
userId: req.params.userId,
|
||||||
moderatorId: req.session.user?.id,
|
moderatorId: req.session.user?.id,
|
||||||
reason,
|
reason: reason.trim(),
|
||||||
});
|
});
|
||||||
const warnings = (result.warnings || []).map((warning) => ({
|
const warnings = (result.warnings || []).map((warning) => ({
|
||||||
...warning,
|
...warning,
|
||||||
@@ -718,6 +832,7 @@ const createDashboardApp = () => {
|
|||||||
'/api/guilds/:guildId/warnings/:userId',
|
'/api/guilds/:guildId/warnings/:userId',
|
||||||
ensureSession,
|
ensureSession,
|
||||||
ensureManageableGuild,
|
ensureManageableGuild,
|
||||||
|
ensureCsrf,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const warnings = await clearWarnings({
|
const warnings = await clearWarnings({
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
const {MongoClient, ServerApiVersion} = require('mongodb');
|
const { MongoClient, ServerApiVersion } = require('mongodb');
|
||||||
const {mongodb} = require("./config.json");
|
const { getConfigValue } = require('./config');
|
||||||
|
|
||||||
let dbClient;
|
let dbClient;
|
||||||
|
|
||||||
async function connectToDatabase() {
|
async function connectToDatabase() {
|
||||||
if (!dbClient) {
|
if (!dbClient) {
|
||||||
dbClient = new MongoClient(mongodb, {
|
const mongodbUri = getConfigValue('MONGODB_URI', 'mongodb', 'mongodbUri');
|
||||||
|
if (!mongodbUri) {
|
||||||
|
throw new Error('Missing MongoDB connection string. Set MONGODB_URI or config.json mongodb.');
|
||||||
|
}
|
||||||
|
|
||||||
|
dbClient = new MongoClient(mongodbUri, {
|
||||||
serverApi: {
|
serverApi: {
|
||||||
version: ServerApiVersion.v1,
|
version: ServerApiVersion.v1,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|||||||
+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 () => {
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { Client, Collection, GatewayIntentBits } = require('discord.js');
|
const { Client, Collection, GatewayIntentBits } = require('discord.js');
|
||||||
const { token } = require('./config.json');
|
const { getConfigValue } = require('./config');
|
||||||
const { loadCommandModules } = require('./loaders/commands');
|
const { loadCommandModules } = require('./loaders/commands');
|
||||||
const { loadEventModules } = require('./loaders/events');
|
const { loadEventModules } = require('./loaders/events');
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('Unhandled promise rejection:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('Uncaught exception:', 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);
|
||||||
@@ -18,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));
|
||||||
@@ -26,4 +61,15 @@ for (const { event } of eventModules) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (duplicateCommands.length > 0 || commandLoadErrors.length > 0 || eventLoadErrors.length > 0) {
|
||||||
|
console.error('Startup aborted because one or more modules failed validation.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken');
|
||||||
|
if (!token) {
|
||||||
|
console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
client.login(token);
|
client.login(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": "",
|
||||||
|
|||||||
+20
-1
@@ -14,11 +14,21 @@ const createFetchError = async (response) => {
|
|||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let csrfToken = null;
|
||||||
|
|
||||||
|
const setCsrfToken = (token) => {
|
||||||
|
csrfToken = typeof token === 'string' ? token : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfToken = () => csrfToken;
|
||||||
|
|
||||||
const fetchJson = async (url, options = {}) => {
|
const fetchJson = async (url, options = {}) => {
|
||||||
|
const method = (options.method || 'GET').toUpperCase();
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(csrfToken && method !== 'GET' && method !== 'HEAD' ? {'X-CSRF-Token': csrfToken} : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
@@ -110,6 +120,7 @@ const initIndexPage = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson('/api/me');
|
const data = await fetchJson('/api/me');
|
||||||
|
setCsrfToken(data.csrfToken);
|
||||||
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`;
|
||||||
loginButton.hidden = true;
|
loginButton.hidden = true;
|
||||||
dashboardButton.hidden = false;
|
dashboardButton.hidden = false;
|
||||||
@@ -131,7 +142,13 @@ const initIndexPage = async () => {
|
|||||||
|
|
||||||
logoutButton?.addEventListener('click', async () => {
|
logoutButton?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(csrfToken ? {'X-CSRF-Token': csrfToken} : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
sessionStatus.textContent = 'Logged out successfully.';
|
sessionStatus.textContent = 'Logged out successfully.';
|
||||||
loginButton.hidden = false;
|
loginButton.hidden = false;
|
||||||
dashboardButton.hidden = true;
|
dashboardButton.hidden = true;
|
||||||
@@ -151,6 +168,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
window.dashboardApi = {
|
window.dashboardApi = {
|
||||||
fetchJson,
|
fetchJson,
|
||||||
|
setCsrfToken,
|
||||||
|
getCsrfToken,
|
||||||
showToast,
|
showToast,
|
||||||
setErrorBanner,
|
setErrorBanner,
|
||||||
clearErrorBanner,
|
clearErrorBanner,
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+8
-1
@@ -580,6 +580,7 @@ const updateBotStatusControls = () => {
|
|||||||
const loadSession = async () => {
|
const loadSession = async () => {
|
||||||
const data = await fetchDashboardJson('/api/me');
|
const data = await fetchDashboardJson('/api/me');
|
||||||
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user);
|
||||||
|
dashboardApi.setCsrfToken(data.csrfToken);
|
||||||
if (data.expiresAt) {
|
if (data.expiresAt) {
|
||||||
const expires = new Date(data.expiresAt);
|
const expires = new Date(data.expiresAt);
|
||||||
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
|
elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`;
|
||||||
@@ -993,7 +994,13 @@ const initDashboard = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
elements.logoutButton.addEventListener('click', async () => {
|
elements.logoutButton.addEventListener('click', async () => {
|
||||||
await fetch('/auth/logout', {method: 'POST', credentials: 'include'});
|
await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(dashboardApi.getCsrfToken() ? {'X-CSRF-Token': dashboardApi.getCsrfToken()} : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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