implement changes to commands and templates

This commit is contained in:
2026-04-08 14:20:54 +02:00
parent e90b8b4d85
commit 65a281dbcb
15 changed files with 326 additions and 59 deletions
+12
View File
@@ -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`
+6
View File
@@ -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 });
+7 -8
View File
@@ -40,22 +40,21 @@ module.exports = {
}
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 senderBalance = senderData?.balance ?? 0;
if (senderBalance < amount) {
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 } },
+6
View File
@@ -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';
+6
View File
@@ -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';
+3
View File
@@ -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();
+28 -8
View File
@@ -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) {
+8
View File
@@ -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,
+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,
};
+135 -36
View File
@@ -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({
+8 -3
View File
@@ -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,
+15 -1
View File
@@ -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);
+20 -1
View File
@@ -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,
+8 -1
View File
@@ -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 = '/';
});