Merge pull request #13 from Fretuks/main

Balls
This commit is contained in:
2026-09-27 21:28:29 +02:00
committed by GitHub
25 changed files with 1285 additions and 115 deletions
+34
View File
@@ -16,6 +16,7 @@ Im going to make a To-do List for the features i want to implement for my Bot.
3. **Moderation Tools** 3. **Moderation Tools**
- [x] Add commands to mute, kick, and temp_ban users. - [x] Add commands to mute, kick, and temp_ban users.
- [x] Add `/ban-role` to ban members with a selected role after confirmation.
- [ ] Create an auto-moderation feature to delete spam or inappropriate content. - [ ] Create an auto-moderation feature to delete spam or inappropriate content.
- [x] Implement a warning system with a strike counter. - [x] Implement a warning system with a strike counter.
@@ -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.
+6
View File
@@ -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 });
+7 -8
View File
@@ -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 } },
+225
View File
@@ -0,0 +1,225 @@
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
MessageFlags,
PermissionFlagsBits,
SlashCommandBuilder,
} = require('discord.js');
const { recordModerationAction } = require('../../services/moderationRecords');
const { isCommandAllowed } = require('../../services/permissions');
const activeGuilds = new Set();
const fetchModerator = async (interaction) => {
const [moderator, bot] = await Promise.all([
interaction.guild.members.fetch({ user: interaction.user.id, force: true }),
interaction.guild.members.fetchMe({ force: true }),
]);
if (!moderator.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('You need the Ban Members permission to use this command.');
}
if (!bot.permissions.has(PermissionFlagsBits.BanMembers)) {
throw new Error('I need the Ban Members permission to use this command.');
}
return moderator;
};
const canBan = (member, moderator, interaction) =>
member.id !== interaction.user.id &&
member.id !== interaction.client.user.id &&
member.id !== interaction.guild.ownerId &&
member.bannable &&
(moderator.id === interaction.guild.ownerId ||
moderator.roles.highest.comparePositionTo(member.roles.highest) > 0);
module.exports = {
mod: true,
permissionGroup: 'moderation',
category: 'Moderation',
data: new SlashCommandBuilder()
.setName('ban-role')
.setDescription('Permanently ban members with a role after confirming a preview.')
.setDMPermission(false)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addRoleOption(option => option
.setName('role')
.setDescription('The role whose members should be banned')
.setRequired(true))
.addStringOption(option => option
.setName('reason')
.setDescription('The reason for banning these members')
.setMinLength(1)
.setMaxLength(400)
.setRequired(true)),
async execute(interaction) {
const guild = interaction.guild;
const reject = (content) => interaction.reply({ content, flags: MessageFlags.Ephemeral });
if (!guild) {
return reject('This command can only be used inside a server.');
}
const role = interaction.options.getRole('role');
const reason = interaction.options.getString('reason').trim();
if (role.id === guild.id) {
return reject('You cannot use this command with @everyone.');
}
if (!reason) {
return reject('Please provide a reason for the bans.');
}
if (activeGuilds.has(guild.id)) {
return reject('A role ban is already being previewed or processed in this server.');
}
activeGuilds.add(guild.id);
const counts = { banned: 0, skipped: 0, failed: 0, logFailed: 0 };
let started = false;
let editReply = (payload) => interaction.editReply(payload);
const summary = () => `Banned: ${counts.banned}. Skipped: ${counts.skipped}. Failed: ${counts.failed}.` +
(counts.logFailed ? ` Moderation records could not be saved for ${counts.logFailed} successful bans.` : '');
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
await guild.roles.fetch();
const moderator = await fetchModerator(interaction);
const candidateIds = [];
let after;
// REST pagination includes uncached members without requesting a full Gateway member cache.
do {
let page;
try {
page = await guild.members.list({ limit: 1000, after, cache: false });
} catch (error) {
console.error('Failed to list members for role ban:', error);
return await editReply({
content: 'Could not load all server members. Check that Server Members Intent is enabled ' +
'in the Discord Developer Portal, then try again. No members were banned.',
});
}
for (const member of page.values()) {
if (!member.roles.cache.has(role.id)) continue;
if (canBan(member, moderator, interaction)) candidateIds.push(member.id);
else counts.skipped++;
}
if (page.size < 1000) break;
after = page.lastKey();
} while (true);
if (!candidateIds.length) {
return await editReply({
content: `No eligible members have <@&${role.id}>. Skipped: ${counts.skipped}.`,
allowedMentions: { parse: [] },
});
}
const confirmId = `ban-role:confirm:${interaction.id}`;
const cancelId = `ban-role:cancel:${interaction.id}`;
const reply = await editReply({
content: `Permanently ban **${candidateIds.length}** members with <@&${role.id}>?\n` +
`Skipped: ${counts.skipped} (yourself, the server owner, this bot, or members outside the role hierarchy).\n` +
`Reason: ${reason}\nExisting messages will be kept. Confirm within 60 seconds.`,
allowedMentions: { parse: [] },
components: [new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(confirmId).setLabel('Ban members').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId(cancelId).setLabel('Cancel').setStyle(ButtonStyle.Secondary),
)],
});
let confirmation;
try {
confirmation = await reply.awaitMessageComponent({
componentType: ComponentType.Button,
time: 60_000,
filter: component => component.user.id === interaction.user.id &&
[confirmId, cancelId].includes(component.customId),
});
} catch (error) {
return await editReply({ content: 'Confirmation expired. No members were banned.', components: [] });
}
await confirmation.update({
content: confirmation.customId === cancelId ? 'Cancelled. No members were banned.' : 'Checking permissions and banning members...',
components: [],
});
// Use the fresh component token for the final result of a potentially long batch.
editReply = (payload) => confirmation.editReply(payload);
if (confirmation.customId === cancelId) return;
await guild.roles.fetch();
const currentModerator = await fetchModerator(interaction);
const allowed = await isCommandAllowed({
guildId: guild.id,
userId: interaction.user.id,
member: currentModerator,
commandName: 'ban-role',
permissionGroup: 'moderation',
});
if (!allowed) {
return await editReply({ content: 'You no longer have permission to use this command. No members were banned.' });
}
started = true;
let processed = 0;
const deadline = Date.now() + 14 * 60_000;
for (const memberId of candidateIds) {
if (Date.now() >= deadline) break;
// Permissions and role membership may have changed since the preview.
const freshModerator = await fetchModerator(interaction);
let member;
try {
member = await guild.members.fetch({ user: memberId, force: true, cache: false });
} catch (error) {
if (error.code === 10007) counts.skipped++; // Member left the server.
else {
counts.failed++;
console.error(`Failed to fetch role-ban member ${memberId}:`, error);
}
processed++;
continue;
}
processed++;
if (!member.roles.cache.has(role.id) || !canBan(member, freshModerator, interaction)) {
counts.skipped++;
continue;
}
try {
await guild.bans.create(memberId, {
reason: `Role ban (${role.id}) by ${interaction.user.id}: ${reason}`,
deleteMessageSeconds: 0,
});
counts.banned++;
} catch (error) {
counts.failed++;
console.error(`Failed to ban member ${memberId} with role ${role.id}:`, error);
continue;
}
try {
await recordModerationAction({
guildId: guild.id,
userId: memberId,
action: 'ban',
moderatorId: interaction.user.id,
reason,
metadata: { command: 'ban-role', roleId: role.id },
stateUpdates: { isBanned: true, bannedUntil: null },
});
} catch (error) {
counts.logFailed++;
console.error(`Failed to record role ban for ${memberId}:`, error);
}
}
const remaining = candidateIds.length - processed;
return await editReply({
content: `${summary()}` + (remaining ? ` Time limit reached; ${remaining} members were not processed. Run the command again to continue.` : ''),
components: [],
});
} catch (error) {
console.error('Role ban stopped:', error);
return await editReply({
content: `Role ban stopped: ${error.message}\n${started ? summary() + ' Remaining members were not processed.' : 'No members were banned.'}`,
components: [],
allowedMentions: { parse: [] },
});
} finally {
activeGuilds.delete(guild.id);
}
},
};
+6
View File
@@ -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';
+6
View File
@@ -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';
+3
View File
@@ -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();
+28 -8
View File
@@ -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) {
+8
View File
@@ -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,
+2 -2
View File
@@ -7,7 +7,7 @@ module.exports = {
category: 'Utility', category: 'Utility',
async execute(interaction) { async execute(interaction) {
const commandDetails = interaction.client.commands const commandDetails = interaction.client.commands
.map(cmd => ({ .map((cmd) => ({
name: cmd.data.name, name: cmd.data.name,
description: cmd.data.description, description: cmd.data.description,
category: cmd.category ?? 'Other', category: cmd.category ?? 'Other',
@@ -26,7 +26,7 @@ module.exports = {
.map((category) => ({ .map((category) => ({
name: category, name: category,
value: grouped[category] value: grouped[category]
.map(command => `• **/${command.name}** — ${command.description}`) .map((command) => `- **/${command.name}**: ${command.description}`)
.join('\n'), .join('\n'),
})); }));
+11
View File
@@ -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
}
+52
View File
@@ -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
View File
@@ -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({
+7 -2
View File
@@ -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
View File
@@ -1,15 +1,43 @@
const path = require('node:path'); const path = require('node:path');
const { REST, Routes } = require('discord.js'); const { REST, Routes } = require('discord.js');
const { clientId, token } = require('./config.json'); const { getConfigValue } = require('./config');
const { loadCommandModules } = require('./loaders/commands'); const { loadCommandModules } = require('./loaders/commands');
const commandsPath = path.join(__dirname, 'commands'); const commandsPath = path.join(__dirname, 'commands');
const { commands, invalidCommands } = loadCommandModules(commandsPath); const {
commands,
duplicateCommands,
invalidCommands,
loadErrors,
} = loadCommandModules(commandsPath);
const commandsData = commands.map((command) => command.data.toJSON()); const commandsData = commands.map((command) => command.data.toJSON());
const clientId = getConfigValue('DISCORD_CLIENT_ID', 'discordClientId', 'clientId', 'appId');
const token = getConfigValue('DISCORD_BOT_TOKEN', 'discordBotToken', 'token');
for (const filePath of invalidCommands) { for (const filePath of invalidCommands) {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
} }
for (const { commandName, filePath, existingFilePath } of duplicateCommands) {
console.error(
`[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`,
);
}
for (const { filePath, error } of loadErrors) {
console.error(`[ERROR] Failed to load command at ${filePath}:`, error);
}
if (!clientId || !token) {
console.error('Missing Discord client ID or bot token. Set env vars or config.json values.');
process.exit(1);
}
if (duplicateCommands.length > 0 || loadErrors.length > 0) {
console.error('Command deployment aborted because one or more command modules failed validation.');
process.exit(1);
}
const rest = new REST().setToken(token); const rest = new REST().setToken(token);
(async () => { (async () => {
+49 -3
View File
@@ -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
View File
@@ -4,38 +4,74 @@ const path = require('node:path');
const isJavaScriptFile = (file) => file.endsWith('.js'); const isJavaScriptFile = (file) => file.endsWith('.js');
const getCommandFilePaths = (commandsPath) => { const getCommandFilePaths = (commandsPath) => {
const commandFolders = fs const commandFilePaths = [];
.readdirSync(commandsPath, { withFileTypes: true }) const entries = fs.readdirSync(commandsPath, { withFileTypes: true });
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
return commandFolders.flatMap((folder) => { for (const entry of entries) {
const folderPath = path.join(commandsPath, folder); const entryPath = path.join(commandsPath, entry.name);
return fs if (entry.isDirectory()) {
.readdirSync(folderPath) commandFilePaths.push(...getCommandFilePaths(entryPath));
.filter(isJavaScriptFile) continue;
.map((file) => path.join(folderPath, file)); }
}); if (entry.isFile() && isJavaScriptFile(entry.name)) {
commandFilePaths.push(entryPath);
}
}
return commandFilePaths.sort((left, right) => left.localeCompare(right));
}; };
const loadCommandModules = (commandsPath) => { const loadCommandModules = (commandsPath) => {
const commandFiles = getCommandFilePaths(commandsPath); const commandFiles = getCommandFilePaths(commandsPath);
const commands = []; const commands = [];
const invalidCommands = []; const invalidCommands = [];
const duplicateCommands = [];
const loadErrors = [];
const commandNames = new Map();
for (const filePath of commandFiles) { for (const filePath of commandFiles) {
const command = require(filePath); let command;
try {
delete require.cache[require.resolve(filePath)];
command = require(filePath);
} catch (error) {
loadErrors.push({ filePath, error });
continue;
}
if ('data' in command && 'execute' in command) { const commandName = command?.data?.name;
commands.push(command); const isValidCommand =
} else { typeof commandName === 'string' &&
commandName.length > 0 &&
typeof command.execute === 'function';
if (!isValidCommand) {
invalidCommands.push(filePath); invalidCommands.push(filePath);
} continue;
} }
return { commands, invalidCommands }; if (commandNames.has(commandName)) {
duplicateCommands.push({
commandName,
filePath,
existingFilePath: commandNames.get(commandName),
});
continue;
}
commandNames.set(commandName, filePath);
commands.push(command);
}
return {
commands,
duplicateCommands,
invalidCommands,
loadErrors,
};
}; };
module.exports = { module.exports = {
getCommandFilePaths,
loadCommandModules, loadCommandModules,
}; };
+29 -4
View File
@@ -7,16 +7,41 @@ const getEventFilePaths = (eventsPath) =>
fs fs
.readdirSync(eventsPath) .readdirSync(eventsPath)
.filter(isJavaScriptFile) .filter(isJavaScriptFile)
.sort((left, right) => left.localeCompare(right))
.map((file) => path.join(eventsPath, file)); .map((file) => path.join(eventsPath, file));
const loadEventModules = (eventsPath) => { const loadEventModules = (eventsPath) => {
const eventFiles = getEventFilePaths(eventsPath); const eventFiles = getEventFilePaths(eventsPath);
return eventFiles.map((filePath) => ({ const events = [];
filePath, const invalidEvents = [];
event: require(filePath), const loadErrors = [];
}));
for (const filePath of eventFiles) {
let event;
try {
delete require.cache[require.resolve(filePath)];
event = require(filePath);
} catch (error) {
loadErrors.push({ filePath, error });
continue;
}
if (typeof event?.name !== 'string' || typeof event?.execute !== 'function') {
invalidEvents.push(filePath);
continue;
}
events.push({ filePath, event });
}
return {
events,
invalidEvents,
loadErrors,
};
}; };
module.exports = { module.exports = {
getEventFilePaths,
loadEventModules, loadEventModules,
}; };
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "deploy:commands": "node deploy-commands.js",
"test": "node scripts/verify.js && node --test scripts/ban-role.test.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
+20 -1
View File
@@ -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
View File
@@ -88,7 +88,7 @@
</div> </div>
<div class="callout" id="botInactiveCallout" hidden> <div class="callout" id="botInactiveCallout" hidden>
<strong>Bot not added yet.</strong> <strong>Bot not added yet.</strong>
<p class="muted">The bot isn’t in this server yet. Invite it to configure settings.</p> <p class="muted">The bot is not in this server yet. Invite it to configure settings.</p>
<p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p> <p class="muted">You can review saved settings, but changes will stay inactive until the bot joins.</p>
</div> </div>
<div class="field"> <div class="field">
@@ -115,13 +115,53 @@
<div class="panel__header panel__header--split"> <div class="panel__header panel__header--split">
<div> <div>
<h2>Command permissions</h2> <h2>Command permissions</h2>
<p class="muted">Restrict commands to roles or specific users.</p> <p class="muted">Review and edit restrictions faster with search, filters, and live summaries.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button> <button class="button button--ghost" id="clearAllCommands">Clear all restrictions</button>
<button class="button" id="copyFromGuild">Copy from another guild</button> <button class="button" id="copyFromGuild">Copy from another guild</button>
</div> </div>
</div> </div>
<div class="stats-grid">
<article class="stat-card">
<span class="stat-card__label">Commands</span>
<strong class="stat-card__value" id="permissionTotalCount">0</strong>
<span class="muted">Available moderation commands</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Restricted</span>
<strong class="stat-card__value" id="permissionRestrictedCount">0</strong>
<span class="muted">Commands limited to specific roles or users</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Open</span>
<strong class="stat-card__value" id="permissionOpenCount">0</strong>
<span class="muted">Commands currently available to everyone</span>
</article>
<article class="stat-card">
<span class="stat-card__label">Unsaved edits</span>
<strong class="stat-card__value" id="permissionChangedCount">0</strong>
<span class="muted">Cards changed since the last save</span>
</article>
</div>
<div class="toolbar">
<div class="field toolbar__field">
<label for="commandSearch">Find command</label>
<input type="search" id="commandSearch" placeholder="Search by command name" />
</div>
<div class="field toolbar__field">
<label for="commandFilter">Show</label>
<select id="commandFilter">
<option value="all">All commands</option>
<option value="restricted">Restricted only</option>
<option value="open">Open only</option>
<option value="changed">Changed only</option>
</select>
</div>
</div>
<div class="field"> <div class="field">
<span> <span>
Add command Add command
@@ -132,48 +172,78 @@
<button class="button" id="addCommandButton">Add</button> <button class="button" id="addCommandButton">Add</button>
</div> </div>
</div> </div>
<div class="command-list" id="commandsList"></div> <div class="command-list" id="commandsList"></div>
<div class="empty-state" id="commandsEmpty" hidden> <div class="empty-state" id="commandsEmpty" hidden>
No commands configured yet. Add one or invite the bot to sync commands. No commands match the current filter. Try clearing the search or add a custom command override.
</div> </div>
</section> </section>
<section class="panel" id="warningsPanel" hidden> <section class="panel" id="warningsPanel" hidden>
<div class="warnings-layout"> <div class="warnings-layout">
<div class="warnings-pane"> <div class="warnings-pane">
<div class="panel__header">
<div>
<h2>User lookup</h2> <h2>User lookup</h2>
<p class="muted">Search members or paste an ID.</p> <p class="muted">Search members or paste an ID, then manage all warnings from one target card.</p>
</div>
</div>
<div class="field"> <div class="field">
<label for="warningsUserSearch">Find member</label> <label for="warningsUserSearch">Find member</label>
<input type="search" id="warningsUserSearch" placeholder="Type a username" /> <input type="search" id="warningsUserSearch" placeholder="Type at least 2 characters" />
<span class="muted" id="warningsSearchStatus">Start typing to search this guild.</span>
</div> </div>
<div class="member-list" id="warningsUserResults"></div> <div class="member-list" id="warningsUserResults"></div>
<p class="muted" id="warningsSelectedUser">No user selected.</p>
<div class="field"> <div class="field">
<label for="warningsUserInput">User ID</label> <label for="warningsUserInput">Manual user ID</label>
<input type="text" id="warningsUserInput" placeholder="123456789" /> <input type="text" id="warningsUserInput" placeholder="123456789" />
</div> </div>
<div class="selected-user-card" id="warningsSelectedCard">
<span class="selected-user-card__label">Current target</span>
<strong id="warningsSelectedUser">No user selected.</strong>
<span class="muted" id="warningsSelectedHint">Pick a member from search results or paste a Discord user ID.</span>
</div>
<div class="button-row"> <div class="button-row">
<button class="button" id="fetchWarningsButton">Fetch warnings</button> <button class="button" id="fetchWarningsButton">Load history</button>
<button class="button button--ghost" id="clearWarningsButton">Clear warnings</button> <button class="button button--ghost" id="clearWarningsButton">Clear warnings</button>
</div> </div>
<div class="spinner" id="warningsSpinner" hidden></div> <div class="spinner" id="warningsSpinner" hidden></div>
</div> </div>
<div class="warnings-pane"> <div class="warnings-pane">
<div class="panel__header panel__header--split">
<div>
<h2>Warnings</h2> <h2>Warnings</h2>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found.</div> <p class="muted">See warning history and add new entries for the selected target.</p>
</div>
<div class="warning-status-pill" id="warningsHistoryState">No history loaded</div>
</div>
<div class="stats-grid stats-grid--compact">
<article class="stat-card stat-card--compact">
<span class="stat-card__label">Total warnings</span>
<strong class="stat-card__value" id="warningsTotalCount">0</strong>
</article>
<article class="stat-card stat-card--compact">
<span class="stat-card__label">Latest warning</span>
<strong class="stat-card__value stat-card__value--small" id="warningsLatestDate">None</strong>
</article>
</div>
<div class="empty-state" id="warningsEmpty" hidden>No warnings found for this user.</div>
<ul id="warningsList"></ul> <ul id="warningsList"></ul>
<div class="divider"></div> <div class="divider"></div>
<h3>Add warning</h3> <h3>Add warning</h3>
<div class="field"> <p class="muted">The warning will be added to the currently selected target.</p>
<label for="newWarningUserInput">User ID</label>
<input type="text" id="newWarningUserInput" placeholder="123456789" />
</div>
<div class="field"> <div class="field">
<label for="warningReasonInput">Reason</label> <label for="warningReasonInput">Reason</label>
<textarea id="warningReasonInput" placeholder="Reason for warning" maxlength="200"></textarea> <textarea
id="warningReasonInput"
placeholder="Describe what happened and why the warning is being issued."
maxlength="500"
></textarea>
<div class="field__meta"> <div class="field__meta">
<span class="muted" id="warningCharCount">0/200</span> <span class="muted" id="warningCharCount">0/500</span>
<span class="muted" id="warningStatus"></span> <span class="muted" id="warningStatus"></span>
</div> </div>
</div> </div>
+8 -1
View File
@@ -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 = '/';
}); });
+129
View File
@@ -552,6 +552,61 @@ textarea:focus-visible,
flex-wrap: wrap; flex-wrap: wrap;
} }
.toolbar {
display: grid;
grid-template-columns: minmax(240px, 1fr) 220px;
gap: 14px;
margin-bottom: 18px;
}
.toolbar__field {
margin-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.stats-grid--compact {
margin-top: 4px;
margin-bottom: 16px;
}
.stat-card {
padding: 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(21, 31, 39, 0.96), rgba(17, 26, 34, 0.9));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.stat-card--compact {
padding: 12px;
}
.stat-card__label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.stat-card__value {
display: block;
font-size: 1.8rem;
line-height: 1.1;
margin-bottom: 8px;
}
.stat-card__value--small {
font-size: 1rem;
}
.command-list { .command-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -578,6 +633,46 @@ textarea:focus-visible,
gap: 12px; gap: 12px;
} }
.command-card__title {
display: flex;
flex-direction: column;
gap: 6px;
}
.command-card__summary {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mini-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.76rem;
background: rgba(11, 17, 19, 0.25);
}
.mini-badge--accent {
border-color: rgba(44, 185, 198, 0.45);
color: #ccf7fb;
}
.mini-badge--warn {
border-color: rgba(244, 184, 96, 0.45);
color: #ffe8b1;
}
.mini-badge--ok {
border-color: rgba(51, 209, 122, 0.45);
color: #d6f5de;
}
.command-card__body { .command-card__body {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -673,6 +768,7 @@ textarea:focus-visible,
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 10px; padding: 10px;
margin-bottom: 10px; margin-bottom: 10px;
list-style: none;
} }
.warning-item__meta { .warning-item__meta {
@@ -697,6 +793,35 @@ textarea:focus-visible,
padding: 14px; padding: 14px;
} }
.selected-user-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(44, 185, 198, 0.25);
background: linear-gradient(180deg, rgba(44, 185, 198, 0.08), rgba(17, 26, 34, 0.9));
margin-bottom: 16px;
}
.selected-user-card__label {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.warning-status-pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: rgba(17, 26, 34, 0.8);
font-size: 0.8rem;
}
.divider { .divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
@@ -869,4 +994,8 @@ strong {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.toolbar {
grid-template-columns: 1fr;
}
} }
+264
View File
@@ -0,0 +1,264 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { Collection, MessageFlags, PermissionFlagsBits } = require('discord.js');
const records = require('../services/moderationRecords');
const permissions = require('../services/permissions');
const makeMember = (id, { position = 1, hasRole = true, bannable = true, canBan = true } = {}) => ({
id,
bannable,
permissions: { has: permission => permission === PermissionFlagsBits.BanMembers && canBan },
roles: {
cache: new Collection(hasRole ? [['role', {}]] : []),
highest: { position, comparePositionTo(other) { return this.position - other.position; } },
},
});
const createHarness = (t, options = {}) => {
const members = options.members || [makeMember('target')];
const moderator = makeMember('moderator', { position: 10, canBan: options.moderatorCanBan !== false });
const bot = makeMember('bot', { position: 20, canBan: options.botCanBan !== false });
const savedRecords = [];
const bans = [];
const banAttempts = [];
const edits = [];
const replies = [];
const listCalls = [];
const fetchCalls = [];
let filter;
t.mock.method(console, 'error', () => {});
t.mock.method(records, 'recordModerationAction', async (record) => {
savedRecords.push(record);
if (options.logFailure) throw new Error('Database unavailable');
});
t.mock.method(permissions, 'isCommandAllowed', async () => options.allowed !== false);
delete require.cache[require.resolve('../commands/moderation/ban_role')];
const command = require('../commands/moderation/ban_role');
const confirmation = {
customId: `ban-role:${options.choice || 'confirm'}:interaction`,
update: async (payload) => {
edits.push(payload);
if (options.onConfirm) options.onConfirm({ members, moderator, bot });
},
editReply: async (payload) => { edits.push(payload); },
};
const message = {
awaitMessageComponent: async (collectorOptions) => {
assert.equal(bans.length, 0, 'Bans must wait for confirmation');
assert.equal(collectorOptions.time, 60_000);
filter = collectorOptions.filter;
if (options.onWait) await options.onWait({ command, interaction });
if (options.timeout) throw new Error('Collector timed out');
return confirmation;
},
};
const guild = {
id: 'guild',
ownerId: options.ownerId || 'owner',
roles: { fetch: async () => {} },
members: {
fetchMe: async () => bot,
fetch: async (fetchOptions) => {
assert.equal(fetchOptions.force, true);
fetchCalls.push(fetchOptions.user);
if (fetchOptions.user === moderator.id) return moderator;
const failure = options.fetchFailures?.[fetchOptions.user];
if (failure) throw failure;
return members.find(member => member.id === fetchOptions.user);
},
list: async (listOptions) => {
listCalls.push(listOptions);
if (options.listFailureAt === listCalls.length) throw new Error('Listing failed');
const page = options.pages ? options.pages[listCalls.length - 1] : members;
return new Collection(page.map(member => [member.id, member]));
},
},
bans: {
create: async (id, banOptions) => {
banAttempts.push(id);
if (options.banFailures?.includes(id)) throw new Error('Ban rejected');
bans.push({ id, ...banOptions });
if (options.onBan) options.onBan({ id, moderator, bot });
},
},
};
const interaction = {
id: 'interaction',
guild: options.dm ? null : guild,
guildId: guild.id,
user: { id: moderator.id },
client: { user: { id: bot.id } },
options: {
getRole: () => ({ id: options.roleId || 'role' }),
getString: () => options.reason ?? 'Raid accounts',
},
reply: async (payload) => { replies.push(payload); },
deferReply: async (payload) => { assert.equal(payload.flags, MessageFlags.Ephemeral); },
editReply: async (payload) => { edits.push(payload); return message; },
};
return {
command, interaction, bans, banAttempts, savedRecords, edits, replies, listCalls, fetchCalls,
run: () => command.execute(interaction),
get filter() { return filter; },
get result() { return edits.at(-1)?.content || replies.at(-1)?.content; },
};
};
test('command registration requires a role, reason, and Ban Members permission', (t) => {
const { command } = createHarness(t);
const data = command.data.toJSON();
assert.equal(data.name, 'ban-role');
assert.equal(data.dm_permission, false);
assert.equal(data.default_member_permissions, PermissionFlagsBits.BanMembers.toString());
assert.deepEqual(data.options.map(option => [option.name, option.required]), [['role', true], ['reason', true]]);
});
for (const [description, options, expected] of [
['direct messages', { dm: true }, /inside a server/],
['everyone', { roleId: 'guild' }, /@everyone/],
['blank reasons', { reason: ' ' }, /provide a reason/],
['moderators without Ban Members', { moderatorCanBan: false }, /You need the Ban Members/],
['bots without Ban Members', { botCanBan: false }, /I need the Ban Members/],
]) {
test(`rejects ${description} without listing or banning members`, async (t) => {
const harness = createHarness(t, options);
await harness.run();
assert.match(harness.result, expected);
assert.equal(harness.listCalls.length, 0);
assert.equal(harness.bans.length, 0);
});
}
test('bans only eligible role members and records successful bans', async (t) => {
const harness = createHarness(t, { members: [
makeMember('eligible'),
makeMember('other-role', { hasRole: false }),
makeMember('moderator'),
makeMember('bot'),
makeMember('owner'),
makeMember('peer', { position: 10 }),
makeMember('higher', { position: 11 }),
makeMember('unbannable', { bannable: false }),
] });
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['eligible']);
assert.equal(harness.bans[0].deleteMessageSeconds, 0);
assert.match(harness.bans[0].reason, /moderator: Raid accounts/);
assert.deepEqual(harness.savedRecords[0], {
guildId: 'guild', userId: 'eligible', action: 'ban', moderatorId: 'moderator', reason: 'Raid accounts',
metadata: { command: 'ban-role', roleId: 'role' },
stateUpdates: { isBanned: true, bannedUntil: null },
});
assert.match(harness.result, /Banned: 1\. Skipped: 6\. Failed: 0/);
assert.equal(harness.filter({ user: { id: 'stranger' }, customId: 'ban-role:confirm:interaction' }), false);
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'different-action' }), false);
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'ban-role:confirm:interaction' }), true);
});
test('server owner bypasses only the moderator hierarchy', async (t) => {
const harness = createHarness(t, { ownerId: 'moderator', members: [
makeMember('above-owner', { position: 11 }),
makeMember('above-bot', { position: 21, bannable: false }),
] });
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['above-owner']);
});
for (const [description, options, expected] of [
['cancellation', { choice: 'cancel' }, /Cancelled/],
['timeout', { timeout: true }, /expired/],
['empty roles', { members: [] }, /No eligible members/],
['protected members only', { members: [makeMember('owner')] }, /Skipped: 1/],
['revoked dashboard permission', { allowed: false }, /no longer have permission/],
['revoked Discord permission', { onConfirm: ({ moderator }) => { moderator.permissions.has = () => false; } }, /You need the Ban Members/],
]) {
test(`${description} produces no bans`, async (t) => {
const harness = createHarness(t, options);
await harness.run();
assert.equal(harness.bans.length, 0);
assert.equal(harness.savedRecords.length, 0);
assert.match(harness.result, expected);
assert.equal(harness.edits.at(-1)?.components?.length || 0, 0);
});
}
test('discovers matching members beyond the first 1000 members', async (t) => {
const firstPage = Array.from({ length: 1000 }, (_, id) => makeMember(String(id), { hasRole: false }));
const target = makeMember('1000');
const harness = createHarness(t, { members: [target], pages: [firstPage, [target]] });
await harness.run();
assert.deepEqual(harness.listCalls, [
{ limit: 1000, after: undefined, cache: false },
{ limit: 1000, after: '999', cache: false },
]);
assert.deepEqual(harness.bans.map(ban => ban.id), ['1000']);
});
test('incomplete member discovery never offers confirmation or bans a partial list', async (t) => {
const page = Array.from({ length: 1000 }, (_, id) => makeMember(String(id)));
const harness = createHarness(t, { pages: [page], listFailureAt: 2 });
await harness.run();
assert.equal(harness.bans.length, 0);
assert.equal(harness.filter, undefined);
assert.match(harness.result, /Could not load all server members/);
});
test('rechecks role membership and hierarchy and excludes members added after the preview', async (t) => {
const harness = createHarness(t, {
members: [makeMember('removed'), makeMember('promoted'), makeMember('left'), makeMember('remaining')],
fetchFailures: { left: { code: 10007 } },
onConfirm: ({ members }) => {
members[0].roles.cache.clear();
members[1].roles.highest.position = 15;
members.push(makeMember('new'));
},
});
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['remaining']);
assert.match(harness.result, /Banned: 1\. Skipped: 3\. Failed: 0/);
});
test('continues after fetch, ban, and record failures while reporting correct totals', async (t) => {
const harness = createHarness(t, {
members: [makeMember('unavailable'), makeMember('denied'), makeMember('success'), makeMember('success2')],
fetchFailures: { unavailable: new Error('Network failed') },
banFailures: ['denied'],
logFailure: true,
});
await harness.run();
assert.deepEqual(harness.banAttempts, ['denied', 'success', 'success2']);
assert.deepEqual(harness.savedRecords.map(record => record.userId), ['success', 'success2']);
assert.match(harness.result, /Banned: 2\. Skipped: 0\. Failed: 2/);
assert.match(harness.result, /could not be saved for 2 successful bans/);
});
test('stops and preserves partial results if moderator permissions change mid-batch', async (t) => {
const harness = createHarness(t, {
members: [makeMember('first'), makeMember('second')],
onBan: ({ moderator }) => { moderator.permissions.has = () => false; },
});
await harness.run();
assert.deepEqual(harness.bans.map(ban => ban.id), ['first']);
assert.match(harness.result, /Role ban stopped/);
assert.match(harness.result, /Banned: 1/);
});
test('prevents overlapping role bans and releases the server after cancellation', async (t) => {
let overlapChecks = 0;
const harness = createHarness(t, {
choice: 'cancel',
onWait: async ({ command, interaction }) => {
overlapChecks++;
await command.execute({
...interaction,
reply: async (payload) => assert.match(payload.content, /already being previewed or processed/),
});
},
});
await harness.run();
await harness.run();
assert.equal(overlapChecks, 2);
assert.equal(harness.bans.length, 0);
});
+55
View File
@@ -0,0 +1,55 @@
const path = require('node:path');
const { loadCommandModules } = require('../loaders/commands');
const { loadEventModules } = require('../loaders/events');
const { createDashboardApp } = require('../dashboardServer');
const commandsPath = path.join(__dirname, '..', 'commands');
const eventsPath = path.join(__dirname, '..', 'events');
const commandResult = loadCommandModules(commandsPath);
const eventResult = loadEventModules(eventsPath);
let hasFailures = false;
const printFailures = (heading, values) => {
if (!values || values.length === 0) {
return;
}
hasFailures = true;
console.error(heading);
for (const value of values) {
console.error(`- ${value}`);
}
};
printFailures('Invalid command modules:', commandResult.invalidCommands);
printFailures(
'Duplicate command names:',
commandResult.duplicateCommands.map(
({ commandName, filePath, existingFilePath }) =>
`${commandName}: ${filePath} conflicts with ${existingFilePath}`,
),
);
printFailures(
'Command load errors:',
commandResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
);
printFailures('Invalid event modules:', eventResult.invalidEvents);
printFailures(
'Event load errors:',
eventResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
);
if (typeof createDashboardApp !== 'function') {
hasFailures = true;
console.error('Dashboard app factory is not exported correctly.');
}
if (!hasFailures) {
console.log(
`Verification passed: ${commandResult.commands.length} commands and ${eventResult.events.length} events loaded successfully.`,
);
}
process.exit(hasFailures ? 1 : 0);