first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
*.json
|
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# MyMC Premium Store
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
MyMC Premium Store is a Discord bot that manages Docker containers for Minecraft servers, automatically adjusting container resources (CPU, memory, and swap) based on users' Discord roles. The bot checks for role changes every 5 minutes and updates container configurations accordingly, supporting default, upgraded, and super-upgraded tiers.
|
||||||
|
|
||||||
|
NOTE: This repo is for educational purposes, and the code is written specifically for our system, however, you may fork it and use it for any need you may have.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Role-Based Resource Allocation**: Adjusts Docker container resources (CPUs, memory, swap) based on Discord roles (`standard`, `manualUpgrade`, `superUpgrade`).
|
||||||
|
- **Periodic Checks**: Runs every 5 minutes via a cron job to ensure container settings match user roles.
|
||||||
|
- **Container Configuration Updates**: Updates PM2 process configurations within containers by copying appropriate `startServer.json` files (`startServer_downgrade.json`, `startServer_upgrade.json`, or `startServer_superUpgrade.json`).
|
||||||
|
- **Safety Mechanisms**:
|
||||||
|
- Handles unknown container limits by optionally resetting to default settings.
|
||||||
|
- Implements command execution timeouts to prevent hanging operations.
|
||||||
|
- **Logging**: Detailed console logs for container status, role checks, and configuration updates.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- **Node.js**: Version 16 or higher.
|
||||||
|
- **Docker**: Installed and running, with the bot having access to the Docker socket.
|
||||||
|
- **Discord Bot Token**: A valid Discord bot token with `Guilds` and `GuildMembers` intents.
|
||||||
|
- **Environment Variables**: Configured via a `.env` file (see [Configuration](#configuration)).
|
||||||
|
- **PM2**: Used inside containers for process management.
|
||||||
|
- **Configuration Files**: `startServer_downgrade.json`, `startServer_upgrade.json`, and `startServer_superUpgrade.json` must exist in the same directory as the bot script.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone git@git.ssh.surf:hypermc/mymc-premium.git
|
||||||
|
cd mymc-premium
|
||||||
|
```
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install discord.js dockerode cron dotenv
|
||||||
|
```
|
||||||
|
3. Create a `.env` file in the project root (see [Configuration](#configuration)).
|
||||||
|
4. Ensure Docker is running and the bot has access to the Docker socket (`/var/run/docker.sock`).
|
||||||
|
5. Place the required `startServer_*.json` files in the project directory.
|
||||||
|
6. Start the bot:
|
||||||
|
```bash
|
||||||
|
node index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Create a `.env` file in the project root with the following variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISCORD_TOKEN=your_discord_bot_token
|
||||||
|
GUILD_ID=your_discord_guild_id
|
||||||
|
ROLE_ID_STANDARD=standard_role_id
|
||||||
|
ROLE_ID_MANUAL_UPGRADE=manual_upgrade_role_id
|
||||||
|
ROLE_ID_SUPER_UPGRADE=super_upgrade_role_id
|
||||||
|
DEFAULT_CPUS=1
|
||||||
|
DEFAULT_MEMORY=512
|
||||||
|
DEFAULT_SWAP=1024
|
||||||
|
UPGRADED_CPUS=2
|
||||||
|
UPGRADED_MEMORY=1024
|
||||||
|
UPGRADED_SWAP=2048
|
||||||
|
SUPER_UPGRADED_CPUS=4
|
||||||
|
SUPER_UPGRADED_MEMORY=2048
|
||||||
|
SUPER_UPGRADED_SWAP=4096
|
||||||
|
RESET_UNKNOWN_TO_DEFAULT=true
|
||||||
|
EXEC_TIMEOUT=10000
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DISCORD_TOKEN**: Discord bot token.
|
||||||
|
- **GUILD_ID**: ID of the Discord server (guild) to monitor.
|
||||||
|
- **ROLE_ID_***: Discord role IDs for each tier.
|
||||||
|
- **DEFAULT_***: Resource limits for default tier (CPUs, memory in MiB, swap in MiB).
|
||||||
|
- **UPGRADED_***: Resource limits for upgraded tier.
|
||||||
|
- **SUPER_UPGRADED_***: Resource limits for super-upgraded tier.
|
||||||
|
- **RESET_UNKNOWN_TO_DEFAULT**: If `true`, resets containers with unknown limits to default settings.
|
||||||
|
- **EXEC_TIMEOUT**: Timeout (in milliseconds) for Docker exec commands.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
- The bot logs in to Discord and performs an initial container check on startup.
|
||||||
|
- Every 5 minutes, it checks all running containers with names starting with `mc_` (e.g., `mc_1234567890`, where `1234567890` is the Discord user ID).
|
||||||
|
- For each container:
|
||||||
|
- Verifies current resource limits (CPUs, memory, swap).
|
||||||
|
- Checks the user's Discord roles in the specified guild.
|
||||||
|
- Updates container resources and configuration files if the role-based tier doesn't match the current settings:
|
||||||
|
- **Super Upgrade**: Applies if the user has the `superUpgrade` role.
|
||||||
|
- **Standard/Manual Upgrade**: Applies if the user has `standard` or `manualUpgrade` roles.
|
||||||
|
- **Default**: Applies if the user has no relevant roles.
|
||||||
|
- Copies the appropriate `startServer_*.json` file to the container and restarts the PM2 process.
|
||||||
|
- Logs all actions, including errors, to the console.
|
||||||
|
|
||||||
|
## Container Naming
|
||||||
|
Containers must be named with the prefix `mc_` followed by the Discord user ID (e.g., `mc_1234567890`).
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
The bot uses the following JSON files to configure PM2 processes inside containers:
|
||||||
|
- `startServer_downgrade.json`: For default tier.
|
||||||
|
- `startServer_upgrade.json`: For standard/manual upgrade tier.
|
||||||
|
- `startServer_superUpgrade.json`: For super-upgraded tier.
|
||||||
|
|
||||||
|
These files must be present in the bot's working directory and contain valid PM2 configuration for the Minecraft server.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- **Docker Errors**: Logs errors during container listing, inspection, or updates.
|
||||||
|
- **Discord Errors**: Logs errors when fetching guild or member data.
|
||||||
|
- **Timeout Errors**: Commands exceeding `EXEC_TIMEOUT` are terminated and logged.
|
||||||
|
- **File Copy Errors**: Logs failures when copying configuration files.
|
||||||
|
- **PM2 Errors**: Logs errors during PM2 process deletion or startup, but attempts to continue operations where possible.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
The bot provides detailed console logs for:
|
||||||
|
- Container details (name, user ID, current resources).
|
||||||
|
- Role check results.
|
||||||
|
- Actions taken (upgrade, downgrade, reset, or no action).
|
||||||
|
- Errors during any operation.
|
264
mymc-premium.js
Normal file
264
mymc-premium.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
const Discord = require('discord.js');
|
||||||
|
const Docker = require('dockerode');
|
||||||
|
const { CronJob } = require('cron');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const client = new Discord.Client({
|
||||||
|
intents: [
|
||||||
|
Discord.GatewayIntentBits.Guilds,
|
||||||
|
Discord.GatewayIntentBits.GuildMembers
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const docker = new Docker(); // Assumes local Docker socket; adjust if remote
|
||||||
|
|
||||||
|
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
|
||||||
|
const GUILD_ID = process.env.GUILD_ID;
|
||||||
|
const ROLE_IDS = {
|
||||||
|
standard: process.env.ROLE_ID_STANDARD,
|
||||||
|
manualUpgrade: process.env.ROLE_ID_MANUAL_UPGRADE,
|
||||||
|
superUpgrade: process.env.ROLE_ID_SUPER_UPGRADE
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CPUS = parseInt(process.env.DEFAULT_CPUS);
|
||||||
|
const DEFAULT_MEMORY = parseInt(process.env.DEFAULT_MEMORY) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
const DEFAULT_SWAP = parseInt(process.env.DEFAULT_SWAP) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
|
||||||
|
const UPGRADED_CPUS = parseInt(process.env.UPGRADED_CPUS);
|
||||||
|
const UPGRADED_MEMORY = parseInt(process.env.UPGRADED_MEMORY) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
const UPGRADED_SWAP = parseInt(process.env.UPGRADED_SWAP) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
|
||||||
|
const SUPER_UPGRADED_CPUS = parseInt(process.env.SUPER_UPGRADED_CPUS);
|
||||||
|
const SUPER_UPGRADED_MEMORY = parseInt(process.env.SUPER_UPGRADED_MEMORY) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
const SUPER_UPGRADED_SWAP = parseInt(process.env.SUPER_UPGRADED_SWAP) * 1024 * 1024; // Convert MiB to bytes
|
||||||
|
|
||||||
|
const RESET_UNKNOWN_TO_DEFAULT = process.env.RESET_UNKNOWN_TO_DEFAULT === 'true';
|
||||||
|
const EXEC_TIMEOUT = parseInt(process.env.EXEC_TIMEOUT);
|
||||||
|
|
||||||
|
async function execWithTimeout(container, cmd, timeout = EXEC_TIMEOUT) {
|
||||||
|
try {
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: cmd,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await exec.start();
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Command ${cmd.join(' ')} timed out after ${timeout}ms`)), timeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
stream.on('data', (chunk) => output += chunk.toString());
|
||||||
|
stream.on('end', () => resolve(output));
|
||||||
|
stream.on('error', reject);
|
||||||
|
}),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateContainerConfig(container, name, userId, memoryLimit) {
|
||||||
|
try {
|
||||||
|
let sourceFile;
|
||||||
|
if (memoryLimit === SUPER_UPGRADED_MEMORY) {
|
||||||
|
sourceFile = 'startServer_superUpgrade.json';
|
||||||
|
} else if (memoryLimit === UPGRADED_MEMORY) {
|
||||||
|
sourceFile = 'startServer_upgrade.json';
|
||||||
|
} else {
|
||||||
|
sourceFile = 'startServer_downgrade.json';
|
||||||
|
}
|
||||||
|
console.log(` 🔄 Updating container ${name} with ${sourceFile}...`);
|
||||||
|
|
||||||
|
// Check if startServer.json exists
|
||||||
|
console.log(` 🔍 Checking if startServer.json exists in container ${name}...`);
|
||||||
|
try {
|
||||||
|
await execWithTimeout(container, ['test', '-f', '/var/tools/pm2/startServer.json']);
|
||||||
|
console.log(` ✅ startServer.json exists`);
|
||||||
|
|
||||||
|
// Remove existing startServer.json
|
||||||
|
console.log(` 🗑️ Removing existing startServer.json in container ${name}...`);
|
||||||
|
await execWithTimeout(container, ['rm', '-f', '/var/tools/pm2/startServer.json']);
|
||||||
|
console.log(` ✅ Removed startServer.json`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ⚠️ startServer.json does not exist or cannot be removed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy new startServer.json file
|
||||||
|
console.log(` 📤 Copying ${sourceFile} to container ${name}...`);
|
||||||
|
try {
|
||||||
|
execSync(`docker cp ${sourceFile} ${name}:/var/tools/pm2/startServer.json`, { stdio: 'inherit', timeout: EXEC_TIMEOUT });
|
||||||
|
console.log(` ✅ Copied ${sourceFile} to /var/tools/pm2/startServer.json`);
|
||||||
|
} catch (cpErr) {
|
||||||
|
console.error(` ❌ Error copying ${sourceFile} to container ${name}: ${cpErr.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing PM2 process
|
||||||
|
console.log(` 🗑️ Deleting PM2 process in container ${name}...`);
|
||||||
|
try {
|
||||||
|
await execWithTimeout(container, ['su', '-', 'mc', '-c', 'cd /var/tools/pm2 && pm2 delete 0']);
|
||||||
|
console.log(` ✅ PM2 process deleted`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ⚠️ Error deleting PM2 process in container ${name}: ${err.message}`);
|
||||||
|
// Continue to attempt starting the process
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new PM2 process
|
||||||
|
console.log(` ▶️ Starting PM2 process in container ${name}...`);
|
||||||
|
try {
|
||||||
|
await execWithTimeout(container, ['su', '-', 'mc', '-c', 'cd /var/tools/pm2 && pm2 start startServer.json']);
|
||||||
|
console.log(` ✅ PM2 process started with startServer.json`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error starting PM2 process in container ${name}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ❌ Error updating container ${name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkContainers() {
|
||||||
|
console.log('\n=== Starting Container Check ===\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// List running containers
|
||||||
|
const containers = await docker.listContainers({ all: false });
|
||||||
|
|
||||||
|
for (const contInfo of containers) {
|
||||||
|
// Container names start with '/', e.g., '/mc_1234567890'
|
||||||
|
const name = contInfo.Names[0].slice(1);
|
||||||
|
if (name.startsWith('mc_')) {
|
||||||
|
const userId = name.slice(3); // Extract Discord ID
|
||||||
|
const container = docker.getContainer(contInfo.Id);
|
||||||
|
const inspect = await container.inspect();
|
||||||
|
|
||||||
|
const currentCpus = inspect.HostConfig.NanoCpus / 1e9;
|
||||||
|
const currentMem = inspect.HostConfig.Memory;
|
||||||
|
const currentSwap = inspect.HostConfig.MemorySwap;
|
||||||
|
|
||||||
|
// Log container details in a structured format
|
||||||
|
console.log(`📦 Container: ${name}`);
|
||||||
|
console.log(` User ID: ${userId}`);
|
||||||
|
console.log(` Current Settings:`);
|
||||||
|
console.log(` CPUs: ${currentCpus}`);
|
||||||
|
console.log(` Memory: ${currentMem / 1024 / 1024} MiB`);
|
||||||
|
console.log(` Swap: ${currentSwap / 1024 / 1024} MiB`);
|
||||||
|
|
||||||
|
const isDefault =
|
||||||
|
currentCpus === DEFAULT_CPUS &&
|
||||||
|
currentMem === DEFAULT_MEMORY &&
|
||||||
|
currentSwap === DEFAULT_SWAP;
|
||||||
|
|
||||||
|
const isUpgraded =
|
||||||
|
currentCpus === UPGRADED_CPUS &&
|
||||||
|
currentMem === UPGRADED_MEMORY &&
|
||||||
|
currentSwap === UPGRADED_SWAP;
|
||||||
|
|
||||||
|
const isSuperUpgraded =
|
||||||
|
currentCpus === SUPER_UPGRADED_CPUS &&
|
||||||
|
currentMem === SUPER_UPGRADED_MEMORY &&
|
||||||
|
currentSwap === SUPER_UPGRADED_SWAP;
|
||||||
|
|
||||||
|
// Handle unknown limits
|
||||||
|
if (!isDefault && !isUpgraded && !isSuperUpgraded) {
|
||||||
|
console.log(` ⚠️ Warning: Unknown limits detected!`);
|
||||||
|
console.log(` Expected Default: CPUs=${DEFAULT_CPUS}, Memory=${DEFAULT_MEMORY / 1024 / 1024} MiB, Swap=${DEFAULT_SWAP / 1024 / 1024} MiB`);
|
||||||
|
console.log(` Expected Upgraded: CPUs=${UPGRADED_CPUS}, Memory=${UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${UPGRADED_SWAP / 1024 / 1024} MiB`);
|
||||||
|
console.log(` Expected Super Upgraded: CPUs=${SUPER_UPGRADED_CPUS}, Memory=${SUPER_UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${SUPER_UPGRADED_SWAP / 1024 / 1024} MiB`);
|
||||||
|
if (RESET_UNKNOWN_TO_DEFAULT) {
|
||||||
|
console.log(` 🔄 Resetting to default settings...`);
|
||||||
|
await container.update({
|
||||||
|
NanoCpus: DEFAULT_CPUS * 1e9,
|
||||||
|
Memory: DEFAULT_MEMORY,
|
||||||
|
MemorySwap: DEFAULT_SWAP
|
||||||
|
});
|
||||||
|
await updateContainerConfig(container, name, userId, DEFAULT_MEMORY);
|
||||||
|
console.log(` ✅ Container reset to default settings.`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⏭️ Skipping due to unknown limits.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch guild and check roles
|
||||||
|
const guild = client.guilds.cache.get(GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
console.log(` ❌ Guild ${GUILD_ID} not found.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasSuperUpgradeRole = false;
|
||||||
|
let hasStandardOrManualRole = false;
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(userId);
|
||||||
|
hasSuperUpgradeRole = member.roles.cache.has(ROLE_IDS.superUpgrade);
|
||||||
|
hasStandardOrManualRole = [ROLE_IDS.standard, ROLE_IDS.manualUpgrade].some(roleId => member.roles.cache.has(roleId));
|
||||||
|
console.log(` Role Check: User ${hasSuperUpgradeRole ? 'has superUpgrade role' : hasStandardOrManualRole ? 'has standard or manual upgrade role' : 'has no relevant roles'} (${Object.values(ROLE_IDS).join(' or ')})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ❌ Error fetching member ${userId}: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSuperUpgradeRole && !isSuperUpgraded) {
|
||||||
|
// Apply super upgrade
|
||||||
|
console.log(` 🔼 Applying super upgrade to container...`);
|
||||||
|
await container.update({
|
||||||
|
NanoCpus: SUPER_UPGRADED_CPUS * 1e9,
|
||||||
|
Memory: SUPER_UPGRADED_MEMORY,
|
||||||
|
MemorySwap: SUPER_UPGRADED_SWAP
|
||||||
|
});
|
||||||
|
await updateContainerConfig(container, name, userId, SUPER_UPGRADED_MEMORY);
|
||||||
|
console.log(` ✅ Super Upgraded to: CPUs=${SUPER_UPGRADED_CPUS}, Memory=${SUPER_UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${SUPER_UPGRADED_SWAP / 1024 / 1024} MiB`);
|
||||||
|
} else if (!hasSuperUpgradeRole && hasStandardOrManualRole && !isUpgraded) {
|
||||||
|
// Apply standard upgrade
|
||||||
|
console.log(` 🔼 Upgrading container...`);
|
||||||
|
await container.update({
|
||||||
|
NanoCpus: UPGRADED_CPUS * 1e9,
|
||||||
|
Memory: UPGRADED_MEMORY,
|
||||||
|
MemorySwap: UPGRADED_SWAP
|
||||||
|
});
|
||||||
|
await updateContainerConfig(container, name, userId, UPGRADED_MEMORY);
|
||||||
|
console.log(` ✅ Upgraded to: CPUs=${UPGRADED_CPUS}, Memory=${UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${UPGRADED_SWAP / 1024 / 1024} MiB`);
|
||||||
|
} else if (!hasSuperUpgradeRole && !hasStandardOrManualRole && (isUpgraded || isSuperUpgraded)) {
|
||||||
|
// Downgrade
|
||||||
|
console.log(` 🔽 Downgrading container...`);
|
||||||
|
await container.update({
|
||||||
|
NanoCpus: DEFAULT_CPUS * 1e9,
|
||||||
|
Memory: DEFAULT_MEMORY,
|
||||||
|
MemorySwap: DEFAULT_SWAP
|
||||||
|
});
|
||||||
|
await updateContainerConfig(container, name, userId, DEFAULT_MEMORY);
|
||||||
|
console.log(` ✅ Downgraded to: CPUs=${DEFAULT_CPUS}, Memory=${DEFAULT_MEMORY / 1024 / 1024} MiB, Swap=${DEFAULT_SWAP / 1024 / 1024} MiB`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ No action needed. Container settings match role status.`);
|
||||||
|
}
|
||||||
|
console.log('----------------------------------------');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('\n=== Container Check Completed ===\n');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n❌ Error in container check: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.once('ready', () => {
|
||||||
|
console.log(`✅ Logged in as ${client.user.tag}. Bot is ready.`);
|
||||||
|
|
||||||
|
// Run initial check on startup
|
||||||
|
checkContainers();
|
||||||
|
|
||||||
|
// Cron job every 5 minutes
|
||||||
|
const job = new CronJob('*/5 * * * *', checkContainers, null, true, 'UTC');
|
||||||
|
job.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
client.login(DISCORD_TOKEN);
|
Reference in New Issue
Block a user