From 65a281dbcbc134a2b49ae43be2bec861c4b26a3a Mon Sep 17 00:00:00 2001 From: fretuks Date: Wed, 8 Apr 2026 14:20:54 +0200 Subject: [PATCH 1/2] implement changes to commands and templates --- README.md | 12 +++ commands/db/test.js | 6 ++ commands/db/transfer.js | 17 ++-- commands/moderation/kick.js | 6 ++ commands/moderation/mute.js | 6 ++ commands/moderation/role.js | 3 + commands/moderation/temp_ban.js | 36 +++++-- commands/moderation/warn.js | 8 ++ config.example.json | 11 ++ config.js | 52 ++++++++++ dashboardServer.js | 171 +++++++++++++++++++++++++------- db.js | 11 +- index.js | 16 ++- public/app.js | 21 +++- public/dashboard.js | 9 +- 15 files changed, 326 insertions(+), 59 deletions(-) create mode 100644 config.example.json create mode 100644 config.js diff --git a/README.md b/README.md index fbaebc5..b556b8a 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,15 @@ 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` diff --git a/commands/db/test.js b/commands/db/test.js index ad584a2..896132d 100644 --- a/commands/db/test.js +++ b/commands/db/test.js @@ -14,6 +14,12 @@ module.exports = { async execute(interaction) { const dbClient = await connectToDatabase(); 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 collection = db.collection('test'); await collection.insertOne({ userId: interaction.user.id, message: message }); diff --git a/commands/db/transfer.js b/commands/db/transfer.js index dab7bdf..db5c1a6 100644 --- a/commands/db/transfer.js +++ b/commands/db/transfer.js @@ -40,22 +40,21 @@ module.exports = { } const senderId = interaction.user.id; - const senderData = await collection.findOne({ userID: senderId }); - const senderBalance = senderData?.balance ?? 0; + const debitResult = await collection.findOneAndUpdate( + { userID: senderId, balance: { $gte: amount } }, + { $inc: { balance: -amount } }, + { returnDocument: 'after' } + ); - if (senderBalance < amount) { + if (!debitResult.value) { + const senderData = await collection.findOne({ userID: senderId }); + const senderBalance = senderData?.balance ?? 0; return interaction.reply({ content: `You only have **${senderBalance}** coins available.`, ephemeral: true, }); } - await collection.updateOne( - { userID: senderId }, - { $inc: { balance: -amount } }, - { upsert: true } - ); - await collection.updateOne( { userID: target.id }, { $inc: { balance: amount } }, diff --git a/commands/moderation/kick.js b/commands/moderation/kick.js index 59fd524..71117a6 100644 --- a/commands/moderation/kick.js +++ b/commands/moderation/kick.js @@ -18,6 +18,12 @@ module.exports = { .setDescription('The reason of the kick')), category: 'Moderation', 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 reason = interaction.options.getString('reason') || 'No reason provided'; diff --git a/commands/moderation/mute.js b/commands/moderation/mute.js index 85ac8db..37f1f58 100644 --- a/commands/moderation/mute.js +++ b/commands/moderation/mute.js @@ -25,6 +25,12 @@ module.exports = { .setDescription('The reason of the mute')), category: 'Moderation', 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 duration = interaction.options.getInteger('duration'); const reason = interaction.options.getString('reason') || 'No reason provided'; diff --git a/commands/moderation/role.js b/commands/moderation/role.js index f1fbbf2..c42b7b6 100644 --- a/commands/moderation/role.js +++ b/commands/moderation/role.js @@ -48,6 +48,9 @@ module.exports = { .setRequired(true))), category: 'Moderation', 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 role = interaction.options.getRole('role'); const action = interaction.options.getSubcommand(); diff --git a/commands/moderation/temp_ban.js b/commands/moderation/temp_ban.js index fe97802..cdb58a6 100644 --- a/commands/moderation/temp_ban.js +++ b/commands/moderation/temp_ban.js @@ -18,6 +18,8 @@ module.exports = { option .setName('durationms') .setDescription('Duration of the temporary ban (in milliseconds).') + .setMinValue(60_000) + .setMaxValue(2_592_000_000) .setRequired(true)) .addStringOption(option => option @@ -31,23 +33,41 @@ module.exports = { .setMinValue(0).setMaxValue(7)), category: 'Moderation', 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 reason = interaction.options.getString('reason'); const durationms = interaction.options.getInteger('durationms'); const deleteMessages = interaction.options.getInteger('delete-messages'); - // If you want to parse time like "1d", "10m", etc., use ms package: - let banUntil; - try { - banUntil = Date.now() + durationms; - } catch (e) { - // If parsing fails, handle error + if (user.bot) { return interaction.reply({ - content: 'Invalid duration format. Please use something like "10m" or "1d".', - ephemeral: true + content: 'You cannot temp-ban a bot account.', + 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 async function sendEphemeral(message) { diff --git a/commands/moderation/warn.js b/commands/moderation/warn.js index 79163b8..1cb3448 100644 --- a/commands/moderation/warn.js +++ b/commands/moderation/warn.js @@ -4,6 +4,8 @@ const { getGuildSettings } = require('../../services/guildSettings'); const { recordModerationAction } = require('../../services/moderationRecords'); const { connectToDatabase } = require('../../db'); +const MAX_REASON_LENGTH = 500; + module.exports = { permissionGroup: 'moderation', data: new SlashCommandBuilder() @@ -31,6 +33,12 @@ module.exports = { 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({ guildId: interaction.guildId, diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..fe91f31 --- /dev/null +++ b/config.example.json @@ -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 +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..84f59ae --- /dev/null +++ b/config.js @@ -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, +}; diff --git a/dashboardServer.js b/dashboardServer.js index 4895501..710bed4 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -5,39 +5,40 @@ const axios = require('axios'); const express = require('express'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); const {getGuildPermissions, updateGuildPermissions} = require('./services/permissions'); +const { CONFIG_PATH, getConfig, getConfigValue } = require('./config'); 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 = { ADMINISTRATOR: 0x8n, MANAGE_GUILD: 0x20n, }; -const loadConfig = () => { - const configPath = path.join(__dirname, 'config.json'); - if (fs.existsSync(configPath)) { - return require(configPath); - } - return {}; -}; +const config = getConfig(); -const config = loadConfig(); - -const DISCORD_CLIENT_ID = - process.env.DISCORD_CLIENT_ID || - config.discordClientId || - config.clientId || - config.appId; -const DISCORD_CLIENT_SECRET = - process.env.DISCORD_CLIENT_SECRET || - config.discordClientSecret || - config.clientSecret; -const DISCORD_REDIRECT_URI = - process.env.DISCORD_REDIRECT_URI || - config.discordRedirectUri || - config.redirectUri; -const DISCORD_BOT_TOKEN = - process.env.DISCORD_BOT_TOKEN || - config.discordBotToken || - config.token; +const DISCORD_CLIENT_ID = getConfigValue( + 'DISCORD_CLIENT_ID', + 'discordClientId', + 'clientId', + 'appId', +); +const DISCORD_CLIENT_SECRET = getConfigValue( + 'DISCORD_CLIENT_SECRET', + 'discordClientSecret', + 'clientSecret', +); +const DISCORD_REDIRECT_URI = getConfigValue( + 'DISCORD_REDIRECT_URI', + 'discordRedirectUri', + 'redirectUri', +); +const DISCORD_BOT_TOKEN = getConfigValue( + 'DISCORD_BOT_TOKEN', + 'discordBotToken', + 'token', +); const DASHBOARD_INVITE_PERMISSIONS = Number.parseInt( process.env.DASHBOARD_INVITE_PERMISSIONS || config.dashboardInvitePermissions || '0', 10, @@ -50,13 +51,17 @@ const SESSION_TTL_MS = Number.parseInt( process.env.DASHBOARD_SESSION_TTL_MS || config.dashboardSessionTtlMs, 10, ); +const TRUST_PROXY = + process.env.DASHBOARD_TRUST_PROXY === 'true' || + process.env.DASHBOARD_TRUST_PROXY === '1' || + Boolean(config.trustProxy); console.log('[oauth config]', { 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_REDIRECT_URI: DISCORD_REDIRECT_URI || null, - configFilePath: path.join(__dirname, 'config.json'), - configFileExists: fs.existsSync(path.join(__dirname, 'config.json')), + configFilePath: CONFIG_PATH, + configFileExists: fs.existsSync(CONFIG_PATH), }); const DISCORD_API_BASE = 'https://discord.com/api'; @@ -72,6 +77,61 @@ const botGuildCache = { 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 cookies = {}; if (!headerValue) { @@ -115,13 +175,15 @@ const clearCookie = (res, name) => { const createSession = (payload) => { const sessionId = crypto.randomBytes(32).toString('hex'); 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, { ...payload, expiresAt, + csrfToken, }); - return {sessionId, expiresAt}; + return {sessionId, expiresAt, csrfToken}; }; const getSession = (sessionId) => { @@ -409,10 +471,34 @@ const ensureManageableGuild = async (req, res, next) => { const createDashboardApp = () => { 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(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()) { return res.status(500).json({error: 'Missing Discord OAuth configuration'}); } @@ -428,7 +514,7 @@ const createDashboardApp = () => { return res.redirect(buildAuthorizationUrl(state)); }); - app.get('/auth/discord/callback', async (req, res) => { + app.get('/auth/discord/callback', authLimiter, async (req, res) => { if (!ensureOAuthConfig()) { return res.status(500).json({error: 'Missing Discord OAuth configuration'}); } @@ -467,7 +553,7 @@ const createDashboardApp = () => { return res.redirect('/dashboard.html'); } - return res.status(200).json({user}); + return res.status(200).json({user, csrfToken: session.csrfToken}); } catch (error) { console.error('Discord OAuth failed', { message: error.message, @@ -482,7 +568,7 @@ const createDashboardApp = () => { } }); - app.post('/auth/logout', ensureSession, (req, res) => { + app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => { destroySession(req.sessionId); clearCookie(res, 'dashboard_session'); return res.status(204).send(); @@ -492,6 +578,7 @@ const createDashboardApp = () => { return res.json({ user: req.session.user, expiresAt: req.session.expiresAt, + csrfToken: req.session.csrfToken, }); }); @@ -554,8 +641,12 @@ const createDashboardApp = () => { '/api/guilds/:guildId/permissions', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { try { + if (!req.body || typeof req.body !== 'object') { + return res.status(400).json({ error: 'Invalid permissions payload' }); + } const permissions = await updateGuildPermissions({ guildId: req.params.guildId, permissions: req.body || {}, @@ -605,6 +696,9 @@ const createDashboardApp = () => { if (query.length < 2) { return res.json({members: []}); } + if (query.length > MAX_MEMBER_QUERY_LENGTH) { + return res.status(400).json({ error: 'Query too long' }); + } try { const members = await fetchDiscordGuildMembersSearch(req.params.guildId, query); const normalized = (members || []) @@ -679,17 +773,21 @@ const createDashboardApp = () => { '/api/guilds/:guildId/warnings/:userId', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { const {reason} = req.body || {}; - if (!reason) { + if (typeof reason !== 'string' || reason.trim().length === 0) { 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 { const result = await addWarning({ guildId: req.params.guildId, userId: req.params.userId, moderatorId: req.session.user?.id, - reason, + reason: reason.trim(), }); const warnings = (result.warnings || []).map((warning) => ({ ...warning, @@ -718,6 +816,7 @@ const createDashboardApp = () => { '/api/guilds/:guildId/warnings/:userId', ensureSession, ensureManageableGuild, + ensureCsrf, async (req, res) => { try { const warnings = await clearWarnings({ diff --git a/db.js b/db.js index 1ad12b4..6055d36 100644 --- a/db.js +++ b/db.js @@ -1,11 +1,16 @@ -const {MongoClient, ServerApiVersion} = require('mongodb'); -const {mongodb} = require("./config.json"); +const { MongoClient, ServerApiVersion } = require('mongodb'); +const { getConfigValue } = require('./config'); let dbClient; async function connectToDatabase() { 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: { version: ServerApiVersion.v1, strict: true, diff --git a/index.js b/index.js index e5dd771..f818d43 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,16 @@ const path = require('node:path'); const { Client, Collection, GatewayIntentBits } = require('discord.js'); -const { token } = require('./config.json'); +const { getConfigValue } = require('./config'); const { loadCommandModules } = require('./loaders/commands'); 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] }); client.commands = new Collection(); const commandsPath = path.join(__dirname, 'commands'); @@ -26,4 +34,10 @@ for (const { event } of eventModules) { } } +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); diff --git a/public/app.js b/public/app.js index 1c34702..b4083f3 100644 --- a/public/app.js +++ b/public/app.js @@ -14,11 +14,21 @@ const createFetchError = async (response) => { return error; }; +let csrfToken = null; + +const setCsrfToken = (token) => { + csrfToken = typeof token === 'string' ? token : null; +}; + +const getCsrfToken = () => csrfToken; + const fetchJson = async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); const response = await fetch(url, { credentials: 'include', headers: { 'Content-Type': 'application/json', + ...(csrfToken && method !== 'GET' && method !== 'HEAD' ? {'X-CSRF-Token': csrfToken} : {}), ...(options.headers || {}), }, ...options, @@ -110,6 +120,7 @@ const initIndexPage = async () => { try { const data = await fetchJson('/api/me'); + setCsrfToken(data.csrfToken); sessionStatus.textContent = `Signed in as ${formatUserDisplay(data.user)}.`; loginButton.hidden = true; dashboardButton.hidden = false; @@ -131,7 +142,13 @@ const initIndexPage = async () => { logoutButton?.addEventListener('click', async () => { 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.'; loginButton.hidden = false; dashboardButton.hidden = true; @@ -151,6 +168,8 @@ document.addEventListener('DOMContentLoaded', () => { window.dashboardApi = { fetchJson, + setCsrfToken, + getCsrfToken, showToast, setErrorBanner, clearErrorBanner, diff --git a/public/dashboard.js b/public/dashboard.js index c300854..49c4042 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -580,6 +580,7 @@ const updateBotStatusControls = () => { const loadSession = async () => { const data = await fetchDashboardJson('/api/me'); elements.userDisplay.textContent = dashboardApi.formatUserDisplay(data.user); + dashboardApi.setCsrfToken(data.csrfToken); if (data.expiresAt) { const expires = new Date(data.expiresAt); elements.sessionExpiry.textContent = `Session expires ${expires.toLocaleString()}`; @@ -993,7 +994,13 @@ const initDashboard = 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 = '/'; }); From a7f82639317eba0d696c9e0e15f1b754fb581f0a Mon Sep 17 00:00:00 2001 From: fretuks Date: Sun, 27 Sep 2026 01:04:44 +0200 Subject: [PATCH 2/2] Add `/ban-role` moderation command and improve module loading --- README.md | 22 +++ commands/moderation/ban_role.js | 225 +++++++++++++++++++++++++++ commands/utility/help.js | 4 +- dashboardServer.js | 36 +++-- deploy-commands.js | 32 +++- index.js | 36 ++++- loaders/commands.js | 72 ++++++--- loaders/events.js | 33 +++- package.json | 3 +- public/dashboard.html | 104 +++++++++++-- public/styles.css | 129 ++++++++++++++++ scripts/ban-role.test.js | 264 ++++++++++++++++++++++++++++++++ scripts/verify.js | 55 +++++++ 13 files changed, 959 insertions(+), 56 deletions(-) create mode 100644 commands/moderation/ban_role.js create mode 100644 scripts/ban-role.test.js create mode 100644 scripts/verify.js diff --git a/README.md b/README.md index b556b8a..40e9a4e 100644 --- a/README.md +++ b/README.md @@ -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** - [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. - [x] Implement a warning system with a strike counter. @@ -102,3 +103,24 @@ Recommended env vars: - `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. diff --git a/commands/moderation/ban_role.js b/commands/moderation/ban_role.js new file mode 100644 index 0000000..be9ea15 --- /dev/null +++ b/commands/moderation/ban_role.js @@ -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); + } + }, +}; diff --git a/commands/utility/help.js b/commands/utility/help.js index e1b63f1..c4503ea 100644 --- a/commands/utility/help.js +++ b/commands/utility/help.js @@ -7,7 +7,7 @@ module.exports = { category: 'Utility', async execute(interaction) { const commandDetails = interaction.client.commands - .map(cmd => ({ + .map((cmd) => ({ name: cmd.data.name, description: cmd.data.description, category: cmd.category ?? 'Other', @@ -26,7 +26,7 @@ module.exports = { .map((category) => ({ name: category, value: grouped[category] - .map(command => `• **/${command.name}** — ${command.description}`) + .map((command) => `- **/${command.name}**: ${command.description}`) .join('\n'), })); diff --git a/dashboardServer.js b/dashboardServer.js index 710bed4..7021a1e 100644 --- a/dashboardServer.js +++ b/dashboardServer.js @@ -6,6 +6,7 @@ const express = require('express'); const {addWarning, getWarnings, clearWarnings} = require('./services/warnings'); 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_CLEANUP_MS = 60 * 60 * 1000; const MAX_REASON_LENGTH = 500; @@ -66,7 +67,6 @@ console.log('[oauth config]', { const DISCORD_API_BASE = 'https://discord.com/api'; 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 BOT_GUILD_CACHE_TTL_MS = 60 * 1000; const BOT_TOKEN_ERROR_MESSAGE = 'Missing Discord bot token.'; @@ -407,18 +407,34 @@ const isHttpsRequest = (req) => { }; const loadCommands = () => { - if (!fs.existsSync(COMMANDS_PATH)) { - return []; + const commandsPath = path.join(__dirname, 'commands'); + const { commands, duplicateCommands, invalidCommands, loadErrors } = loadCommandModules(commandsPath); + + if (invalidCommands.length > 0) { + console.warn('Skipping invalid commands while building dashboard command list.', { + invalidCommands, + }); } - try { - const raw = fs.readFileSync(COMMANDS_PATH, 'utf-8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : []; - } catch (error) { - console.error('Failed to load commands.json', {message: error.message}); - return []; + if (duplicateCommands.length > 0) { + console.error('Duplicate command definitions detected while building dashboard command list.', { + duplicateCommands, + }); } + + 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) => { diff --git a/deploy-commands.js b/deploy-commands.js index d7edebb..0a8bb8e 100644 --- a/deploy-commands.js +++ b/deploy-commands.js @@ -1,15 +1,43 @@ const path = require('node:path'); const { REST, Routes } = require('discord.js'); -const { clientId, token } = require('./config.json'); +const { getConfigValue } = require('./config'); const { loadCommandModules } = require('./loaders/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 clientId = getConfigValue('DISCORD_CLIENT_ID', 'discordClientId', 'clientId', 'appId'); +const token = getConfigValue('DISCORD_BOT_TOKEN', 'discordBotToken', 'token'); for (const filePath of invalidCommands) { 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); (async () => { diff --git a/index.js b/index.js index f818d43..1c83189 100644 --- a/index.js +++ b/index.js @@ -14,9 +14,18 @@ process.on('uncaughtException', (error) => { const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.commands = new Collection(); 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 eventModules = loadEventModules(eventsPath); +const { + events: eventModules, + invalidEvents, + loadErrors: eventLoadErrors, +} = loadEventModules(eventsPath); for (const command of commands) { client.commands.set(command.data.name, command); @@ -26,6 +35,24 @@ for (const filePath of invalidCommands) { console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); } +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) { if (event.once) { client.once(event.name, (...args) => event.execute(...args)); @@ -34,6 +61,11 @@ for (const { event } of eventModules) { } } +if (duplicateCommands.length > 0 || commandLoadErrors.length > 0 || eventLoadErrors.length > 0) { + console.error('Startup aborted because one or more modules failed validation.'); + process.exit(1); +} + const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken'); if (!token) { console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.'); diff --git a/loaders/commands.js b/loaders/commands.js index 94f0d35..2b51bbb 100644 --- a/loaders/commands.js +++ b/loaders/commands.js @@ -4,38 +4,74 @@ const path = require('node:path'); const isJavaScriptFile = (file) => file.endsWith('.js'); const getCommandFilePaths = (commandsPath) => { - const commandFolders = fs - .readdirSync(commandsPath, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); + const commandFilePaths = []; + const entries = fs.readdirSync(commandsPath, { withFileTypes: true }); - return commandFolders.flatMap((folder) => { - const folderPath = path.join(commandsPath, folder); - return fs - .readdirSync(folderPath) - .filter(isJavaScriptFile) - .map((file) => path.join(folderPath, file)); - }); + for (const entry of entries) { + const entryPath = path.join(commandsPath, entry.name); + if (entry.isDirectory()) { + commandFilePaths.push(...getCommandFilePaths(entryPath)); + continue; + } + if (entry.isFile() && isJavaScriptFile(entry.name)) { + commandFilePaths.push(entryPath); + } + } + + return commandFilePaths.sort((left, right) => left.localeCompare(right)); }; const loadCommandModules = (commandsPath) => { const commandFiles = getCommandFilePaths(commandsPath); const commands = []; const invalidCommands = []; + const duplicateCommands = []; + const loadErrors = []; + const commandNames = new Map(); for (const filePath of commandFiles) { - const command = require(filePath); - - if ('data' in command && 'execute' in command) { - commands.push(command); - } else { - invalidCommands.push(filePath); + let command; + try { + delete require.cache[require.resolve(filePath)]; + command = require(filePath); + } catch (error) { + loadErrors.push({ filePath, error }); + continue; } + + const commandName = command?.data?.name; + const isValidCommand = + typeof commandName === 'string' && + commandName.length > 0 && + typeof command.execute === 'function'; + + if (!isValidCommand) { + invalidCommands.push(filePath); + continue; + } + + if (commandNames.has(commandName)) { + duplicateCommands.push({ + commandName, + filePath, + existingFilePath: commandNames.get(commandName), + }); + continue; + } + + commandNames.set(commandName, filePath); + commands.push(command); } - return { commands, invalidCommands }; + return { + commands, + duplicateCommands, + invalidCommands, + loadErrors, + }; }; module.exports = { + getCommandFilePaths, loadCommandModules, }; diff --git a/loaders/events.js b/loaders/events.js index c91abd9..a55cb85 100644 --- a/loaders/events.js +++ b/loaders/events.js @@ -7,16 +7,41 @@ const getEventFilePaths = (eventsPath) => fs .readdirSync(eventsPath) .filter(isJavaScriptFile) + .sort((left, right) => left.localeCompare(right)) .map((file) => path.join(eventsPath, file)); const loadEventModules = (eventsPath) => { const eventFiles = getEventFilePaths(eventsPath); - return eventFiles.map((filePath) => ({ - filePath, - event: require(filePath), - })); + const events = []; + const invalidEvents = []; + 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 = { + getEventFilePaths, loadEventModules, }; diff --git a/package.json b/package.json index afd5dba..a238e28 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "main": "index.js", "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": [], "author": "", diff --git a/public/dashboard.html b/public/dashboard.html index c6257ea..bbd46c4 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -88,7 +88,7 @@
@@ -115,13 +115,53 @@

Command permissions

-

Restrict commands to roles or specific users.

+

Review and edit restrictions faster with search, filters, and live summaries.

+ +
+
+ Commands + 0 + Available moderation commands +
+
+ Restricted + 0 + Commands limited to specific roles or users +
+
+ Open + 0 + Commands currently available to everyone +
+
+ Unsaved edits + 0 + Cards changed since the last save +
+
+ +
+
+ + +
+
+ + +
+
+
Add command @@ -132,48 +172,78 @@
+