Compare commits

86 Commits

Author SHA1 Message Date
Frederik Spirgi 0a7d056fc8 Update version 2026-09-28 09:38:52 +02:00
Fretux e9d34c28fc Merge pull request #14 from Fretuks/main
Refactor dashboard.js: streamline state management, improve layouts, …
2026-09-27 21:35:37 +02:00
Fretux 1aa83d83f9 Refactor dashboard.js: streamline state management, improve layouts, optimize role and command handlers. 2026-09-27 21:35:05 +02:00
Fretux 709bba63a6 Merge pull request #13 from Fretuks/main
Balls
2026-09-27 21:28:29 +02:00
Fretux a7f8263931 Add /ban-role moderation command and improve module loading 2026-09-27 01:04:44 +02:00
Fretux 65a281dbcb implement changes to commands and templates 2026-04-08 14:20:54 +02:00
Fretux 4c806c787f Merge pull request #12 from Fretuks/main
Implement alot of changes, adding backend and frontend for the bot dashboard.
2026-02-03 11:14:35 +01:00
Fretux e90b8b4d85 Update CSS 2026-02-03 11:13:27 +01:00
Fretux c6204e1941 Fix most current issues (overlay issues, some issues with search functionality, and clear warnings functionality) 2026-02-03 11:05:06 +01:00
Fretux 0bcf0829c8 Merge pull request #20 from Fretuks/fix-the-css
Add landing page styles for dashboard home
2026-02-03 10:06:37 +01:00
Fretux 7c85ab2500 Add landing page styles 2026-02-03 10:05:54 +01:00
Fretux 097434d7be Merge pull request #19 from Fretuks/improve-dashboard-gui-and-invite-bot-handling
Improve dashboard UX and handle bot invite flow
2026-01-28 13:00:11 +01:00
Fretux 10caf20369 Improve dashboard UX and bot invite flow 2026-01-28 10:41:22 +01:00
Fretux ac120bc4cb Merge pull request #18 from Fretuks/fix-errors-after-logging-into-discord
Guard permissions normalization against null configs
2026-01-28 09:31:22 +01:00
Fretux 088629a3db Fix permissions config defaults 2026-01-28 09:19:26 +01:00
Fretux e57ad15776 Merge pull request #17 from Fretuks/fix-guilds-api-errors-on-login
Cache user guilds and improve guild endpoint error handling
2026-01-28 09:13:44 +01:00
Fretux 39cd79ed51 Improve guild endpoint error handling 2026-01-28 09:13:31 +01:00
Fretux 53b828e801 refactor 2026-01-28 09:00:54 +01:00
Fretux 44719bb2b5 Merge pull request #16 from Fretuks/fix-502-bad-gateway-errors-for-api
Handle Discord API auth and rate-limit errors for guild endpoints
2026-01-27 16:04:36 +01:00
Fretux 7a90f51449 Handle Discord auth errors for guild endpoints 2026-01-27 16:04:19 +01:00
Fretux faca78619d Merge pull request #15 from Fretuks/fix-redeclaration-of-const-fetchjson
Namespace dashboard helpers to avoid redeclaration collisions
2026-01-27 16:00:12 +01:00
Fretux ad5f4593cd Avoid dashboard helper redeclarations 2026-01-27 15:59:56 +01:00
Fretux b3cf987778 Merge pull request #14 from Fretuks/improve-discord-bot-dashboard-for-usability
Add web dashboard UI; fix OAuth flow and harden permissions API
2026-01-27 15:40:26 +01:00
Fretux 4fe1831386 Add dashboard UI and secure guild permissions 2026-01-27 15:40:06 +01:00
Fretux 06fdd57aa0 Merge pull request #13 from Fretuks/revert-12-add-web-dashboard-for-discord-bot-configuration
Revert "Add dashboard UI and secure API endpoints"
2026-01-27 15:30:20 +01:00
Fretux cbf9c112a4 Revert "Add dashboard UI and secure API endpoints" 2026-01-27 15:30:09 +01:00
Fretux f38b8a2199 Merge pull request #12 from Fretuks/add-web-dashboard-for-discord-bot-configuration
Add dashboard UI and secure API endpoints
2026-01-27 15:20:26 +01:00
Fretux 5905b16d50 Add dashboard UI and secure API endpoints 2026-01-27 15:20:10 +01:00
Fretux d4a34fdc34 Merge pull request #11 from Fretuks/main
Implement small fixes
2026-01-27 15:06:23 +01:00
Fretux 41949fe596 Small fix 2026-01-27 15:05:04 +01:00
Fretux ca07a0ed3b Merge pull request #11 from Fretuks/create-frontend-for-backend
Add dashboard frontend UI
2026-01-27 14:35:57 +01:00
Fretux f610d7a922 Add dashboard frontend 2026-01-27 14:33:32 +01:00
Fretux bb13f086df Merge pull request #10 from Fretuks/main
Implement backend
2026-01-27 14:28:03 +01:00
Fretux 4f406a7cd6 Merge branch 'ScriptSorcerer20:main' into main 2026-01-27 14:27:34 +01:00
Fretux b70c177a46 Merge pull request #10 from Fretuks/create-backend-for-discord-bot-dashboard
Add dashboard backend endpoints for Discord OAuth and guild management
2026-01-27 13:04:16 +01:00
Fretux 01f7289fb5 Add dashboard backend endpoints 2026-01-27 12:00:08 +01:00
Fretux e32948d1ae Merge pull request #9 from Fretuks/main
Implement more config options
2026-01-27 11:47:26 +01:00
Fretux 23a1e41933 Merge branch 'ScriptSorcerer20:main' into main 2026-01-27 11:47:04 +01:00
Fretux 923c96af34 Merge pull request #9 from Fretuks/implement-configurable-command-settings
Add per-guild moderation settings and persistent moderation records
2026-01-27 11:46:53 +01:00
Fretux f743262991 Add per-guild moderation settings and records 2026-01-27 11:46:43 +01:00
Fretux 551b4adb0e Merge pull request #8 from Fretuks/main
Implement more stuff
2026-01-27 10:07:35 +01:00
Fretux b6a7d35ce3 Merge branch 'ScriptSorcerer20:main' into main 2026-01-27 10:07:12 +01:00
Fretux f234a56dd7 Merge remote-tracking branch 'origin/main' 2026-01-27 09:59:24 +01:00
Fretux cdcb280bcc Update README.md 2026-01-27 09:57:03 +01:00
Fretux 6a5777f40e Update README.md 2026-01-27 09:56:52 +01:00
Fretux 9c8013523c Merge pull request #7 from Fretuks/main
Implement more commands
2026-01-27 09:53:52 +01:00
Fretux 0ebb7e3dea Merge pull request #8 from Fretuks/implement-additional-features-from-readme
Add warning system, role management commands, and a guessing game
2026-01-27 09:53:18 +01:00
Fretux 31e9169231 Add warning system, role management, and guessing game 2026-01-27 09:53:03 +01:00
Fretux 2edcf2ccf6 Merge pull request #6 from Fretuks/main
Implement simple permissions
2026-01-27 09:44:10 +01:00
Fretux 822b1a2910 Merge pull request #7 from Fretuks/make-admin-commands-configurable
Add guild-configurable moderation permissions
2026-01-27 09:38:08 +01:00
Fretux 5519548802 Add guild-configurable moderation permissions 2026-01-27 09:37:36 +01:00
Fretux 7f56996ed3 Merge remote-tracking branch 'origin/main' 2026-01-27 09:13:32 +01:00
Fretux ea3a368367 Merge pull request #5 from Fretuks/main
Fix issue with streams
2026-01-27 09:13:12 +01:00
Fretux 37cae6cc1a Merge pull request #6 from Fretuks/fix-mongodb-change-stream-error
Add polling fallback and dedupe scheduling for temp bans when change streams unavailable
2026-01-27 09:12:48 +01:00
Fretux 6a1c81fa3c Handle tempban change stream fallback 2026-01-27 09:12:13 +01:00
Fretux 0666c9263d Merge remote-tracking branch 'origin/main' 2026-01-27 09:05:43 +01:00
Fretux 6dc3f2632a Merge pull request #4 from Fretuks/main
Fix quiz command
2026-01-27 09:04:27 +01:00
Fretux 7d2fdfcafe Merge pull request #5 from Fretuks/add-additional-commands
Add utility commands: avatar, roll, and uptime
2026-01-27 09:04:00 +01:00
Fretux aba21131b4 Add utility commands for uptime avatar and roll 2026-01-27 09:03:44 +01:00
Fretux 4e4fb82ceb Merge pull request #4 from Fretuks/improve-command-implementation-and-update-readme
Enhance quiz with multiple-choice and rewards
2026-01-26 19:15:58 +01:00
Fretux ad6642e3b0 Enhance quiz with choices and rewards 2026-01-26 19:15:43 +01:00
Fretux ad19510aae Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	commands/db/balance.js
#	commands/db/earn.js
#	commands/db/give.js
#	commands/db/test.js
#	commands/db/transfer.js
#	commands/moderation/kick.js
#	commands/moderation/mute.js
#	commands/moderation/temp_ban.js
#	commands/unique/quiz.js
#	commands/unique/timestamp.js
#	commands/utility/about.js
#	commands/utility/help.js
#	commands/utility/ping.js
#	events/tempban.js
2026-01-26 14:12:15 +01:00
Fretux 7321c1960f Merge pull request #3 from Fretuks/main
Implement more commands
2026-01-26 14:11:33 +01:00
Fretux a1d469af4d Merge pull request #3 from Fretuks/improve-commands-and-update-readme
Add /wiki and /weather commands; improve help, utility embeds, and moderation UX
2026-01-26 14:11:03 +01:00
Fretux ef7ca16a85 Add wiki and weather commands 2026-01-26 14:10:46 +01:00
Fretux a41a81f367 Ignore secrets and local files 2026-01-26 13:54:20 +01:00
Fretux 1ace32dd00 Ignore config.json 2026-01-26 13:53:06 +01:00
Fretux ca5d6cdd37 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	commands/db/balance.js
2026-01-26 12:17:23 +01:00
Fretux a86c90f8ce Merge pull request #2 from Fretuks/main
Implement some commands
2026-01-26 12:16:05 +01:00
Fretux 63a7abfe03 Merge pull request #2 from Fretuks/main
Implement some commands
2026-01-26 12:16:05 +01:00
Fretux 10b33a79b3 Small fix 2026-01-26 12:15:25 +01:00
Fretux 7561fa88d1 Merge pull request #2 from Fretuks/implement-improvements-from-readme
Add currency commands and polish utility responses
2026-01-26 12:14:33 +01:00
Fretux 7cdd48d696 Merge pull request #2 from Fretuks/implement-improvements-from-readme
Add currency commands and polish utility responses
2026-01-26 12:14:33 +01:00
Fretux 0c36960645 Add currency commands and polish utilities 2026-01-26 12:14:18 +01:00
Fretux 022e09a1f3 Add currency commands and polish utilities 2026-01-26 12:14:18 +01:00
ScriptSorcerer 60d52b220a Merge pull request #1 from Fretuks/main
Overhaul everything
2026-01-26 11:41:43 +01:00
ScriptSorcerer 41d8c11a40 Merge pull request #1 from Fretuks/main
Overhaul everything
2026-01-26 11:41:43 +01:00
Fretux e48b63ed74 Merge pull request #1 from Fretuks/clean-up-existing-structure
Refactor command and event loading
2026-01-26 11:39:08 +01:00
Fretux 766d21656e Merge pull request #1 from Fretuks/clean-up-existing-structure
Refactor command and event loading
2026-01-26 11:39:08 +01:00
Fretux 8483c490cb Refactor command and event loading 2026-01-26 11:34:48 +01:00
Fretux 7662337553 Refactor command and event loading 2026-01-26 11:34:48 +01:00
ScriptSorcerer 0d265b0fb5 startet currency system, think i added temporary ban and a give command 2025-03-12 19:35:49 +01:00
ScriptSorcerer ea0507dd16 added mognodb database 2025-01-30 20:15:50 +01:00
ScriptSorcerer e89cc46413 added kick command 2025-01-28 22:40:05 +01:00
ScriptSorcerer f7ac4773ac Implemented new Commands:
help - list all commands
timestamp - convert date to timestamp (still in progress)
about - Bot porpose (still in  progress)
ping - check bot availability
2025-01-25 16:56:46 +01:00
ScriptSorcerer 6336053d41 Create README.md
a file to keep track of my goals =)
2025-01-24 14:23:24 +01:00
58 changed files with 6900 additions and 1608 deletions
+4
View File
@@ -0,0 +1,4 @@
config.json
.env
node_modules/
.idea/
+71 -17
View File
@@ -6,42 +6,43 @@ Im going to make a To-do List for the features i want to implement for my Bot.
(I will try to make these tools optional, because alot of bots already feature them) (I will try to make these tools optional, because alot of bots already feature them)
1. **Basic Commands** 1. **Basic Commands**
- [x] Add a `!help` command to display a list of available commands. - [x] Add a `/help` command to display a list of available commands.
- [x] Implement a `!ping` command to check bot responsiveness. - [x] Implement a `/ping` command to check bot responsiveness.
- [ **Only Text missing** ] Add a `!about` command to describe the bot's purpose. - [x] Add an `/about` command to describe the bot's purpose.
2. **Role Management** 2. **Role Management**
- [ ] Set up a self-assignable role system (e.g., using reaction roles). - [ ] Set up a self-assignable role system (e.g., using reaction roles).
- [ ] Allow admins to assign/remove roles with commands. - [x] Allow admins to assign/remove roles with commands.
3. **Moderation Tools** 3. **Moderation Tools**
- [ ] Add commands to mute, x kick, and x 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.
- [ ] Implement a warning system with a strike counter. - [x] Implement a warning system with a strike counter.
--- ---
#### **Unique Features** #### **Unique Features**
1. **Currency System** 1. **Currency System**
- [ ] Design a virtual currency system with basic commands: - [x] Design a virtual currency system with basic commands:
- [ ] `!balance` to check currency balance. - [x] `/balance` to check currency balance.
- [ ] `!earn` to earn daily or periodic rewards. - [x] `/earn` to earn daily or periodic rewards.
- [ ] `!transfer @user [amount]` to send currency to another user. - [x] `/transfer @user [amount]` to send currency to another user.
- [ ] Link the currency system to role management: - [ ] Link the currency system to role management:
- [ ] Allow users to purchase roles using the currency with a command (e.g., `!buyrole [role name]`). - [ ] Allow users to purchase roles using the currency with a command (e.g., `!buyrole [role name]`).
2. **Datetime Converter** 2. **Datetime Converter**
(Still in Progress, maybe i'll add visual Menu) (Still in Progress, maybe i'll add visual Menu)
- [x] Create a command to convert timezones: - [x] Create a command to convert timezones:
- [x] `!convert [time] [timezone] to [timezone]` to handle conversions. - [x] `/timestamp [date]` to generate Discord timestamps.
- [x] Support both 12-hour and 24-hour formats. - [x] Support multiple Discord timestamp formats.
- [ ] Include a command to display server-specific or user-specific timezones. - [ ] Include a command to display server-specific or user-specific timezones.
3. **Minigames** 3. **Minigames**
- [ ] Implement a Quiz Game: - [ ] Implement a Quiz Game:
- [ ] Add a `!quiz` command to start the game. - [x] Add a `/quiz` command to start the game.
- [ ] Provide multiple-choice or true/false questions. - [x] Provide multiple-choice or true/false questions.
- [ ] Award virtual currency for correct answers. - [x] Award virtual currency for correct answers.
- [ ] Add a simple number-guessing game (e.g., `!guess [number]`). - [ ] Add a simple number-guessing game (e.g., `!guess [number]`).
- [ ] Create a leaderboard for game winners. - [ ] Create a leaderboard for game winners.
@@ -49,8 +50,8 @@ Im going to make a To-do List for the features i want to implement for my Bot.
#### **Utility Tools** #### **Utility Tools**
1. **Search and Lookups** 1. **Search and Lookups**
- [ ] Add a `!wiki [topic]` command to fetch summaries from Wikipedia. - [x] Add a `/wiki [topic]` command to fetch summaries from Wikipedia.
- [ ] Include a `!weather [location]` command for weather updates. - [x] Include a `/weather [location]` command for weather updates.
--- ---
@@ -70,3 +71,56 @@ Im going to make a To-do List for the features i want to implement for my Bot.
2. **Monitoring** 2. **Monitoring**
- [ ] Add logging to track errors and usage statistics. - [ ] Add logging to track errors and usage statistics.
- [ ] Implement uptime monitoring to ensure the bot stays online. - [ ] Implement uptime monitoring to ensure the bot stays online.
---
### **Server-Specific Permission Configuration**
Moderation commands support per-server permission overrides stored in MongoDB. Each guild can define which users or roles are allowed to run specific commands, plus optional admin overrides that apply to all moderation commands. This is intended for future web-based configuration.
Example document stored in the `discord.guild_permissions` collection:
```json
{
"guildId": "1234567890",
"adminUserIds": ["1111111111"],
"adminRoleIds": ["2222222222"],
"commandPermissions": {
"kick": {
"allowedUserIds": ["3333333333"],
"allowedRoleIds": ["4444444444"]
}
}
}
```
---
### **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.
+8
View File
@@ -0,0 +1,8 @@
[
"warn",
"warnings",
"ban",
"kick",
"mute",
"unmute"
]
+25
View File
@@ -0,0 +1,25 @@
const { SlashCommandBuilder } = require('discord.js');
const { connectToDatabase } = require('../../db.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('balance')
.setDescription('Check your coin balance or another user\'s balance.')
.addUserOption(option =>
option
.setName('user')
.setDescription('User to check (defaults to you)')
.setRequired(false)),
category: 'Currency',
async execute(interaction) {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('currency');
const target = interaction.options.getUser('user') ?? interaction.user;
const targetData = await collection.findOne({ userID: target.id });
const balance = targetData?.balance ?? 0;
return interaction.reply({
content: `${target.tag} has **${balance}** coins.`,
});
}
};
+54
View File
@@ -0,0 +1,54 @@
const { SlashCommandBuilder } = require('discord.js');
const { connectToDatabase } = require('../../db.js');
const REWARD_AMOUNT = 100;
const COOLDOWN_MS = 24 * 60 * 60 * 1000;
const formatDuration = (ms) => {
const totalSeconds = Math.ceil(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts = [];
if (hours) parts.push(`${hours}h`);
if (minutes) parts.push(`${minutes}m`);
if (!hours && !minutes) parts.push(`${seconds}s`);
return parts.join(' ');
};
module.exports = {
data: new SlashCommandBuilder()
.setName('earn')
.setDescription('Claim your daily coin reward.'),
category: 'Currency',
async execute(interaction) {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('currency');
const now = new Date();
const userId = interaction.user.id;
const userData = await collection.findOne({ userID: userId });
const lastClaim = userData?.lastClaimedAt ? new Date(userData.lastClaimedAt) : null;
if (lastClaim) {
const elapsed = now - lastClaim;
if (elapsed < COOLDOWN_MS) {
const remaining = COOLDOWN_MS - elapsed;
return interaction.reply({
content: `You already claimed your reward. Try again in **${formatDuration(remaining)}**.`,
ephemeral: true,
});
}
}
await collection.updateOne(
{ userID: userId },
{
$set: { lastClaimedAt: now.toISOString() },
$inc: { balance: REWARD_AMOUNT },
},
{ upsert: true }
);
return interaction.reply({
content: `You earned **${REWARD_AMOUNT}** coins! Come back tomorrow for more.`,
});
}
};
+24 -19
View File
@@ -1,37 +1,42 @@
// exampleCommand.js const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { SlashCommandBuilder } = require('discord.js');
const { connectToDatabase } = require('../../db.js'); const { connectToDatabase } = require('../../db.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('give') .setName('give')
.setDescription('give somebody coins') .setDescription('Admin: grant coins to a user.')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('amount') .setName('amount')
.setDescription('amount of the coins') .setDescription('Amount of coins to give')
.setMinValue(1)
.setRequired(true)) .setRequired(true))
.addUserOption(option => .addUserOption(option =>
option.setName('target') option
.setDescription('The user to give coins to') .setName('target')
.setDescription('User to receive coins')
.setRequired(true)), .setRequired(true)),
category: 'Currency',
async execute(interaction) { async execute(interaction) {
const dbClient = await connectToDatabase(); const dbClient = await connectToDatabase();
const target = interaction.options.getUser('target'); const target = interaction.options.getUser('target');
const amount = interaction.options.getInteger('amount'); const amount = interaction.options.getInteger('amount');
if (target.bot) {
// Choose a DB + Collection return interaction.reply({
content: 'You cannot grant coins to bot accounts.',
ephemeral: true,
});
}
const db = dbClient.db('discord'); const db = dbClient.db('discord');
const collection = db.collection('currency'); const collection = db.collection('currency');
await collection.updateOne(
let targetData = await collection.findOne({ userID: target.id }); { userID: target.id },
if (!targetData) { { $inc: { balance: amount } },
await collection.insertOne({ userID: target.id, balance: 0 }); { upsert: true }
} );
return interaction.reply({
// Perform the transaction: subtract from sender and add to target. content: `You granted **${amount}** coins to ${target.tag}.`,
await collection.updateOne({ userID: target.id }, { $inc: { balance: amount } }, { upsert: true }); });
return interaction.reply({ content: `You have given ${amount} coins to ${target.tag}.` });
} }
}; };
+7 -6
View File
@@ -1,4 +1,3 @@
// exampleCommand.js
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder } = require('discord.js');
const { connectToDatabase } = require('../../db.js'); const { connectToDatabase } = require('../../db.js');
@@ -11,17 +10,19 @@ module.exports = {
.setName('message') .setName('message')
.setDescription('string to store') .setDescription('string to store')
.setRequired(true)), .setRequired(true)),
category: 'Database',
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) {
// Choose a DB + Collection 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');
// Example: Insert something
await collection.insertOne({ userId: interaction.user.id, message: message }); await collection.insertOne({ userId: interaction.user.id, message: message });
await interaction.reply('Successfully inserted your data into MongoDB.'); await interaction.reply('Successfully inserted your data into MongoDB.');
}, },
}; };
+68
View File
@@ -0,0 +1,68 @@
const { SlashCommandBuilder } = require('discord.js');
const { connectToDatabase } = require('../../db.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('transfer')
.setDescription('Send coins to another user.')
.addUserOption(option =>
option
.setName('target')
.setDescription('User to send coins to')
.setRequired(true))
.addIntegerOption(option =>
option
.setName('amount')
.setDescription('Amount of coins to send')
.setMinValue(1)
.setRequired(true)),
category: 'Currency',
async execute(interaction) {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('currency');
const target = interaction.options.getUser('target');
const amount = interaction.options.getInteger('amount');
if (target.bot) {
return interaction.reply({
content: 'You cannot transfer coins to a bot account.',
ephemeral: true,
});
}
if (target.id === interaction.user.id) {
return interaction.reply({
content: 'You cannot transfer coins to yourself.',
ephemeral: true,
});
}
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;
return interaction.reply({
content: `You only have **${senderBalance}** coins available.`,
ephemeral: true,
});
}
await collection.updateOne(
{ userID: target.id },
{ $inc: { balance: amount } },
{ upsert: true }
);
return interaction.reply({
content: `You sent **${amount}** coins to ${target.tag}.`,
});
}
};
+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);
}
},
};
+25 -2
View File
@@ -1,9 +1,12 @@
const { SlashCommandBuilder } = require("discord.js"); const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = { module.exports = {
permissionGroup: 'moderation',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("kick") .setName("kick")
.setDescription("Kick misbehaving people") .setDescription("Kick misbehaving people")
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('target') .setName('target')
@@ -13,15 +16,35 @@ module.exports = {
option option
.setName('reason') .setName('reason')
.setDescription('The reason of the kick')), .setDescription('The reason of the kick')),
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';
try { try {
const member = await interaction.guild.members.fetch(user.id); const member = await interaction.guild.members.fetch(user.id);
if (!member.kickable) {
return interaction.reply({
content: 'I cannot kick that member. Check my role permissions and hierarchy.',
ephemeral: true,
});
}
await member.kick(reason); await member.kick(reason);
await recordModerationAction({
guildId: interaction.guildId,
userId: user.id,
action: 'kick',
moderatorId: interaction.user.id,
reason,
metadata: {},
});
await interaction.reply(`Kicked ${user.tag} for: ${reason}`); await interaction.reply(`Kicked ${user.tag} for: ${reason}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
+38 -13
View File
@@ -1,9 +1,12 @@
const { SlashCommandBuilder } = require("discord.js"); const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = { module.exports = {
permissionGroup: 'moderation',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("mute") .setName("mute")
.setDescription("mute misbehaving people") .setDescription("Temporarily mute a member with a timeout.")
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('target') .setName('target')
@@ -12,27 +15,49 @@ module.exports = {
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('duration') .setName('duration')
.setDescription('The duration of the Mute (Minutes)') .setDescription('The duration of the mute (in minutes)')
.setMinValue(1)
.setMaxValue(40320)
.setRequired(true)) .setRequired(true))
.addStringOption(option => .addStringOption(option =>
option option
.setName('reason') .setName('reason')
.setDescription('The reason of the mute')), .setDescription('The reason of the mute')),
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'); const reason = interaction.options.getString('reason') || 'No reason provided';
// Convert duration to milliseconds
const durationMs = duration * 60 * 1000; const durationMs = duration * 60 * 1000;
const member = await interaction.guild.members.fetch(target.id);
const guildmember = await interaction.guild.members.fetch(target.id); if (!member.moderatable) {
return interaction.reply({
const muteRole = interaction.guild.roles.find(role => role.name === "mute"); content: 'I cannot mute that member. Check my role permissions and hierarchy.',
if (!muteRole) { ephemeral: true,
});
} else {
await guildmember.roles.add(muteRole, reason);
} }
await member.timeout(durationMs, reason);
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'mute',
moderatorId: interaction.user.id,
reason,
metadata: { durationMinutes: duration },
stateUpdates: { mutedUntil: new Date(Date.now() + durationMs) },
});
return interaction.reply({
content: `Muted ${target.tag} for **${duration} minutes**. Reason: ${reason}`,
});
}, },
}; };
+93
View File
@@ -0,0 +1,93 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const ensureRoleEditable = (role) => {
if (role.managed) {
return 'That role is managed by an integration and cannot be modified.';
}
if (!role.editable) {
return 'I cannot manage that role. Check my role permissions and hierarchy.';
}
return null;
};
module.exports = {
permissionGroup: 'moderation',
data: new SlashCommandBuilder()
.setName('role')
.setDescription('Assign or remove roles from members.')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add a role to a member.')
.addUserOption(option =>
option
.setName('target')
.setDescription('The member to update')
.setRequired(true))
.addRoleOption(option =>
option
.setName('role')
.setDescription('The role to add')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a role from a member.')
.addUserOption(option =>
option
.setName('target')
.setDescription('The member to update')
.setRequired(true))
.addRoleOption(option =>
option
.setName('role')
.setDescription('The role to remove')
.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();
const roleError = ensureRoleEditable(role);
if (roleError) {
return interaction.reply({ content: roleError, ephemeral: true });
}
const member = await interaction.guild.members.fetch(target.id);
if (action === 'add') {
if (member.roles.cache.has(role.id)) {
return interaction.reply({
content: `${target.tag} already has ${role.name}.`,
ephemeral: true,
});
}
await member.roles.add(role);
return interaction.reply({
content: `Added ${role.name} to ${target.tag}.`,
});
}
if (!member.roles.cache.has(role.id)) {
return interaction.reply({
content: `${target.tag} does not have ${role.name}.`,
ephemeral: true,
});
}
await member.roles.remove(role);
return interaction.reply({
content: `Removed ${role.name} from ${target.tag}.`,
});
},
};
+49 -12
View File
@@ -1,20 +1,25 @@
const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require("discord.js");
const { connectToDatabase } = require('../../db.js'); const { connectToDatabase } = require('../../db.js');
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = { module.exports = {
mod: true, mod: true,
permissionGroup: 'moderation',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("temp-ban") .setName("temp-ban")
.setDescription("Kick misbehaving people") .setDescription("Temporarily ban a member.")
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('target') .setName('target')
.setDescription('The member to kick') .setDescription('The member to ban')
.setRequired(true)) .setRequired(true))
.addIntegerOption(option => .addIntegerOption(option =>
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
@@ -26,24 +31,43 @@ module.exports = {
.setName('delete-messages') .setName('delete-messages')
.setDescription('Number of days to delete messages') .setDescription('Number of days to delete messages')
.setMinValue(0).setMaxValue(7)), .setMinValue(0).setMaxValue(7)),
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) {
@@ -70,11 +94,24 @@ module.exports = {
const tempBanCollection = db.collection("tempban"); const tempBanCollection = db.collection("tempban");
// 3. Insert the ban record // 3. Insert the ban record
await tempBanCollection.insertOne({ const banRecord = {
guildId: interaction.guild.id, guildId: interaction.guild.id,
userId: user.id, userId: user.id,
banTime: banUntil, banTime: banUntil,
reason: reason, reason: reason,
};
await tempBanCollection.insertOne(banRecord);
await recordModerationAction({
guildId: interaction.guild.id,
userId: user.id,
action: 'temp-ban',
moderatorId: interaction.user.id,
reason,
metadata: { durationMs: durationms },
stateUpdates: {
isBanned: true,
bannedUntil: new Date(banUntil),
},
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
+151
View File
@@ -0,0 +1,151 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { addWarning } = require('../../services/warnings');
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()
.setName('warn')
.setDescription('Warn a member and track strikes.')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption(option =>
option
.setName('target')
.setDescription('The member to warn')
.setRequired(true))
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the warning')
.setRequired(true)),
category: 'Moderation',
async execute(interaction) {
const target = interaction.options.getUser('target');
const reason = interaction.options.getString('reason');
if (!interaction.guildId) {
return interaction.reply({
content: 'Warnings can only be issued inside a server.',
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,
userId: target.id,
moderatorId: interaction.user.id,
reason,
});
const settings = await getGuildSettings(interaction.guildId);
const thresholds = settings.moderation?.strikeThresholds ?? {};
const muteThreshold = Number.isInteger(thresholds.mute) ? thresholds.mute : null;
const kickThreshold = Number.isInteger(thresholds.kick) ? thresholds.kick : null;
const banThreshold = Number.isInteger(thresholds.ban) ? thresholds.ban : null;
const muteDurationMinutes = settings.moderation?.muteDurationMinutes ?? 0;
const banDurationMs = settings.moderation?.banDurationMs ?? 0;
let actionTaken = null;
if (banThreshold && strikeCount === banThreshold) {
actionTaken = 'ban';
} else if (kickThreshold && strikeCount === kickThreshold) {
actionTaken = 'kick';
} else if (muteThreshold && strikeCount === muteThreshold) {
actionTaken = 'mute';
}
if (actionTaken === 'mute') {
try {
const member = await interaction.guild.members.fetch(target.id);
if (member.moderatable && muteDurationMinutes > 0) {
const durationMs = muteDurationMinutes * 60 * 1000;
await member.timeout(durationMs, 'Strike threshold reached');
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'mute',
moderatorId: interaction.user.id,
reason: 'Strike threshold reached',
metadata: { strikeCount, durationMinutes: muteDurationMinutes },
stateUpdates: { mutedUntil: new Date(Date.now() + durationMs) },
});
}
} catch (error) {
console.error('Failed to auto-mute member:', error);
}
}
if (actionTaken === 'kick') {
try {
const member = await interaction.guild.members.fetch(target.id);
if (member.kickable) {
await member.kick('Strike threshold reached');
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'kick',
moderatorId: interaction.user.id,
reason: 'Strike threshold reached',
metadata: { strikeCount },
});
}
} catch (error) {
console.error('Failed to auto-kick member:', error);
}
}
if (actionTaken === 'ban') {
try {
const banReason = 'Strike threshold reached';
const shouldTempBan = Number.isInteger(banDurationMs) && banDurationMs > 0;
await interaction.guild.bans.create(target.id, {
reason: banReason,
});
if (shouldTempBan) {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const tempBanCollection = db.collection('tempban');
const banUntil = Date.now() + banDurationMs;
await tempBanCollection.insertOne({
guildId: interaction.guildId,
userId: target.id,
banTime: banUntil,
reason: banReason,
});
}
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'ban',
moderatorId: interaction.user.id,
reason: banReason,
metadata: { strikeCount, banDurationMs },
stateUpdates: {
isBanned: true,
bannedUntil: shouldTempBan ? new Date(Date.now() + banDurationMs) : null,
},
});
} catch (error) {
console.error('Failed to auto-ban member:', error);
}
}
return interaction.reply({
content: `${target.tag} has been warned. Total strikes: **${warnings.length}**.`,
});
},
};
+52
View File
@@ -0,0 +1,52 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { getWarnings } = require('../../services/warnings');
const formatWarning = (warning, index) => {
const date = new Date(warning.createdAt);
const timestamp = Number.isNaN(date.getTime())
? 'Unknown date'
: date.toLocaleString();
return `${index + 1}. ${warning.reason} (by <@${warning.moderatorId}> on ${timestamp})`;
};
module.exports = {
permissionGroup: 'moderation',
data: new SlashCommandBuilder()
.setName('warnings')
.setDescription('View a member warning history.')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption(option =>
option
.setName('target')
.setDescription('The member to check')
.setRequired(true)),
category: 'Moderation',
async execute(interaction) {
const target = interaction.options.getUser('target');
if (!interaction.guildId) {
return interaction.reply({
content: 'Warnings can only be checked inside a server.',
ephemeral: true,
});
}
const warnings = await getWarnings({
guildId: interaction.guildId,
userId: target.id,
});
if (warnings.length === 0) {
return interaction.reply({
content: `${target.tag} has no warnings.`,
});
}
const recentWarnings = warnings.slice(-5).map(formatWarning).join('\n');
return interaction.reply({
content: `Warnings for ${target.tag} (total ${warnings.length}):\n${recentWarnings}`,
});
},
};
+29
View File
@@ -0,0 +1,29 @@
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('guess')
.setDescription('Try to guess a number between 1 and 10.')
.addIntegerOption(option =>
option
.setName('number')
.setDescription('Your guess (1-10)')
.setMinValue(1)
.setMaxValue(10)
.setRequired(true)),
category: 'Unique',
async execute(interaction) {
const guess = interaction.options.getInteger('number');
const answer = Math.floor(Math.random() * 10) + 1;
if (guess === answer) {
return interaction.reply({
content: `🎉 Correct! The number was **${answer}**.`,
});
}
return interaction.reply({
content: `Not quite! The number was **${answer}**. Try again!`,
});
},
};
+124 -21
View File
@@ -1,45 +1,148 @@
const quiz = require('./quiz.json'); const quiz = require('./quiz.json');
const {SlashCommandBuilder} = require("discord.js"); const {
SlashCommandBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
} = require('discord.js');
const { connectToDatabase } = require('../../db.js');
const QUIZ_REWARD = 50;
const QUIZ_TIMEOUT_MS = 30000;
const normalizeAnswer = (answer) => answer.trim().toLowerCase();
const buildRows = (choices) => {
const rows = [];
for (let i = 0; i < choices.length; i += 5) {
const slice = choices.slice(i, i + 5);
const row = new ActionRowBuilder().addComponents(
slice.map((choice, index) =>
new ButtonBuilder()
.setCustomId(`quiz_choice_${i + index}`)
.setLabel(choice)
.setStyle(ButtonStyle.Primary)
)
);
rows.push(row);
}
return rows;
};
const disableRows = (rows) =>
rows.map((row) => {
const disabledRow = ActionRowBuilder.from(row);
disabledRow.components = disabledRow.components.map((component) =>
ButtonBuilder.from(component).setDisabled(true)
);
return disabledRow;
});
const awardReward = async (userId) => {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('currency');
await collection.updateOne(
{ userID: userId },
{ $inc: { balance: QUIZ_REWARD } },
{ upsert: true }
);
};
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("quiz") .setName('quiz')
.setDescription("Start a cool quiz"), .setDescription('Start a cool quiz'),
category: 'Games',
async execute(interaction) { async execute(interaction) {
const item = quiz[Math.floor(Math.random() * quiz.length)]; const item = quiz[Math.floor(Math.random() * quiz.length)];
await interaction.reply({ const answers = (item.answers ?? [])
content: `**Quiz Time!**\n${item.question}\n\n_You have 30 seconds to answer!_`, .map(normalizeAnswer)
}); .filter(Boolean);
const hasChoices = Array.isArray(item.choices) && item.choices.length > 0;
// Define a filter to check the correct answer const embed = new EmbedBuilder()
const collectorFilter = (response) => { .setColor(0xf7b731)
return ( .setTitle('Quiz Time!')
response.author.id === interaction.user.id && // Ensure it's the same user .setDescription(item.question)
item.answers.some( .setFooter({ text: 'You have 30 seconds to answer.' });
(answer) => answer.toLowerCase() === response.content.toLowerCase()
) if (!interaction.channel) {
); return interaction.reply({
}; content: 'I need a channel to run the quiz.',
ephemeral: true,
});
}
if (hasChoices) {
const choices = item.choices;
const rows = buildRows(choices);
const reply = await interaction.reply({
embeds: [embed],
components: rows,
fetchReply: true,
});
try {
const selection = await reply.awaitMessageComponent({
filter: (component) => component.user.id === interaction.user.id,
time: QUIZ_TIMEOUT_MS,
});
const choiceIndex = Number(selection.customId.split('_').pop());
const selected = choices[choiceIndex];
const isCorrect = answers.includes(normalizeAnswer(selected));
const resultText = isCorrect
? `${selection.user} nailed it! **${selected}** is correct. +${QUIZ_REWARD} coins! 🎉`
: `Nice try! The correct answer was **${item.answers[0]}**.`;
if (isCorrect) {
await awardReward(selection.user.id);
}
await selection.update({
content: resultText,
embeds: [],
components: disableRows(rows),
});
} catch (err) {
await interaction.editReply({
content: `⏳ Time's up! The correct answer was **${item.answers[0]}**.`,
embeds: [],
components: disableRows(rows),
});
}
return;
}
await interaction.reply({ embeds: [embed] });
const collectorFilter = (response) =>
response.author.id === interaction.user.id &&
answers.includes(normalizeAnswer(response.content));
try { try {
// Await messages in the same channel
const collected = await interaction.channel.awaitMessages({ const collected = await interaction.channel.awaitMessages({
filter: collectorFilter, filter: collectorFilter,
max: 1, max: 1,
time: 30000, // 30 seconds time: QUIZ_TIMEOUT_MS,
errors: ['time'], errors: ['time'],
}); });
// If a message is collected, reply with the correct response
const correctAnswer = collected.first(); const correctAnswer = collected.first();
await awardReward(correctAnswer.author.id);
await interaction.followUp( await interaction.followUp(
`${correctAnswer.author} got the correct answer: **${correctAnswer.content}**! 🎉` `${correctAnswer.author} got the correct answer: **${correctAnswer.content}**! +${QUIZ_REWARD} coins! 🎉`
); );
} catch (err) { } catch (err) {
// If no one answers in time
await interaction.followUp( await interaction.followUp(
`⏳ Time's up! Nobody got the correct answer. The correct answers were: **${item.answers.join(', ')}**.` `⏳ Time's up! Nobody got the correct answer. The correct answers were: **${item.answers.join(', ')}**.`
); );
} }
}, },
}; };
+15 -3
View File
@@ -1,10 +1,22 @@
[ [
{ {
"question": "What color is the sky?", "question": "What color is the clear daytime sky?",
"choices": ["Blue", "Green", "Red", "Yellow"],
"answers": ["blue"] "answers": ["blue"]
}, },
{ {
"question": "How many letters are there in the alphabet?", "question": "How many letters are there in the English alphabet?",
"answers": ["26", "twenty-six", "twenty six", "twentysix"] "choices": ["24", "25", "26", "27"],
"answers": ["26"]
},
{
"question": "True or False: The Earth orbits the Sun.",
"choices": ["True", "False"],
"answers": ["true"]
},
{
"question": "Which planet is known as the Red Planet?",
"choices": ["Mars", "Venus", "Jupiter", "Mercury"],
"answers": ["mars"]
} }
] ]
+2 -1
View File
@@ -25,6 +25,7 @@ module.exports = {
{ name: 'Full date and time with weekday', value: 'F' }, { name: 'Full date and time with weekday', value: 'F' },
{ name: 'Relative time', value: 'R' }, { name: 'Relative time', value: 'R' },
)), )),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
// Get user input // Get user input
const dateInput = interaction.options.getString('date'); const dateInput = interaction.options.getString('date');
@@ -57,4 +58,4 @@ module.exports = {
}); });
} }
}, },
}; };
+12 -7
View File
@@ -1,19 +1,24 @@
const {SlashCommandBuilder, EmbedBuilder} = require("discord.js"); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("about") .setName('about')
.setDescription("Information about this bot and creator"), .setDescription('Information about this bot and creator'),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
const exampleEmbed = new EmbedBuilder() const exampleEmbed = new EmbedBuilder()
.setColor(0x0099FF) .setColor(0x0099FF)
.setTitle('About Page') .setTitle('About This Bot')
.setDescription('I build this bot for managing my servers. (to lazy to write mor text rn)') .setDescription('A multipurpose Discord bot focused on helpful utilities, moderation, and fun community features.')
.addFields(
{ name: 'Highlights', value: 'Timezone converter, quiz games, a currency system, and helpful utility tools.' },
{ name: 'Created By', value: interaction.client.user?.username ?? 'Bot Creator' }
)
.setImage('https://i.postimg.cc/QdZZwKVD/Screenshot-2024-12-25-005933.png') .setImage('https://i.postimg.cc/QdZZwKVD/Screenshot-2024-12-25-005933.png')
.setTimestamp(); .setTimestamp();
await interaction.reply({ await interaction.reply({
embeds: [exampleEmbed] embeds: [exampleEmbed]
}) });
} }
} };
+25
View File
@@ -0,0 +1,25 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('avatar')
.setDescription('Display a user\'s avatar in full size.')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to fetch the avatar for.')
),
category: 'Utility',
async execute(interaction) {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const avatarUrl = targetUser.displayAvatarURL({ size: 1024, extension: 'png' });
const embed = new EmbedBuilder()
.setColor(0xa55eea)
.setTitle(`${targetUser.username}'s Avatar`)
.setImage(avatarUrl)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
},
};
+32 -13
View File
@@ -1,23 +1,42 @@
const { SlashCommandBuilder} = require("discord.js"); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('help') .setName('help')
.setDescription('Gives all available commands.'), .setDescription('Gives all available commands.'),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
// Map over the full command objects to get names and descriptions 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',
}))
.sort((a, b) => a.name.localeCompare(b.name));
// Format the commands into a user-friendly help message const grouped = commandDetails.reduce((acc, command) => {
const helpMessage = commandDetails const group = acc[command.category] ?? [];
.map(detail => `/${detail.name} - ${detail.description}`) group.push(command);
.join('\n'); acc[command.category] = group;
return acc;
}, {});
// Send the help message const fields = Object.keys(grouped)
await interaction.reply(`Here are all the available commands:\n\n${helpMessage}`); .sort((a, b) => a.localeCompare(b))
.map((category) => ({
name: category,
value: grouped[category]
.map((command) => `- **/${command.name}**: ${command.description}`)
.join('\n'),
}));
const embed = new EmbedBuilder()
.setColor(0x4b7bec)
.setTitle('Available Commands')
.setDescription('Here is everything I can do right now.')
.addFields(fields)
.setFooter({ text: `${commandDetails.length} commands loaded.` });
await interaction.reply({ embeds: [embed], ephemeral: true });
}, },
}; };
+19 -4
View File
@@ -1,10 +1,25 @@
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('ping') .setName('ping')
.setDescription('Check if the bots responsivnes'), .setDescription('Check the bot\'s responsiveness.'),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
await interaction.reply({ content: 'Pong! (Online)', ephemeral: true }); const sentAt = Date.now();
await interaction.reply({ content: 'Pinging...', ephemeral: true });
const latency = Date.now() - sentAt;
const apiLatency = Math.round(interaction.client.ws.ping);
const embed = new EmbedBuilder()
.setColor(0x34ace0)
.setTitle('Pong! 🏓')
.addFields(
{ name: 'Round Trip', value: `${latency}ms`, inline: true },
{ name: 'API Latency', value: `${apiLatency}ms`, inline: true },
)
.setTimestamp();
await interaction.editReply({ embeds: [embed], content: null });
}, },
}; };
+54
View File
@@ -0,0 +1,54 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const rollDice = (count, sides) => {
const rolls = [];
for (let i = 0; i < count; i += 1) {
rolls.push(1 + Math.floor(Math.random() * sides));
}
return rolls;
};
module.exports = {
data: new SlashCommandBuilder()
.setName('roll')
.setDescription('Roll one or more dice with a custom number of sides.')
.addIntegerOption((option) =>
option
.setName('sides')
.setDescription('Number of sides on each die (2-100).')
.setMinValue(2)
.setMaxValue(100)
)
.addIntegerOption((option) =>
option
.setName('count')
.setDescription('How many dice to roll (1-6).')
.setMinValue(1)
.setMaxValue(6)
),
category: 'Utility',
async execute(interaction) {
const sidesInput = interaction.options.getInteger('sides') ?? 6;
const countInput = interaction.options.getInteger('count') ?? 1;
const sides = clamp(sidesInput, 2, 100);
const count = clamp(countInput, 1, 6);
const rolls = rollDice(count, sides);
const total = rolls.reduce((sum, roll) => sum + roll, 0);
const embed = new EmbedBuilder()
.setColor(0x20bf6b)
.setTitle('Dice Roll')
.addFields(
{ name: 'Sides', value: `${sides}`, inline: true },
{ name: 'Dice', value: `${count}`, inline: true },
{ name: 'Total', value: `${total}`, inline: true },
{ name: 'Rolls', value: rolls.join(', ') }
)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
},
};
+23 -3
View File
@@ -1,10 +1,30 @@
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('server') .setName('server')
.setDescription('Provides information about the server.'), .setDescription('Provides information about the server.'),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
await interaction.reply(`This server is ${interaction.guild.name} and has ${interaction.guild.memberCount} members.`); const { guild } = interaction;
const owner = await guild.fetchOwner();
const created = `<t:${Math.floor(guild.createdTimestamp / 1000)}:F>`;
const boostCount = guild.premiumSubscriptionCount ?? 0;
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle(guild.name)
.setThumbnail(guild.iconURL({ size: 256 }))
.addFields(
{ name: 'Server ID', value: guild.id, inline: true },
{ name: 'Owner', value: owner.user.tag, inline: true },
{ name: 'Members', value: guild.memberCount.toString(), inline: true },
{ name: 'Boosts', value: boostCount.toString(), inline: true },
{ name: 'Created', value: created, inline: false },
)
.setFooter({ text: `Requested by ${interaction.user.tag}` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}, },
}; };
+34
View File
@@ -0,0 +1,34 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const formatDuration = (totalSeconds) => {
const seconds = Math.floor(totalSeconds % 60);
const minutes = Math.floor((totalSeconds / 60) % 60);
const hours = Math.floor((totalSeconds / 3600) % 24);
const days = Math.floor(totalSeconds / 86400);
const parts = [
days ? `${days}d` : null,
hours ? `${hours}h` : null,
minutes ? `${minutes}m` : null,
`${seconds}s`,
].filter(Boolean);
return parts.join(' ');
};
module.exports = {
data: new SlashCommandBuilder()
.setName('uptime')
.setDescription('Display how long the bot has been running.'),
category: 'Utility',
async execute(interaction) {
const uptimeSeconds = process.uptime();
const embed = new EmbedBuilder()
.setColor(0x45aaf2)
.setTitle('Bot Uptime')
.setDescription(`Online for **${formatDuration(uptimeSeconds)}**.`)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
},
};
+26 -4
View File
@@ -1,10 +1,32 @@
const { SlashCommandBuilder } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('user') .setName('user')
.setDescription('Provides information about the user.'), .setDescription('Provides information about a user.')
.addUserOption(option =>
option
.setName('target')
.setDescription('User to look up')
.setRequired(false)),
category: 'Utility',
async execute(interaction) { async execute(interaction) {
await interaction.reply(`This command was run by ${interaction.user.username}, who joined on ${interaction.member.joinedAt}.`); const target = interaction.options.getUser('target') ?? interaction.user;
const member = await interaction.guild.members.fetch(target.id);
const embed = new EmbedBuilder()
.setColor(0x4b7bec)
.setTitle(`${target.username}`)
.setThumbnail(target.displayAvatarURL({ size: 256 }))
.addFields(
{ name: 'Tag', value: target.tag, inline: true },
{ name: 'User ID', value: target.id, inline: true },
{ name: 'Account Created', value: `<t:${Math.floor(target.createdTimestamp / 1000)}:F>` },
{ name: 'Joined Server', value: member.joinedAt ? `<t:${Math.floor(member.joinedAt.getTime() / 1000)}:F>` : 'Unknown' },
)
.setFooter({ text: `Requested by ${interaction.user.tag}` })
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}, },
}; };
+72
View File
@@ -0,0 +1,72 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const getJson = async (url) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
};
const formatTemperature = (value, unit) => `${Math.round(value)}°${unit === 'fahrenheit' ? 'F' : 'C'}`;
module.exports = {
data: new SlashCommandBuilder()
.setName('weather')
.setDescription('Get the current weather for a location.')
.addStringOption(option =>
option
.setName('location')
.setDescription('City or place name to look up')
.setRequired(true))
.addStringOption(option =>
option
.setName('units')
.setDescription('Temperature units')
.setRequired(false)
.addChoices(
{ name: 'Celsius', value: 'celsius' },
{ name: 'Fahrenheit', value: 'fahrenheit' },
)),
category: 'Utility',
async execute(interaction) {
const location = interaction.options.getString('location');
const units = interaction.options.getString('units') ?? 'celsius';
await interaction.deferReply();
try {
const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1&language=en&format=json`;
const geo = await getJson(geoUrl);
const match = geo.results?.[0];
if (!match) {
return interaction.editReply({ content: 'I could not find that location. Try a more specific place name.' });
}
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${match.latitude}&longitude=${match.longitude}&current_weather=true&temperature_unit=${units}`;
const weather = await getJson(weatherUrl);
const current = weather.current_weather;
if (!current) {
return interaction.editReply({ content: 'Weather data is currently unavailable for that location.' });
}
const embed = new EmbedBuilder()
.setColor(0x3498db)
.setTitle(`Weather for ${match.name}${match.country ? `, ${match.country}` : ''}`)
.addFields(
{ name: 'Temperature', value: formatTemperature(current.temperature, units), inline: true },
{ name: 'Wind Speed', value: `${Math.round(current.windspeed)} km/h`, inline: true },
{ name: 'Conditions', value: `Weather code: ${current.weathercode}`, inline: true },
)
.setFooter({ text: `Latitude ${match.latitude.toFixed(2)}, Longitude ${match.longitude.toFixed(2)}` })
.setTimestamp(new Date(current.time));
return interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error(error);
return interaction.editReply({ content: 'Something went wrong fetching the weather. Please try again later.' });
}
},
};
+54
View File
@@ -0,0 +1,54 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const MAX_DESCRIPTION = 4096;
const truncate = (text, maxLength) => {
if (!text) return 'No summary available.';
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
};
module.exports = {
data: new SlashCommandBuilder()
.setName('wiki')
.setDescription('Search Wikipedia for a topic.')
.addStringOption(option =>
option
.setName('topic')
.setDescription('Topic to search')
.setRequired(true)),
category: 'Utility',
async execute(interaction) {
const topic = interaction.options.getString('topic');
const endpoint = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(topic)}`;
await interaction.deferReply();
const response = await fetch(endpoint, {
headers: {
'User-Agent': 'DiscordBot/1.0 (https://github.com)',
},
});
if (!response.ok) {
return interaction.editReply({
content: 'I could not find a Wikipedia summary for that topic.',
});
}
const data = await response.json();
const embed = new EmbedBuilder()
.setColor(0x8e44ad)
.setTitle(data.title ?? topic)
.setURL(data.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(topic)}`)
.setDescription(truncate(data.extract, MAX_DESCRIPTION))
.setFooter({ text: 'Source: Wikipedia' });
if (data.thumbnail?.source) {
embed.setThumbnail(data.thumbnail.source);
}
return interaction.editReply({ embeds: [embed] });
},
};
+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,
};
+861
View File
@@ -0,0 +1,861 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
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 { 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;
const MAX_MEMBER_QUERY_LENGTH = 64;
const JSON_BODY_LIMIT = '50kb';
const PERMISSION_BITS = {
ADMINISTRATOR: 0x8n,
MANAGE_GUILD: 0x20n,
};
const config = getConfig();
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,
);
const DASHBOARD_CLIENT_URL =
process.env.DASHBOARD_CLIENT_URL ||
config.dashboardClientUrl ||
config.clientUrl;
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: CONFIG_PATH,
configFileExists: fs.existsSync(CONFIG_PATH),
});
const DISCORD_API_BASE = 'https://discord.com/api';
const DISCORD_OAUTH_BASE = 'https://discord.com/oauth2';
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.';
const sessionStore = new Map();
const botGuildCache = {
guildIds: null,
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) {
return cookies;
}
headerValue.split(';').forEach((cookie) => {
const [rawName, ...rest] = cookie.trim().split('=');
if (!rawName) {
return;
}
cookies[rawName] = decodeURIComponent(rest.join('='));
});
return cookies;
};
const setCookie = (res, name, value, options = {}) => {
const directives = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge) {
directives.push(`Max-Age=${options.maxAge}`);
}
directives.push(`Path=${options.path || '/'}`);
if (options.httpOnly !== false) {
directives.push('HttpOnly');
}
if (options.sameSite) {
directives.push(`SameSite=${options.sameSite}`);
}
if (options.secure) {
directives.push('Secure');
}
res.append('Set-Cookie', directives.join('; '));
};
const clearCookie = (res, name) => {
setCookie(res, name, '', {maxAge: 0});
};
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, csrfToken};
};
const getSession = (sessionId) => {
const session = sessionStore.get(sessionId);
if (!session) {
return null;
}
if (session.expiresAt <= Date.now()) {
sessionStore.delete(sessionId);
return null;
}
return session;
};
const destroySession = (sessionId) => {
sessionStore.delete(sessionId);
};
const ensureOAuthConfig = () => {
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !DISCORD_REDIRECT_URI) {
return null;
}
return {
clientId: DISCORD_CLIENT_ID,
clientSecret: DISCORD_CLIENT_SECRET,
redirectUri: DISCORD_REDIRECT_URI,
};
};
const getDiscordStatusCode = (error) => {
const status = error?.response?.status;
return typeof status === 'number' ? status : null;
};
const sendDiscordApiError = (res, error, fallbackMessage) => {
const status = getDiscordStatusCode(error);
if (status === 401 || status === 403) {
return res.status(401).json({error: 'DISCORD_AUTH', message: 'Discord authentication expired'});
}
if (status === 429) {
return res.status(503).json({error: 'DISCORD_RATE_LIMIT', message: 'Discord rate limit exceeded'});
}
return res.status(502).json({error: 'DISCORD_REQUEST_FAILED', message: fallbackMessage});
};
const buildAuthorizationUrl = (state) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
response_type: 'code',
scope: 'identify guilds',
state,
prompt: 'consent',
});
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const fetchDiscordAccessToken = async (code) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: DISCORD_REDIRECT_URI,
});
const response = await axios.post(`${DISCORD_API_BASE}/oauth2/token`, params.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
};
const fetchDiscordUser = async (accessToken) => {
const response = await axios.get(`${DISCORD_API_BASE}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
};
const fetchDiscordUserGuilds = async (accessToken) => {
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return response.data;
};
const fetchDiscordGuildRoles = async (guildId) => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/roles`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const fetchDiscordGuildMembersSearch = async (guildId, query) => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/guilds/${guildId}/members/search`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
params: {
query,
limit: 20,
},
});
return response.data;
};
const fetchDiscordBotGuilds = async () => {
if (!DISCORD_BOT_TOKEN) {
throw new Error(BOT_TOKEN_ERROR_MESSAGE);
}
const response = await axios.get(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
});
return response.data;
};
const getCachedBotGuildIds = () => {
if (!Array.isArray(botGuildCache.guildIds)) {
return null;
}
if (!botGuildCache.fetchedAt) {
return null;
}
if (Date.now() - botGuildCache.fetchedAt > BOT_GUILD_CACHE_TTL_MS) {
return null;
}
return botGuildCache.guildIds;
};
const setCachedBotGuildIds = (guildIds) => {
botGuildCache.guildIds = guildIds;
botGuildCache.fetchedAt = Date.now();
};
const buildInviteUrl = (guildId) => {
if (!DISCORD_CLIENT_ID) {
return null;
}
const permissions = Number.isNaN(DASHBOARD_INVITE_PERMISSIONS) ? 0 : DASHBOARD_INVITE_PERMISSIONS;
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
scope: 'bot applications.commands',
permissions: String(permissions),
disable_guild_select: 'true',
});
if (guildId) {
params.set('guild_id', guildId);
}
return `${DISCORD_OAUTH_BASE}/authorize?${params.toString()}`;
};
const cacheSessionGuilds = (session, guilds) => {
session.guilds = guilds;
session.guildsFetchedAt = Date.now();
};
const getCachedGuilds = (session) => {
if (!Array.isArray(session.guilds)) {
return null;
}
if (!session.guildsFetchedAt) {
return null;
}
if (Date.now() - session.guildsFetchedAt > GUILD_CACHE_TTL_MS) {
return null;
}
return session.guilds;
};
const parsePermissionBits = (permissionValue) => {
if (!permissionValue) {
return 0n;
}
try {
return BigInt(permissionValue);
} catch (error) {
return 0n;
}
};
const isHttpsRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string' && forwardedProto.split(',')[0].trim() === 'https') {
return true;
}
return Boolean(config.cookieSecure);
};
const loadCommands = () => {
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,
});
}
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) => {
const permissions = parsePermissionBits(guild.permissions);
return (
(permissions & PERMISSION_BITS.ADMINISTRATOR) === PERMISSION_BITS.ADMINISTRATOR ||
(permissions & PERMISSION_BITS.MANAGE_GUILD) === PERMISSION_BITS.MANAGE_GUILD
);
};
const ensureSession = (req, res, next) => {
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies.dashboard_session;
if (!sessionId) {
return res.status(401).json({error: 'Unauthorized'});
}
const session = getSession(sessionId);
if (!session) {
return res.status(401).json({error: 'Unauthorized'});
}
req.session = session;
req.sessionId = sessionId;
return next();
};
const ensureManageableGuild = async (req, res, next) => {
try {
let guilds = getCachedGuilds(req.session);
if (!guilds) {
guilds = await fetchDiscordUserGuilds(req.session.accessToken);
cacheSessionGuilds(req.session, guilds);
}
const guild = guilds.find((entry) => entry.id === req.params.guildId);
if (!guild) {
return res.status(404).json({error: 'Guild not found'});
}
if (!canManageGuild(guild)) {
return res.status(403).json({error: 'Forbidden'});
}
req.guild = guild;
return next();
} catch (error) {
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
};
const createDashboardApp = () => {
const app = express();
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',
}));
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'});
}
const state = crypto.randomBytes(16).toString('hex');
setCookie(res, 'oauth_state', state, {
httpOnly: true,
sameSite: 'Lax',
secure: isHttpsRequest(req),
maxAge: 300,
});
return res.redirect(buildAuthorizationUrl(state));
});
app.get('/auth/discord/callback', authLimiter, async (req, res) => {
if (!ensureOAuthConfig()) {
return res.status(500).json({error: 'Missing Discord OAuth configuration'});
}
const {code, state} = req.query;
const cookies = parseCookies(req.headers.cookie);
if (!code || !state || cookies.oauth_state !== state) {
return res.status(400).json({error: 'Invalid OAuth state'});
}
try {
const tokenData = await fetchDiscordAccessToken(code);
const user = await fetchDiscordUser(tokenData.access_token);
const session = createSession({
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type,
scope: tokenData.scope,
user,
});
setCookie(res, 'dashboard_session', session.sessionId, {
httpOnly: true,
sameSite: 'Lax',
secure: isHttpsRequest(req),
maxAge: Math.floor((session.expiresAt - Date.now()) / 1000),
});
clearCookie(res, 'oauth_state');
if (DASHBOARD_CLIENT_URL) {
return res.redirect(DASHBOARD_CLIENT_URL);
}
if (req.accepts('html')) {
return res.redirect('/dashboard.html');
}
return res.status(200).json({user, csrfToken: session.csrfToken});
} catch (error) {
console.error('Discord OAuth failed', {
message: error.message,
status: error.response?.status,
data: error.response?.data?.error || error.response?.data?.error_description,
});
return res.status(502).json({
error: 'Failed to authenticate with Discord',
details: error.response?.data?.error_description || error.message,
});
}
});
app.post('/auth/logout', authLimiter, ensureSession, ensureCsrf, (req, res) => {
destroySession(req.sessionId);
clearCookie(res, 'dashboard_session');
return res.status(204).send();
});
app.get('/api/me', ensureSession, (req, res) => {
return res.json({
user: req.session.user,
expiresAt: req.session.expiresAt,
csrfToken: req.session.csrfToken,
});
});
app.get('/api/guilds', ensureSession, async (req, res) => {
try {
const guilds = await fetchDiscordUserGuilds(req.session.accessToken);
cacheSessionGuilds(req.session, guilds);
const manageableGuilds = guilds.filter(canManageGuild);
return res.json({guilds: manageableGuilds});
} catch (error) {
return sendDiscordApiError(res, error, 'Failed to fetch guilds');
}
});
app.get('/api/bot/guilds', ensureSession, async (req, res) => {
try {
const cached = getCachedBotGuildIds();
if (cached) {
return res.json({guildIds: cached});
}
const guilds = await fetchDiscordBotGuilds();
const guildIds = Array.isArray(guilds) ? guilds.map((guild) => guild.id) : [];
setCachedBotGuildIds(guildIds);
return res.json({guildIds});
} catch (error) {
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch bot guilds');
}
});
app.get('/api/invite-url', ensureSession, (req, res) => {
if (!DISCORD_CLIENT_ID) {
return res.status(500).json({error: 'MISSING_CLIENT_ID', message: 'Discord client ID not configured.'});
}
const inviteUrl = buildInviteUrl(req.query.guildId);
return res.json({inviteUrl});
});
app.get(
'/api/guilds/:guildId/permissions',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const permissions = await getGuildPermissions(req.params.guildId);
return res.json({permissions});
} catch (error) {
console.error('Failed to fetch guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
app.patch(
'/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 || {},
});
return res.json({permissions});
} catch (error) {
console.error('Failed to update guild permissions', {
guildId: req.params.guildId,
message: error.message,
});
return res.status(503).json({error: 'Permissions store unavailable'});
}
},
);
app.get(
'/api/guilds/:guildId/roles',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const roles = await fetchDiscordGuildRoles(req.params.guildId);
return res.json({roles});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to fetch roles');
}
},
);
app.get(
'/api/guilds/:guildId/members',
ensureSession,
ensureManageableGuild,
async (req, res) => {
const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
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 || [])
.map((member) => {
const user = member.user || {};
if (!user.id) {
return null;
}
return {
id: user.id,
user: {
id: user.id,
username: user.username || '',
discriminator: user.discriminator || '',
global_name: user.global_name || '',
avatar: user.avatar || '',
},
nick: member.nick || '',
};
})
.filter(Boolean);
return res.json({members: normalized});
} catch (error) {
const status = getDiscordStatusCode(error);
if (status === 403 || status === 404) {
return res.status(409).json({
error: 'BOT_NOT_IN_GUILD',
message: 'Bot not added to this guild.',
inviteUrl: buildInviteUrl(req.params.guildId),
});
}
if (error.message === BOT_TOKEN_ERROR_MESSAGE) {
return res.status(503).json({error: 'BOT_TOKEN_MISSING', message: 'Discord bot token not configured.'});
}
return sendDiscordApiError(res, error, 'Failed to search guild members');
}
},
);
app.get(
'/api/guilds/:guildId/commands',
ensureSession,
ensureManageableGuild,
(req, res) => {
const commands = loadCommands();
return res.json({commands});
},
);
app.get(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
async (req, res) => {
try {
const warnings = await getWarnings({
guildId: req.params.guildId,
userId: req.params.userId,
});
const normalizedWarnings = warnings.map((warning) => ({
...warning,
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
}));
return res.json({warnings: normalizedWarnings});
} catch (error) {
return res.status(500).json({error: 'Failed to fetch warnings'});
}
},
);
app.post(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
ensureCsrf,
async (req, res) => {
const {reason} = req.body || {};
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.trim(),
});
const warnings = (result.warnings || []).map((warning) => ({
...warning,
createdAt: warning.createdAt ? new Date(warning.createdAt).toISOString() : null,
}));
const warningEntry = result.warningEntry
? {
...result.warningEntry,
createdAt: result.warningEntry.createdAt
? new Date(result.warningEntry.createdAt).toISOString()
: null,
}
: null;
return res.status(201).json({
...result,
warnings,
warningEntry,
});
} catch (error) {
return res.status(500).json({error: 'Failed to add warning'});
}
},
);
app.delete(
'/api/guilds/:guildId/warnings/:userId',
ensureSession,
ensureManageableGuild,
ensureCsrf,
async (req, res) => {
try {
const warnings = await clearWarnings({
guildId: req.params.guildId,
userId: req.params.userId,
});
return res.json({warnings});
} catch (error) {
return res.status(500).json({error: 'Failed to clear warnings'});
}
},
);
return app;
};
if (require.main === module) {
const port = Number.parseInt(process.env.DASHBOARD_PORT || 47823, 10);
const app = createDashboardApp();
app.listen(port, () => {
console.log(`Dashboard API listening on port ${port}`);
});
}
module.exports = {
createDashboardApp,
};
+8 -8
View File
@@ -1,27 +1,27 @@
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) {
// Create a new MongoClient only once const mongodbUri = getConfigValue('MONGODB_URI', 'mongodb', 'mongodbUri');
dbClient = new MongoClient(mongodb, { 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,
deprecationErrors: true, deprecationErrors: true,
} }
}); });
await dbClient.connect(); await dbClient.connect();
console.log("Connected to MongoDB!"); console.log("Connected to MongoDB!");
await dbClient.db("admin").command({ping: 1});
await dbClient.db("admin").command({ ping: 1 });
console.log("Pinged your deployment. You successfully connected to MongoDB!"); console.log("Pinged your deployment. You successfully connected to MongoDB!");
} }
// Return the existing connection if already connected
return dbClient; return dbClient;
} }
+45 -37
View File
@@ -1,46 +1,54 @@
const { REST, Routes } = require('discord.js');
const { clientId, guildId, token } = require('./config.json');
const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { REST, Routes } = require('discord.js');
const { getConfigValue } = require('./config');
const { loadCommandModules } = require('./loaders/commands');
const commands = []; const commandsPath = path.join(__dirname, 'commands');
// Grab all the command folders from the commands directory you created earlier const {
const foldersPath = path.join(__dirname, 'commands'); commands,
const commandFolders = fs.readdirSync(foldersPath); 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 folder of commandFolders) { for (const filePath of invalidCommands) {
// Grab all the command files from the commands directory you created earlier console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
const commandsPath = path.join(foldersPath, folder); }
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment for (const { commandName, filePath, existingFilePath } of duplicateCommands) {
for (const file of commandFiles) { console.error(
const filePath = path.join(commandsPath, file); `[ERROR] Duplicate command name "${commandName}" in ${filePath}. Already registered by ${existingFilePath}.`,
const command = require(filePath); );
if ('data' in command && 'execute' in command) { }
commands.push(command.data.toJSON());
} else { for (const { filePath, error } of loadErrors) {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); 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);
} }
// Construct and prepare an instance of the REST module
const rest = new REST().setToken(token); const rest = new REST().setToken(token);
// and deploy your commands!
(async () => { (async () => {
try { try {
console.log(`Started refreshing ${commands.length} application (/) commands.`); console.log(`Started refreshing ${commandsData.length} application (/) commands.`);
const data = await rest.put(
// The put method is used to fully refresh all commands in the guild with the current set Routes.applicationCommands(clientId),
const data = await rest.put( { body: commandsData },
Routes.applicationCommands(clientId), );
{ body: commands }, console.log(`Successfully reloaded ${data.length} application (/) commands.`);
); } catch (error) {
console.error(error);
console.log(`Successfully reloaded ${data.length} application (/) commands.`); }
} catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error);
}
})(); })();
+25 -7
View File
@@ -1,26 +1,44 @@
const { Events, MessageFlags } = require('discord.js'); const {Events, MessageFlags} = require('discord.js');
const {isCommandAllowed} = require('../services/permissions');
module.exports = { module.exports = {
name: Events.InteractionCreate, name: Events.InteractionCreate,
async execute(interaction) { async execute(interaction) {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName); const command = interaction.client.commands.get(interaction.commandName);
if (!command) { if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`); console.error(`No command matching ${interaction.commandName} was found.`);
return; return;
} }
try { try {
const allowed = await isCommandAllowed({
guildId: interaction.guildId,
userId: interaction.user.id,
member: interaction.member,
commandName: interaction.commandName,
permissionGroup: command.permissionGroup,
});
if (!allowed) {
await interaction.reply({
content: 'You do not have permission to use this command.',
flags: MessageFlags.Ephemeral,
});
return;
}
await command.execute(interaction); await command.execute(interaction);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral }); await interaction.followUp({
content: 'There was an error while executing this command!',
flags: MessageFlags.Ephemeral
});
} else { } else {
await interaction.reply({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral }); await interaction.reply({
content: 'There was an error while executing this command!',
flags: MessageFlags.Ephemeral
});
} }
} }
}, },
}; };
+48 -5
View File
@@ -1,5 +1,6 @@
const { Events } = require('discord.js'); const { Events } = require('discord.js');
const { connectToDatabase } = require('../db.js'); const { connectToDatabase } = require('../db.js');
const { clearBanStatus } = require('../services/moderationRecords');
module.exports = { module.exports = {
name: Events.ClientReady, name: Events.ClientReady,
@@ -7,9 +8,16 @@ module.exports = {
const dbClient = await connectToDatabase(); const dbClient = await connectToDatabase();
const db = dbClient.db('discord'); const db = dbClient.db('discord');
const tempBanCollection = db.collection('tempban'); const tempBanCollection = db.collection('tempban');
const scheduledBanIds = new Set();
// 2) Define a function to schedule unbans let pollingInterval = null;
async function scheduleUnban(banData) { async function scheduleUnban(banData) {
const banId = banData?._id?.toString();
if (banId && scheduledBanIds.has(banId)) {
return;
}
if (banId) {
scheduledBanIds.add(banId);
}
const delay = banData.banTime - Date.now(); const delay = banData.banTime - Date.now();
if (delay <= 0) { if (delay <= 0) {
@@ -21,24 +29,59 @@ module.exports = {
await guild.bans.remove(banData.userId); await guild.bans.remove(banData.userId);
await tempBanCollection.deleteOne({ _id: banData._id }); await tempBanCollection.deleteOne({ _id: banData._id });
await clearBanStatus({
guildId: banData.guildId,
userId: banData.userId,
});
} catch (err) { } catch (err) {
console.error('Error unbanning member:', err); console.error('Error unbanning member:', err);
} finally {
if (banId) {
scheduledBanIds.delete(banId);
}
} }
}, delay); }, delay);
} }
try { async function loadAndScheduleBans() {
const allBans = await tempBanCollection.find({}).toArray(); const allBans = await tempBanCollection.find({}).toArray();
allBans.forEach(scheduleUnban); allBans.forEach(scheduleUnban);
}
tempBanCollection.watch().on('change', (change) => { function startPollingFallback() {
if (pollingInterval) {
return;
}
pollingInterval = setInterval(() => {
loadAndScheduleBans().catch((err) => {
console.error('Error polling temp bans:', err);
});
}, 60_000);
}
try {
await loadAndScheduleBans();
const changeStream = tempBanCollection.watch();
changeStream.on('change', (change) => {
if (change.operationType === 'insert') { if (change.operationType === 'insert') {
const newBan = change.fullDocument; const newBan = change.fullDocument;
scheduleUnban(newBan); scheduleUnban(newBan);
} }
}); });
changeStream.on('error', (err) => {
if (err?.code === 40573) {
startPollingFallback();
} else {
console.error('Change stream error:', err);
}
});
} catch (err) { } catch (err) {
console.error('Error setting up unban system:', err); if (err?.code === 40573) {
startPollingFallback();
} else {
console.error('Error setting up unban system:', err);
}
} }
}, },
}; };
+59 -25
View File
@@ -1,36 +1,59 @@
const fs = require('node:fs');
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 { 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] });
// Retreaves commands from the command folder
client.commands = new Collection(); client.commands = new Collection();
const foldersPath = path.join(__dirname, 'commands'); const commandsPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath); const {
commands,
duplicateCommands,
invalidCommands,
loadErrors: commandLoadErrors,
} = loadCommandModules(commandsPath);
const eventsPath = path.join(__dirname, 'events');
const {
events: eventModules,
invalidEvents,
loadErrors: eventLoadErrors,
} = loadEventModules(eventsPath);
for (const folder of commandFolders) { for (const command of commands) {
const commandsPath = path.join(foldersPath, folder); client.commands.set(command.data.name, command);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
} }
// Retreaves Events from the events folder for (const filePath of invalidCommands) {
const eventsPath = path.join(__dirname, 'events'); console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); }
for (const file of eventFiles) { for (const { commandName, filePath, existingFilePath } of duplicateCommands) {
const filePath = path.join(eventsPath, file); console.error(
const event = require(filePath); `[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) { if (event.once) {
client.once(event.name, (...args) => event.execute(...args)); client.once(event.name, (...args) => event.execute(...args));
} else { } else {
@@ -38,4 +61,15 @@ for (const file of eventFiles) {
} }
} }
client.login(token); 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);
+77
View File
@@ -0,0 +1,77 @@
const fs = require('node:fs');
const path = require('node:path');
const isJavaScriptFile = (file) => file.endsWith('.js');
const getCommandFilePaths = (commandsPath) => {
const commandFilePaths = [];
const entries = fs.readdirSync(commandsPath, { withFileTypes: true });
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) {
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,
duplicateCommands,
invalidCommands,
loadErrors,
};
};
module.exports = {
getCommandFilePaths,
loadCommandModules,
};
+47
View File
@@ -0,0 +1,47 @@
const fs = require('node:fs');
const path = require('node:path');
const isJavaScriptFile = (file) => file.endsWith('.js');
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);
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,
};
+1 -1397
View File
File diff suppressed because it is too large Load Diff
+3 -4
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": "",
@@ -11,12 +12,10 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"better-sqlite3": "^11.3.0",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"express": "^4.21.0", "express": "^4.21.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongodb": "^6.12.0", "mongodb": "^6.12.0",
"openai": "^4.67.3", "openai": "^4.67.3"
"sqlite3": "^5.1.7"
} }
} }
+179
View File
@@ -0,0 +1,179 @@
const createFetchError = async (response) => {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
const message = payload.message || payload.error || `Request failed with status ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.code = payload.error;
error.details = payload.details;
error.payload = payload;
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,
});
if (!response.ok) {
throw await createFetchError(response);
}
return response.json().catch(() => ({}));
};
const showToast = (message, type = 'success') => {
const container = document.getElementById('toastContainer');
if (!container) {
return;
}
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast--visible');
}, 10);
setTimeout(() => {
toast.classList.remove('toast--visible');
setTimeout(() => toast.remove(), 200);
}, 3200);
};
const setErrorBanner = (bannerEl, message, details) => {
if (!bannerEl) {
return;
}
const messageEl = bannerEl.querySelector('[data-role="error-message"]');
const copyButton = bannerEl.querySelector('[data-role="error-copy"]');
if (messageEl) {
messageEl.textContent = message;
}
if (copyButton) {
copyButton.onclick = async () => {
const payload = details || {message};
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
showToast('Error details copied.', 'success');
} catch (error) {
showToast('Unable to copy details.', 'error');
}
};
}
bannerEl.hidden = false;
};
const clearErrorBanner = (bannerEl) => {
if (!bannerEl) {
return;
}
bannerEl.hidden = true;
};
const formatUserDisplay = (user) => {
if (!user) {
return 'Not signed in';
}
const tag = user.discriminator && user.discriminator !== '0' ? `#${user.discriminator}` : '';
return `${user.username}${tag}`;
};
const debounce = (callback, wait = 300) => {
let timeoutId;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), wait);
};
};
const initIndexPage = async () => {
const sessionStatus = document.getElementById('sessionStatus');
const loginButton = document.getElementById('loginButton');
const dashboardButton = document.getElementById('dashboardButton');
const logoutButton = document.getElementById('logoutButton');
const banner = document.getElementById('banner');
if (!sessionStatus || !loginButton) {
return;
}
try {
const data = await fetchJson('/api/me');
setCsrfToken(data.csrfToken);
sessionStatus.textContent = formatUserDisplay(data.user);
loginButton.hidden = true;
dashboardButton.hidden = false;
logoutButton.hidden = false;
if (banner) {
banner.textContent = '';
banner.style.display = 'none';
}
} catch (error) {
sessionStatus.textContent = '';
loginButton.hidden = false;
dashboardButton.hidden = true;
logoutButton.hidden = true;
if (banner) {
const sessionExpired = error.status === 401 || error.status === 403;
banner.textContent = sessionExpired ? '' : 'Unable to check your session. Try again.';
banner.style.display = sessionExpired ? 'none' : 'block';
}
}
logoutButton?.addEventListener('click', async () => {
try {
await fetchJson('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
...(csrfToken ? {'X-CSRF-Token': csrfToken} : {}),
},
});
sessionStatus.textContent = '';
loginButton.hidden = false;
dashboardButton.hidden = true;
logoutButton.hidden = true;
} catch (error) {
if (banner) {
banner.textContent = error.message || 'Failed to log out.';
banner.style.display = 'block';
}
}
});
};
document.addEventListener('DOMContentLoaded', () => {
initIndexPage();
});
window.dashboardApi = {
fetchJson,
setCsrfToken,
getCsrfToken,
showToast,
setErrorBanner,
clearErrorBanner,
formatUserDisplay,
debounce,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 380" fill="none">
<defs>
<linearGradient id="brass" x1="141" y1="160" x2="344" y2="251" gradientUnits="userSpaceOnUse"><stop stop-color="#7b5d30"/><stop offset=".22" stop-color="#c6a366"/><stop offset=".43" stop-color="#f3ddb0"/><stop offset=".63" stop-color="#d4b77b"/><stop offset=".86" stop-color="#9f7a40"/><stop offset="1" stop-color="#6d4a23"/></linearGradient>
<linearGradient id="rim" x1="127" y1="258" x2="364" y2="278" gradientUnits="userSpaceOnUse"><stop stop-color="#806031"/><stop offset=".4" stop-color="#ead3a6"/><stop offset=".7" stop-color="#b7955a"/><stop offset="1" stop-color="#74512a"/></linearGradient>
<linearGradient id="arch" x1="260" y1="22" x2="260" y2="345" gradientUnits="userSpaceOnUse"><stop stop-color="#e8dec9"/><stop offset="1" stop-color="#e8dec9" stop-opacity=".1"/></linearGradient>
<radialGradient id="shadow"><stop stop-color="#483728" stop-opacity=".25"/><stop offset="1" stop-color="#483728" stop-opacity="0"/></radialGradient>
</defs>
<path d="M115 342V162a135 135 0 0 1 270 0v180" fill="url(#arch)"/>
<g stroke="#b69b6a" stroke-opacity=".45"><path d="M99 342V158a151 151 0 0 1 302 0v184"/><path d="M109 342V158a141 141 0 0 1 282 0v184"/><path d="M121 342V160a129 129 0 0 1 258 0v182"/><path d="M133 342V162a117 117 0 0 1 234 0v180"/><path d="M79 342h342"/></g>
<circle cx="250" cy="190" r="94" stroke="#b69b6a" stroke-opacity=".3" stroke-dasharray="2 5"/>
<ellipse cx="252" cy="292" rx="151" ry="24" fill="url(#shadow)"/>
<path d="M128 275h245l8 11H120z" fill="#493032"/>
<path d="M135 270h231l7 8H128z" fill="#6e4e45"/>
<rect x="135" y="259" width="231" height="13" rx="4" fill="url(#rim)"/>
<path d="M148 254c0-51 39-91 102-91s102 40 102 91z" fill="url(#brass)" stroke="#a07e44" stroke-width="1"/>
<path d="M157 249c2-42 31-73 76-78" stroke="#f6e5be" stroke-opacity=".65" stroke-width="2"/>
<path d="M143 253h214v8H143z" fill="url(#rim)"/>
<path d="M245 146h10v19h-10z" fill="url(#rim)"/>
<ellipse cx="250" cy="145" rx="18" ry="5" fill="url(#brass)" stroke="#ad8e54"/>
<path d="M239 213h22m-11-11v22" stroke="#755627" stroke-opacity=".48"/>
<g fill="#b09462"><path d="m411 93 3 9 9 3-9 3-3 9-3-9-9-3 9-3z"/><path d="m88 238 2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></g>
<path d="M433 215v65m-5-65h10m-10 65h10" stroke="#b69b6a" stroke-opacity=".6"/>
<text x="250" y="322" text-anchor="middle" fill="#8f7953" font-size="9" font-family="Georgia,serif" letter-spacing="4">THE STEWARD</text>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+407
View File
@@ -0,0 +1,407 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#302224" />
<title>Overview · The Steward</title>
<link rel="icon" href="mark.svg" type="image/svg+xml" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<a class="skip-link" href="#mainContent">Skip to content</a>
<div class="app-shell">
<aside class="sidebar" aria-label="Dashboard navigation">
<a class="brand" href="/" aria-label="The Steward home">
<img src="mark.svg" alt="" />
<span
><small>THE</small>Steward<span class="brand-period">.</span></span
>
</a>
<nav class="tabs" aria-label="Server management">
<button class="tab active" data-tab="settings" aria-current="page">
<svg><use href="icons.svg#grid" /></svg>Overview
</button>
<button class="tab" data-tab="permissions">
<svg><use href="icons.svg#key" /></svg>Permissions
<span class="nav-count" id="navCommandCount">–</span>
</button>
<button class="tab" data-tab="warnings">
<svg><use href="icons.svg#shield" /></svg>Warnings
</button>
</nav>
<div class="sidebar__section-label server-label">
SERVERS <span id="guildCount">–</span>
</div>
<div class="search-field sidebar-search">
<svg><use href="icons.svg#search" /></svg>
<input
type="search"
id="guildSearch"
aria-label="Search servers"
placeholder="Search servers"
/>
</div>
<div class="guild-list" id="guildList"></div>
<div class="skeleton-list" id="guildListSkeleton" hidden>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
<div class="empty-state" id="guildEmpty" hidden>
No servers available.
</div>
<div class="sidebar-bottom">
<div class="profile">
<div class="profile-avatar" id="userInitial">S</div>
<div class="profile__text">
<strong id="userDisplay">Loading…</strong
><span id="sessionExpiry">Discord account</span>
</div>
<button
class="icon-button"
id="logoutButton"
aria-label="Log out"
title="Log out"
>
<svg><use href="icons.svg#logout" /></svg>
</button>
</div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div class="breadcrumb">
Dashboard <span>/</span><strong id="currentPage">Overview</strong>
</div>
<a class="icon-button" href="/" aria-label="Home" title="Home"
><svg><use href="icons.svg#home" /></svg
></a>
</header>
<main class="content" id="mainContent" tabindex="-1">
<div class="error-banner" id="errorBanner" role="alert" hidden>
<p data-role="error-message"></p>
<button class="button button--ghost" data-role="error-copy">
Copy details
</button>
</div>
<section class="page-heading">
<h1 id="pageTitle">Overview</h1>
<div class="guild-header">
<span class="guild-avatar" id="guildHeaderFallback">S</span
><img id="guildHeaderIcon" alt="" hidden /><strong
id="guildHeaderName"
>Select a server</strong
>
</div>
</section>
<section
class="overview-stats"
id="overviewStats"
aria-label="Server summary"
>
<article class="overview-stat">
<div class="stat-heading">
<span>Bot status</span
><button
class="icon-button"
id="refreshBotButton"
aria-label="Refresh bot status"
title="Refresh bot status"
hidden
>
↻
</button>
</div>
<strong
class="stat-value stat-value--status"
id="overviewBotStatus"
>–</strong
>
</article>
<article class="overview-stat">
<div class="stat-heading">
<button class="text-link" data-open-tab="permissions">
Commands ↗</button
><svg><use href="icons.svg#terminal" /></svg>
</div>
<strong class="stat-value" id="overviewCommandCount">–</strong>
</article>
<article class="overview-stat">
<div class="stat-heading">
<span>Admin roles</span
><svg><use href="icons.svg#users" /></svg>
</div>
<strong class="stat-value" id="overviewRoleCount">–</strong>
</article>
<article class="overview-stat">
<div class="stat-heading">
<span>Restricted commands</span
><svg><use href="icons.svg#key" /></svg>
</div>
<strong class="stat-value" id="overviewRestrictedCount">–</strong>
</article>
</section>
<div class="callout" id="botInactiveCallout" hidden>
<svg><use href="icons.svg#info" /></svg>
<p>Invite the bot to edit settings.</p>
<button class="button button--primary" id="inviteButton" hidden>
Invite bot ↗
</button>
</div>
<div class="empty-state panel" id="workspaceEmpty" hidden>
Select a server to get started.
</div>
<section
class="settings-layout"
id="settingsPanel"
aria-labelledby="adminTitle"
hidden
>
<div class="panel admin-panel">
<div class="panel__header">
<div class="section-title">
<span class="section-icon"
><svg><use href="icons.svg#users" /></svg
></span>
<h2 id="adminTitle">Admin access</h2>
</div>
</div>
<div class="admin-fields">
<div class="field">
<span id="adminRolesLabel">Roles</span>
<div id="adminRolesPicker"></div>
</div>
<div class="field">
<label for="adminUserInput">Members</label>
<div class="input-row">
<input
type="text"
id="adminUserInput"
placeholder="Discord user IDs, comma-separated"
/><button
class="button button--soft"
id="addAdminUserButton"
>
Add
</button>
</div>
<div class="chip-list" id="adminUserChips"></div>
</div>
</div>
<div class="panel-footnote">
<svg><use href="icons.svg#info" /></svg>Admins bypass command
restrictions.
</div>
</div>
</section>
<section
class="panel"
id="permissionsPanel"
aria-labelledby="permissionsTitle"
hidden
>
<div class="panel__header panel__header--split">
<h2 id="permissionsTitle">Commands</h2>
<div class="button-row">
<button class="button button--ghost" id="clearAllCommands">
Reset restrictions</button
><button class="button" id="copyFromGuild">
<svg><use href="icons.svg#copy" /></svg>Copy from server
</button>
</div>
</div>
<div class="stats-grid">
<article class="stat-card">
<span>Total</span><strong id="permissionTotalCount">0</strong>
</article>
<article class="stat-card">
<span>Restricted</span
><strong id="permissionRestrictedCount">0</strong>
</article>
<article class="stat-card">
<span>Default access</span
><strong id="permissionOpenCount">0</strong>
</article>
<article class="stat-card">
<span>Unsaved</span
><strong id="permissionChangedCount">0</strong>
</article>
</div>
<div class="toolbar">
<div class="search-field">
<svg><use href="icons.svg#search" /></svg
><input
type="search"
id="commandSearch"
aria-label="Search commands"
placeholder="Search commands"
/>
</div>
<select id="commandFilter" aria-label="Filter commands">
<option value="all">All commands</option>
<option value="restricted">Restricted</option>
<option value="open">Default access</option>
<option value="changed">Unsaved</option>
</select>
</div>
<p class="field-hint">Discord permissions still apply.</p>
<div class="command-list" id="commandsList"></div>
<div class="empty-state" id="commandsEmpty" hidden>
No matching commands.
</div>
<div class="custom-command">
<label for="newCommandInput">Custom command</label>
<div class="input-row">
<input
type="text"
id="newCommandInput"
placeholder="command-name"
maxlength="32"
/><button class="button" id="addCommandButton">Add</button>
</div>
</div>
</section>
<section
class="warnings-layout"
id="warningsPanel"
aria-label="Member warnings"
hidden
>
<div class="panel warnings-pane">
<div class="panel__header"><h2>Member</h2></div>
<div class="field">
<label for="warningsUserSearch">Search</label>
<div class="search-field">
<svg><use href="icons.svg#search" /></svg
><input
type="search"
id="warningsUserSearch"
placeholder="Member name"
maxlength="64"
/>
</div>
<span
class="field-hint"
id="warningsSearchStatus"
role="status"
></span>
</div>
<div class="member-list" id="warningsUserResults"></div>
<div class="field">
<label for="warningsUserInput">Or enter a user ID</label
><input
type="text"
id="warningsUserInput"
placeholder="123456789012345678"
inputmode="numeric"
/>
</div>
<div class="selected-user-card" id="warningsSelectedCard" hidden>
<strong id="warningsSelectedUser"></strong
><span class="field-hint" id="warningsSelectedHint"></span>
</div>
<div class="button-row warning-actions">
<button class="button button--dark" id="fetchWarningsButton">
Load history</button
><button class="button button--danger" id="clearWarningsButton">
Clear warnings
</button>
</div>
<div
class="spinner"
id="warningsSpinner"
aria-label="Loading warnings"
hidden
></div>
</div>
<div class="panel warnings-pane">
<div class="panel__header"><h2>History</h2></div>
<div class="stats-grid stats-grid--compact">
<article class="stat-card">
<span>Warnings</span
><strong id="warningsTotalCount">–</strong>
</article>
<article class="stat-card">
<span>Latest</span
><strong class="stat-value--small" id="warningsLatestDate"
>–</strong
>
</article>
</div>
<div class="empty-state" id="warningsEmpty">
Select a member to view warnings.
</div>
<ul id="warningsList"></ul>
<div class="divider"></div>
<div class="field">
<label for="warningReasonInput">Reason</label
><textarea
id="warningReasonInput"
placeholder="Reason for warning"
maxlength="500"
rows="4"
></textarea>
<div class="field__meta">
<span id="warningStatus" role="status"></span
><span class="field-hint" id="warningCharCount">0/500</span>
</div>
</div>
<button class="button button--primary" id="addWarningButton">
Add warning
</button>
</div>
</section>
<div class="save-bar" id="saveBar" role="status" hidden>
<div>
<span class="save-dot"></span><strong>Unsaved changes</strong>
</div>
<div class="button-row">
<button class="button button--ghost" id="discardChangesButton">
Discard</button
><button
class="button button--primary"
id="savePermissionsButton"
>
Save changes <svg><use href="icons.svg#check" /></svg>
</button>
</div>
</div>
<div
class="panel skeleton-panel"
id="contentSkeleton"
aria-label="Loading server"
hidden
>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</main>
</div>
</div>
<div
class="toast-container"
id="toastContainer"
role="status"
aria-live="polite"
></div>
<dialog class="modal" id="copyModal" aria-labelledby="copyTitle">
<div class="modal__content">
<h2 id="copyTitle">Copy permissions</h2>
<label for="copyGuildSelect">From server</label
><select id="copyGuildSelect"></select>
<p class="field-hint">Replace unavailable roles before saving.</p>
<div class="button-row">
<button class="button button--ghost" id="cancelCopyButton">
Cancel</button
><button class="button button--primary" id="confirmCopyButton">
Copy
</button>
</div>
</div>
</dialog>
<script src="app.js"></script>
<script src="dashboard.js"></script>
</body>
</html>
+1083
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="key" viewBox="0 0 24 24"><circle cx="8" cy="8" r="5"/><path d="m12 12 9 9m-6-6 3-3m0 6 3-3"/></symbol>
<symbol id="shield" viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6zM9 12l2 2 4-4"/></symbol>
<symbol id="search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M16 16l5 5"/></symbol>
<symbol id="spark" viewBox="0 0 24 24"><path d="m12 2 2.8 7.2L22 12l-7.2 2.8L12 22l-2.8-7.2L2 12l7.2-2.8z"/></symbol>
<symbol id="bell" viewBox="0 0 24 24"><path d="M3 18h18M5 15a7 7 0 0 1 14 0zm7-7V4m-2 0h4M2 21h20"/></symbol>
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 4H4v16h5m5-12 4 4-4 4m-5-4h12"/></symbol>
<symbol id="home" viewBox="0 0 24 24"><path d="M3 10l9-7 9 7M5 9v12h14V9M9 21v-8h6v8"/></symbol>
<symbol id="activity" viewBox="0 0 24 24"><path d="M2 12h5l3-8 4 16 3-8h5"/></symbol>
<symbol id="terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3m7 0h3"/></symbol>
<symbol id="users" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M16 5a3 3 0 0 1 0 6m2 4a5 5 0 0 1 3 5"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v.1"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="discord" viewBox="0 0 24 24"><path d="M8 5 4 6C2 9 1 13 2 17l5 2 1-2m8-12 4 1c2 3 3 7 2 11l-5 2-1-2M7 7c3-1 7-1 10 0M6 16c4 2 8 2 12 0"/><circle cx="8" cy="12" r="1.5"/><circle cx="16" cy="12" r="1.5"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#302224" />
<title>The Steward</title>
<link rel="icon" href="mark.svg" type="image/svg+xml" />
<link rel="stylesheet" href="styles.css" />
</head>
<body class="landing-body">
<div class="landing-shell">
<header class="landing-nav">
<a class="brand" href="/" aria-label="The Steward home"
><img src="mark.svg" alt="" /><span
><small>THE</small>Steward<span class="brand-period">.</span></span
></a
>
</header>
<main class="landing-main">
<div class="landing-copy">
<h1>Welcome.</h1>
<p class="landing-description">Manage your Discord servers.</p>
<div class="button-row">
<a
class="button button--primary"
href="/auth/discord"
id="loginButton"
><svg><use href="icons.svg#discord" /></svg>Sign in with Discord
<span>↗</span></a
>
<a
class="button button--dark"
href="/dashboard.html"
id="dashboardButton"
hidden
>Open dashboard <span>↗</span></a
>
</div>
<div class="landing-session">
<span id="sessionStatus">Checking session…</span
><button class="text-link" id="logoutButton" hidden>Log out</button>
</div>
<div class="banner" id="banner" role="status"></div>
</div>
<div class="landing-art">
<img src="concierge.svg" alt="Brass service bell" />
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="5" fill="#bd9b61"/><g stroke="#302224" stroke-width="1.8" stroke-linecap="round" fill="none"><path d="M10 32h28M13 29a11 11 0 0 1 22 0zM24 18v-4m-3 0h6M8 36h32"/></g></svg>

After

Width:  |  Height:  |  Size: 266 B

+1837
View File
File diff suppressed because it is too large Load Diff
+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);
+45
View File
@@ -0,0 +1,45 @@
const { connectToDatabase } = require('../db');
const DEFAULT_GUILD_SETTINGS = {
moderation: {
strikeThresholds: {
mute: 2,
kick: 3,
ban: 5,
},
muteDurationMinutes: 30,
banDurationMs: 0,
},
};
const normalizeGuildSettings = (settings = {}) => {
const moderation = settings.moderation ?? {};
const strikeThresholds = moderation.strikeThresholds ?? {};
return {
...DEFAULT_GUILD_SETTINGS,
...settings,
moderation: {
...DEFAULT_GUILD_SETTINGS.moderation,
...moderation,
strikeThresholds: {
...DEFAULT_GUILD_SETTINGS.moderation.strikeThresholds,
...strikeThresholds,
},
},
};
};
const getGuildSettings = async (guildId) => {
if (!guildId) {
return normalizeGuildSettings();
}
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('guild_settings');
const settings = await collection.findOne({ guildId });
return normalizeGuildSettings(settings);
};
module.exports = {
getGuildSettings,
};
+91
View File
@@ -0,0 +1,91 @@
const { connectToDatabase } = require('../db');
const MODERATION_COLLECTION = 'moderation_records';
const getModerationCollection = async () => {
const client = await connectToDatabase();
return client.db('discord').collection(MODERATION_COLLECTION);
};
const updateStrikes = async ({ guildId, userId, strikes }) => {
const collection = await getModerationCollection();
const now = new Date();
return collection.findOneAndUpdate(
{ guildId, userId },
{
$set: {
strikes,
lastWarnedAt: now,
updatedAt: now,
},
$setOnInsert: {
guildId,
userId,
actions: [],
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after' },
);
};
const recordModerationAction = async ({
guildId,
userId,
action,
moderatorId,
reason,
metadata = {},
stateUpdates = {},
}) => {
const collection = await getModerationCollection();
const now = new Date();
const actionEntry = {
action,
moderatorId,
reason,
metadata,
createdAt: now,
};
return collection.findOneAndUpdate(
{ guildId, userId },
{
$push: { actions: actionEntry },
$set: {
lastAction: action,
lastActionAt: now,
updatedAt: now,
...stateUpdates,
},
$setOnInsert: {
guildId,
userId,
strikes: 0,
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after' },
);
};
const clearBanStatus = async ({ guildId, userId }) =>
recordModerationAction({
guildId,
userId,
action: 'unban',
moderatorId: null,
reason: 'Temporary ban expired',
metadata: {},
stateUpdates: {
isBanned: false,
bannedUntil: null,
},
});
module.exports = {
updateStrikes,
recordModerationAction,
clearBanStatus,
};
+132
View File
@@ -0,0 +1,132 @@
const { connectToDatabase } = require('../db');
const DEFAULT_GUILD_PERMISSIONS = {
adminRoleIds: [],
adminUserIds: [],
commandPermissions: {},
};
const COMMAND_NAME_REGEX = /^[a-z0-9_-]{1,32}$/i;
const normalizeIdArray = (value) => {
if (!Array.isArray(value)) {
return [];
}
const normalized = value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
return Array.from(new Set(normalized));
};
const sanitizeCommandPermissions = (commandPermissions = {}) => {
if (!commandPermissions || typeof commandPermissions !== 'object') {
return {};
}
const sanitized = {};
for (const [commandName, commandConfig] of Object.entries(commandPermissions)) {
if (!COMMAND_NAME_REGEX.test(commandName)) {
continue;
}
sanitized[commandName] = {
allowedRoleIds: normalizeIdArray(commandConfig?.allowedRoleIds),
allowedUserIds: normalizeIdArray(commandConfig?.allowedUserIds),
};
}
return sanitized;
};
const normalizePermissionConfig = (config = {}) => {
const safeConfig = config && typeof config === 'object' ? config : {};
return {
...DEFAULT_GUILD_PERMISSIONS,
adminRoleIds: normalizeIdArray(safeConfig.adminRoleIds),
adminUserIds: normalizeIdArray(safeConfig.adminUserIds),
commandPermissions: sanitizeCommandPermissions(safeConfig.commandPermissions),
};
};
const memberHasRole = (member, roleIds) =>
roleIds.some((roleId) => member.roles?.cache?.has(roleId));
const getGuildPermissions = async (guildId) => {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('guild_permissions');
const config = await collection.findOne({ guildId });
return normalizePermissionConfig(config);
};
const updateGuildPermissions = async ({ guildId, permissions }) => {
if (!guildId) {
throw new Error('Guild ID is required');
}
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('guild_permissions');
const normalizedPermissions = normalizePermissionConfig(permissions);
const payload = {
guildId,
...normalizedPermissions,
updatedAt: new Date(),
};
await collection.updateOne({ guildId }, { $set: payload }, { upsert: true });
return payload;
};
const isCommandAllowed = async ({
guildId,
userId,
member,
commandName,
permissionGroup,
}) => {
if (!guildId) return true;
const permissions = await getGuildPermissions(guildId);
const isAdmin =
permissions.adminUserIds.includes(userId) ||
(member && memberHasRole(member, permissions.adminRoleIds));
if (isAdmin) {
return true;
}
const commandPermissions = permissions.commandPermissions?.[commandName];
if (!commandPermissions) {
return true;
}
const allowedUserIds = Array.isArray(commandPermissions.allowedUserIds)
? commandPermissions.allowedUserIds
: [];
const allowedRoleIds = Array.isArray(commandPermissions.allowedRoleIds)
? commandPermissions.allowedRoleIds
: [];
if (allowedUserIds.length === 0 && allowedRoleIds.length === 0) {
return true;
}
if (allowedUserIds.includes(userId)) {
return true;
}
return member ? memberHasRole(member, allowedRoleIds) : false;
};
module.exports = {
getGuildPermissions,
updateGuildPermissions,
isCommandAllowed,
};
+60
View File
@@ -0,0 +1,60 @@
const { connectToDatabase } = require('../db');
const { updateStrikes } = require('./moderationRecords');
const WARNING_COLLECTION = 'warnings';
const getWarningsCollection = async () => {
const client = await connectToDatabase();
return client.db('discord').collection(WARNING_COLLECTION);
};
const addWarning = async ({ guildId, userId, moderatorId, reason }) => {
const collection = await getWarningsCollection();
const warningEntry = {
reason,
moderatorId,
createdAt: new Date(),
};
const result = await collection.findOneAndUpdate(
{ guildId, userId },
{
$push: { warnings: warningEntry },
$setOnInsert: { guildId, userId },
},
{ upsert: true, returnDocument: 'after' },
);
const warnings = result.value?.warnings ?? [];
const strikeCount = warnings.length;
await updateStrikes({ guildId, userId, strikes: strikeCount });
return { warnings, warningEntry, strikeCount };
};
const getWarnings = async ({ guildId, userId }) => {
const collection = await getWarningsCollection();
const record = await collection.findOne({ guildId, userId });
return record?.warnings ?? [];
};
const clearWarnings = async ({ guildId, userId }) => {
const collection = await getWarningsCollection();
const result = await collection.findOneAndUpdate(
{ guildId, userId },
{ $set: { warnings: [] } },
{ returnDocument: 'after', upsert: true },
);
await updateStrikes({ guildId, userId, strikes: 0 });
return result.value?.warnings ?? [];
};
module.exports = {
addWarning,
getWarnings,
clearWarnings,
};