Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a7d056fc8 | |||
| e9d34c28fc | |||
| 1aa83d83f9 | |||
| 709bba63a6 | |||
| a7f8263931 | |||
| 65a281dbcb | |||
| 4c806c787f | |||
| e90b8b4d85 | |||
| c6204e1941 | |||
| 0bcf0829c8 | |||
| 7c85ab2500 | |||
| 097434d7be | |||
| 10caf20369 | |||
| ac120bc4cb | |||
| 088629a3db | |||
| e57ad15776 | |||
| 39cd79ed51 | |||
| 53b828e801 | |||
| 44719bb2b5 | |||
| 7a90f51449 | |||
| faca78619d | |||
| ad5f4593cd | |||
| b3cf987778 | |||
| 4fe1831386 | |||
| 06fdd57aa0 | |||
| cbf9c112a4 | |||
| f38b8a2199 | |||
| 5905b16d50 | |||
| d4a34fdc34 | |||
| 41949fe596 | |||
| ca07a0ed3b | |||
| f610d7a922 | |||
| bb13f086df | |||
| 4f406a7cd6 | |||
| b70c177a46 | |||
| 01f7289fb5 | |||
| e32948d1ae | |||
| 23a1e41933 | |||
| 923c96af34 | |||
| f743262991 | |||
| 551b4adb0e | |||
| b6a7d35ce3 | |||
| f234a56dd7 | |||
| cdcb280bcc | |||
| 6a5777f40e | |||
| 9c8013523c | |||
| 0ebb7e3dea | |||
| 31e9169231 | |||
| 2edcf2ccf6 | |||
| 822b1a2910 | |||
| 5519548802 | |||
| 7f56996ed3 | |||
| ea3a368367 | |||
| 37cae6cc1a | |||
| 6a1c81fa3c | |||
| 0666c9263d | |||
| 6dc3f2632a | |||
| 7d2fdfcafe | |||
| aba21131b4 | |||
| 4e4fb82ceb | |||
| ad6642e3b0 | |||
| ad19510aae | |||
| 7321c1960f | |||
| a1d469af4d | |||
| ef7ca16a85 | |||
| a41a81f367 | |||
| 1ace32dd00 | |||
| ca5d6cdd37 | |||
| a86c90f8ce | |||
| 63a7abfe03 | |||
| 10b33a79b3 | |||
| 7561fa88d1 | |||
| 7cdd48d696 | |||
| 0c36960645 | |||
| 022e09a1f3 | |||
| 60d52b220a | |||
| 41d8c11a40 | |||
| e48b63ed74 | |||
| 766d21656e | |||
| 8483c490cb | |||
| 7662337553 | |||
| 0d265b0fb5 | |||
| ea0507dd16 | |||
| e89cc46413 | |||
| f7ac4773ac | |||
| 6336053d41 |
@@ -0,0 +1,4 @@
|
|||||||
|
config.json
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
.idea/
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
"warn",
|
||||||
|
"warnings",
|
||||||
|
"ban",
|
||||||
|
"kick",
|
||||||
|
"mute",
|
||||||
|
"unmute"
|
||||||
|
]
|
||||||
@@ -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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+23
-18
@@ -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
@@ -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.');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
+37
-12
@@ -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}`,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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}.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}**.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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!`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
+123
-20
@@ -1,42 +1,145 @@
|
|||||||
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(', ')}**.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
+31
-12
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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}¤t_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.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"clientId": "YOUR_DISCORD_CLIENT_ID",
|
||||||
|
"clientSecret": "YOUR_DISCORD_CLIENT_SECRET",
|
||||||
|
"mongodb": "mongodb://127.0.0.1:27017/botdb",
|
||||||
|
"redirectUri": "http://localhost:3000/auth/discord/callback",
|
||||||
|
"dashboardInvitePermissions": "0",
|
||||||
|
"dashboardSessionTtlMs": "86400000",
|
||||||
|
"dashboardClientUrl": "http://localhost:3000",
|
||||||
|
"trustProxy": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||||
|
let cachedConfig = null;
|
||||||
|
|
||||||
|
const loadConfig = () => {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(CONFIG_PATH)) {
|
||||||
|
cachedConfig = {};
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
cachedConfig = parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse config.json', { message: error.message });
|
||||||
|
cachedConfig = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfig = () => loadConfig();
|
||||||
|
|
||||||
|
const getConfigValue = (envKey, ...configKeys) => {
|
||||||
|
const envValue = process.env[envKey];
|
||||||
|
if (typeof envValue === 'string' && envValue.trim() !== '') {
|
||||||
|
return envValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
for (const key of configKeys) {
|
||||||
|
const value = config?.[key];
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_PATH,
|
||||||
|
getConfig,
|
||||||
|
getConfigValue,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (duplicateCommands.length > 0 || commandLoadErrors.length > 0 || eventLoadErrors.length > 0) {
|
||||||
|
console.error('Startup aborted because one or more modules failed validation.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getConfigValue('DISCORD_BOT_TOKEN', 'token', 'discordBotToken');
|
||||||
|
if (!token) {
|
||||||
|
console.error('Missing Discord bot token. Set DISCORD_BOT_TOKEN or config.json token.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
client.login(token);
|
client.login(token);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Generated
+1
-1397
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -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
@@ -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 |
@@ -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 |
@@ -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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -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 |
@@ -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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const { Collection, MessageFlags, PermissionFlagsBits } = require('discord.js');
|
||||||
|
const records = require('../services/moderationRecords');
|
||||||
|
const permissions = require('../services/permissions');
|
||||||
|
|
||||||
|
const makeMember = (id, { position = 1, hasRole = true, bannable = true, canBan = true } = {}) => ({
|
||||||
|
id,
|
||||||
|
bannable,
|
||||||
|
permissions: { has: permission => permission === PermissionFlagsBits.BanMembers && canBan },
|
||||||
|
roles: {
|
||||||
|
cache: new Collection(hasRole ? [['role', {}]] : []),
|
||||||
|
highest: { position, comparePositionTo(other) { return this.position - other.position; } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createHarness = (t, options = {}) => {
|
||||||
|
const members = options.members || [makeMember('target')];
|
||||||
|
const moderator = makeMember('moderator', { position: 10, canBan: options.moderatorCanBan !== false });
|
||||||
|
const bot = makeMember('bot', { position: 20, canBan: options.botCanBan !== false });
|
||||||
|
const savedRecords = [];
|
||||||
|
const bans = [];
|
||||||
|
const banAttempts = [];
|
||||||
|
const edits = [];
|
||||||
|
const replies = [];
|
||||||
|
const listCalls = [];
|
||||||
|
const fetchCalls = [];
|
||||||
|
let filter;
|
||||||
|
|
||||||
|
t.mock.method(console, 'error', () => {});
|
||||||
|
t.mock.method(records, 'recordModerationAction', async (record) => {
|
||||||
|
savedRecords.push(record);
|
||||||
|
if (options.logFailure) throw new Error('Database unavailable');
|
||||||
|
});
|
||||||
|
t.mock.method(permissions, 'isCommandAllowed', async () => options.allowed !== false);
|
||||||
|
delete require.cache[require.resolve('../commands/moderation/ban_role')];
|
||||||
|
const command = require('../commands/moderation/ban_role');
|
||||||
|
|
||||||
|
const confirmation = {
|
||||||
|
customId: `ban-role:${options.choice || 'confirm'}:interaction`,
|
||||||
|
update: async (payload) => {
|
||||||
|
edits.push(payload);
|
||||||
|
if (options.onConfirm) options.onConfirm({ members, moderator, bot });
|
||||||
|
},
|
||||||
|
editReply: async (payload) => { edits.push(payload); },
|
||||||
|
};
|
||||||
|
const message = {
|
||||||
|
awaitMessageComponent: async (collectorOptions) => {
|
||||||
|
assert.equal(bans.length, 0, 'Bans must wait for confirmation');
|
||||||
|
assert.equal(collectorOptions.time, 60_000);
|
||||||
|
filter = collectorOptions.filter;
|
||||||
|
if (options.onWait) await options.onWait({ command, interaction });
|
||||||
|
if (options.timeout) throw new Error('Collector timed out');
|
||||||
|
return confirmation;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const guild = {
|
||||||
|
id: 'guild',
|
||||||
|
ownerId: options.ownerId || 'owner',
|
||||||
|
roles: { fetch: async () => {} },
|
||||||
|
members: {
|
||||||
|
fetchMe: async () => bot,
|
||||||
|
fetch: async (fetchOptions) => {
|
||||||
|
assert.equal(fetchOptions.force, true);
|
||||||
|
fetchCalls.push(fetchOptions.user);
|
||||||
|
if (fetchOptions.user === moderator.id) return moderator;
|
||||||
|
const failure = options.fetchFailures?.[fetchOptions.user];
|
||||||
|
if (failure) throw failure;
|
||||||
|
return members.find(member => member.id === fetchOptions.user);
|
||||||
|
},
|
||||||
|
list: async (listOptions) => {
|
||||||
|
listCalls.push(listOptions);
|
||||||
|
if (options.listFailureAt === listCalls.length) throw new Error('Listing failed');
|
||||||
|
const page = options.pages ? options.pages[listCalls.length - 1] : members;
|
||||||
|
return new Collection(page.map(member => [member.id, member]));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bans: {
|
||||||
|
create: async (id, banOptions) => {
|
||||||
|
banAttempts.push(id);
|
||||||
|
if (options.banFailures?.includes(id)) throw new Error('Ban rejected');
|
||||||
|
bans.push({ id, ...banOptions });
|
||||||
|
if (options.onBan) options.onBan({ id, moderator, bot });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const interaction = {
|
||||||
|
id: 'interaction',
|
||||||
|
guild: options.dm ? null : guild,
|
||||||
|
guildId: guild.id,
|
||||||
|
user: { id: moderator.id },
|
||||||
|
client: { user: { id: bot.id } },
|
||||||
|
options: {
|
||||||
|
getRole: () => ({ id: options.roleId || 'role' }),
|
||||||
|
getString: () => options.reason ?? 'Raid accounts',
|
||||||
|
},
|
||||||
|
reply: async (payload) => { replies.push(payload); },
|
||||||
|
deferReply: async (payload) => { assert.equal(payload.flags, MessageFlags.Ephemeral); },
|
||||||
|
editReply: async (payload) => { edits.push(payload); return message; },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
command, interaction, bans, banAttempts, savedRecords, edits, replies, listCalls, fetchCalls,
|
||||||
|
run: () => command.execute(interaction),
|
||||||
|
get filter() { return filter; },
|
||||||
|
get result() { return edits.at(-1)?.content || replies.at(-1)?.content; },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test('command registration requires a role, reason, and Ban Members permission', (t) => {
|
||||||
|
const { command } = createHarness(t);
|
||||||
|
const data = command.data.toJSON();
|
||||||
|
assert.equal(data.name, 'ban-role');
|
||||||
|
assert.equal(data.dm_permission, false);
|
||||||
|
assert.equal(data.default_member_permissions, PermissionFlagsBits.BanMembers.toString());
|
||||||
|
assert.deepEqual(data.options.map(option => [option.name, option.required]), [['role', true], ['reason', true]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [description, options, expected] of [
|
||||||
|
['direct messages', { dm: true }, /inside a server/],
|
||||||
|
['everyone', { roleId: 'guild' }, /@everyone/],
|
||||||
|
['blank reasons', { reason: ' ' }, /provide a reason/],
|
||||||
|
['moderators without Ban Members', { moderatorCanBan: false }, /You need the Ban Members/],
|
||||||
|
['bots without Ban Members', { botCanBan: false }, /I need the Ban Members/],
|
||||||
|
]) {
|
||||||
|
test(`rejects ${description} without listing or banning members`, async (t) => {
|
||||||
|
const harness = createHarness(t, options);
|
||||||
|
await harness.run();
|
||||||
|
assert.match(harness.result, expected);
|
||||||
|
assert.equal(harness.listCalls.length, 0);
|
||||||
|
assert.equal(harness.bans.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bans only eligible role members and records successful bans', async (t) => {
|
||||||
|
const harness = createHarness(t, { members: [
|
||||||
|
makeMember('eligible'),
|
||||||
|
makeMember('other-role', { hasRole: false }),
|
||||||
|
makeMember('moderator'),
|
||||||
|
makeMember('bot'),
|
||||||
|
makeMember('owner'),
|
||||||
|
makeMember('peer', { position: 10 }),
|
||||||
|
makeMember('higher', { position: 11 }),
|
||||||
|
makeMember('unbannable', { bannable: false }),
|
||||||
|
] });
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.bans.map(ban => ban.id), ['eligible']);
|
||||||
|
assert.equal(harness.bans[0].deleteMessageSeconds, 0);
|
||||||
|
assert.match(harness.bans[0].reason, /moderator: Raid accounts/);
|
||||||
|
assert.deepEqual(harness.savedRecords[0], {
|
||||||
|
guildId: 'guild', userId: 'eligible', action: 'ban', moderatorId: 'moderator', reason: 'Raid accounts',
|
||||||
|
metadata: { command: 'ban-role', roleId: 'role' },
|
||||||
|
stateUpdates: { isBanned: true, bannedUntil: null },
|
||||||
|
});
|
||||||
|
assert.match(harness.result, /Banned: 1\. Skipped: 6\. Failed: 0/);
|
||||||
|
assert.equal(harness.filter({ user: { id: 'stranger' }, customId: 'ban-role:confirm:interaction' }), false);
|
||||||
|
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'different-action' }), false);
|
||||||
|
assert.equal(harness.filter({ user: { id: 'moderator' }, customId: 'ban-role:confirm:interaction' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server owner bypasses only the moderator hierarchy', async (t) => {
|
||||||
|
const harness = createHarness(t, { ownerId: 'moderator', members: [
|
||||||
|
makeMember('above-owner', { position: 11 }),
|
||||||
|
makeMember('above-bot', { position: 21, bannable: false }),
|
||||||
|
] });
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.bans.map(ban => ban.id), ['above-owner']);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [description, options, expected] of [
|
||||||
|
['cancellation', { choice: 'cancel' }, /Cancelled/],
|
||||||
|
['timeout', { timeout: true }, /expired/],
|
||||||
|
['empty roles', { members: [] }, /No eligible members/],
|
||||||
|
['protected members only', { members: [makeMember('owner')] }, /Skipped: 1/],
|
||||||
|
['revoked dashboard permission', { allowed: false }, /no longer have permission/],
|
||||||
|
['revoked Discord permission', { onConfirm: ({ moderator }) => { moderator.permissions.has = () => false; } }, /You need the Ban Members/],
|
||||||
|
]) {
|
||||||
|
test(`${description} produces no bans`, async (t) => {
|
||||||
|
const harness = createHarness(t, options);
|
||||||
|
await harness.run();
|
||||||
|
assert.equal(harness.bans.length, 0);
|
||||||
|
assert.equal(harness.savedRecords.length, 0);
|
||||||
|
assert.match(harness.result, expected);
|
||||||
|
assert.equal(harness.edits.at(-1)?.components?.length || 0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('discovers matching members beyond the first 1000 members', async (t) => {
|
||||||
|
const firstPage = Array.from({ length: 1000 }, (_, id) => makeMember(String(id), { hasRole: false }));
|
||||||
|
const target = makeMember('1000');
|
||||||
|
const harness = createHarness(t, { members: [target], pages: [firstPage, [target]] });
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.listCalls, [
|
||||||
|
{ limit: 1000, after: undefined, cache: false },
|
||||||
|
{ limit: 1000, after: '999', cache: false },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(harness.bans.map(ban => ban.id), ['1000']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incomplete member discovery never offers confirmation or bans a partial list', async (t) => {
|
||||||
|
const page = Array.from({ length: 1000 }, (_, id) => makeMember(String(id)));
|
||||||
|
const harness = createHarness(t, { pages: [page], listFailureAt: 2 });
|
||||||
|
await harness.run();
|
||||||
|
assert.equal(harness.bans.length, 0);
|
||||||
|
assert.equal(harness.filter, undefined);
|
||||||
|
assert.match(harness.result, /Could not load all server members/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rechecks role membership and hierarchy and excludes members added after the preview', async (t) => {
|
||||||
|
const harness = createHarness(t, {
|
||||||
|
members: [makeMember('removed'), makeMember('promoted'), makeMember('left'), makeMember('remaining')],
|
||||||
|
fetchFailures: { left: { code: 10007 } },
|
||||||
|
onConfirm: ({ members }) => {
|
||||||
|
members[0].roles.cache.clear();
|
||||||
|
members[1].roles.highest.position = 15;
|
||||||
|
members.push(makeMember('new'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.bans.map(ban => ban.id), ['remaining']);
|
||||||
|
assert.match(harness.result, /Banned: 1\. Skipped: 3\. Failed: 0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continues after fetch, ban, and record failures while reporting correct totals', async (t) => {
|
||||||
|
const harness = createHarness(t, {
|
||||||
|
members: [makeMember('unavailable'), makeMember('denied'), makeMember('success'), makeMember('success2')],
|
||||||
|
fetchFailures: { unavailable: new Error('Network failed') },
|
||||||
|
banFailures: ['denied'],
|
||||||
|
logFailure: true,
|
||||||
|
});
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.banAttempts, ['denied', 'success', 'success2']);
|
||||||
|
assert.deepEqual(harness.savedRecords.map(record => record.userId), ['success', 'success2']);
|
||||||
|
assert.match(harness.result, /Banned: 2\. Skipped: 0\. Failed: 2/);
|
||||||
|
assert.match(harness.result, /could not be saved for 2 successful bans/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops and preserves partial results if moderator permissions change mid-batch', async (t) => {
|
||||||
|
const harness = createHarness(t, {
|
||||||
|
members: [makeMember('first'), makeMember('second')],
|
||||||
|
onBan: ({ moderator }) => { moderator.permissions.has = () => false; },
|
||||||
|
});
|
||||||
|
await harness.run();
|
||||||
|
assert.deepEqual(harness.bans.map(ban => ban.id), ['first']);
|
||||||
|
assert.match(harness.result, /Role ban stopped/);
|
||||||
|
assert.match(harness.result, /Banned: 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents overlapping role bans and releases the server after cancellation', async (t) => {
|
||||||
|
let overlapChecks = 0;
|
||||||
|
const harness = createHarness(t, {
|
||||||
|
choice: 'cancel',
|
||||||
|
onWait: async ({ command, interaction }) => {
|
||||||
|
overlapChecks++;
|
||||||
|
await command.execute({
|
||||||
|
...interaction,
|
||||||
|
reply: async (payload) => assert.match(payload.content, /already being previewed or processed/),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await harness.run();
|
||||||
|
await harness.run();
|
||||||
|
assert.equal(overlapChecks, 2);
|
||||||
|
assert.equal(harness.bans.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const path = require('node:path');
|
||||||
|
const { loadCommandModules } = require('../loaders/commands');
|
||||||
|
const { loadEventModules } = require('../loaders/events');
|
||||||
|
const { createDashboardApp } = require('../dashboardServer');
|
||||||
|
|
||||||
|
const commandsPath = path.join(__dirname, '..', 'commands');
|
||||||
|
const eventsPath = path.join(__dirname, '..', 'events');
|
||||||
|
|
||||||
|
const commandResult = loadCommandModules(commandsPath);
|
||||||
|
const eventResult = loadEventModules(eventsPath);
|
||||||
|
|
||||||
|
let hasFailures = false;
|
||||||
|
|
||||||
|
const printFailures = (heading, values) => {
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFailures = true;
|
||||||
|
console.error(heading);
|
||||||
|
for (const value of values) {
|
||||||
|
console.error(`- ${value}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
printFailures('Invalid command modules:', commandResult.invalidCommands);
|
||||||
|
printFailures(
|
||||||
|
'Duplicate command names:',
|
||||||
|
commandResult.duplicateCommands.map(
|
||||||
|
({ commandName, filePath, existingFilePath }) =>
|
||||||
|
`${commandName}: ${filePath} conflicts with ${existingFilePath}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
printFailures(
|
||||||
|
'Command load errors:',
|
||||||
|
commandResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
|
||||||
|
);
|
||||||
|
printFailures('Invalid event modules:', eventResult.invalidEvents);
|
||||||
|
printFailures(
|
||||||
|
'Event load errors:',
|
||||||
|
eventResult.loadErrors.map(({ filePath, error }) => `${filePath}: ${error.message}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof createDashboardApp !== 'function') {
|
||||||
|
hasFailures = true;
|
||||||
|
console.error('Dashboard app factory is not exported correctly.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFailures) {
|
||||||
|
console.log(
|
||||||
|
`Verification passed: ${commandResult.commands.length} commands and ${eventResult.events.length} events loaded successfully.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(hasFailures ? 1 : 0);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user