diff --git a/.replit b/.replit
new file mode 100644
index 000000000..45d746fd8
--- /dev/null
+++ b/.replit
@@ -0,0 +1,51 @@
+modules = ["nodejs-18"]
+[agent]
+expertMode = true
+stack = "BEST_EFFORT_FALLBACK"
+
+[nix]
+channel = "stable-25_05"
+
+[userenv]
+
+[userenv.shared]
+NODE_ENV = "development"
+LOG_LEVEL = "info"
+LOG_TO_FILE = "false"
+PORT = "3000"
+WEB_HOST = "0.0.0.0"
+PORT_RETRY_ATTEMPTS = "5"
+CORS_ORIGIN = "*"
+AUTO_MIGRATE = "true"
+POSTGRES_MIGRATION_TABLE = "schema_migrations"
+SCHEMA_VERSION = "1"
+SCHEMA_VERSION_LABEL = "baseline-v1"
+BACKUP_DIR = "./backups"
+BACKUP_RETENTION_DAYS = "14"
+
+[workflows]
+runButton = "Project"
+
+[[workflows.workflow]]
+name = "Project"
+mode = "parallel"
+author = "agent"
+
+[[workflows.workflow.tasks]]
+task = "workflow.run"
+args = "Start application"
+
+[[workflows.workflow]]
+name = "Start application"
+author = "agent"
+
+[[workflows.workflow.tasks]]
+task = "shell.exec"
+args = "node src/app.js"
+
+[workflows.workflow.metadata]
+outputType = "console"
+
+[[ports]]
+localPort = 3000
+externalPort = 80
diff --git a/LICENSE b/LICENSE
index 8df7f6c94..ae520f62b 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 codebymitch
+Copyright (c) 2026 NHStarlight
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 98755bccb..b88c0b766 100644
--- a/README.md
+++ b/README.md
@@ -1,257 +1,3 @@
-# TitanBot - Ultimate Discord Bot
+# NH_starlightsercurity - Ultimate Sercurity Discord Bot
-**TitanBot** is a powerful, feature-rich Discord bot designed to enhance your server experience with comprehensive moderation tools, engaging economy systems, utility features, and much more. Built with modern Discord.js v14 and PostgreSQL for optimal performance and data persistence.
-
-[](https://discord.gg/8kJBYhTGW9)
-[](https://www.npmjs.com/package/discord.js)
-
-
-## Table of Contents
-
-- [Features Overview](#features-overview)
-- [Quick Setup](#quick-setup)
-- [Manual Installation Steps](#manual-installation-steps)
-- [Support Server](https://discord.gg/QnWNz2dKCE)
-- [Required Bot Intents](#bot-intents)
-- [Contributing](#contributing)
-
-
-## Features Overview
-
-TitanBot offers a complete suite of tools for Discord server management and community engagement:
-
-
-
-|
-
-### Moderation & Administration
-- **Mass Actions** - Bulk ban/kick capabilities
-- **User Notes** - Keep detailed moderation records
-- **Case Management** - View and track all mod actions
-
-### Economy System
-- **Shop & Inventory** - Buy and manage items
-- **Gambling** - Risk it for rewards
-- **Pay System** - Transfer money between users
-
-### Fun & Entertainment
-- **Random Facts** - Learn something new
-- **Wanted Poster** - Create fun wanted images
-- **Text Reversal** - Reverse any text
-
-### Advanced Ticket System
-- **Claim & Priority** - Staff ticket management
-- **Ticket Limits** - Prevent spam
-- **Transcript System** - Save ticket history
-
-### Server Stats
-- **Member Counter** - Live member count channels
-- **Voice Counters** - Track voice stats
-- **Dynamic Updates** - Real-time channel updates
-
-### Reaction Roles
-- **Role Assignment** - Self-assignable roles
-- **Emoji Selection** - Reaction-based system
-- **Multi-role Support** - Multiple role options
-
- |
-
-
-### Leveling & XP System
-- **XP Tracking** - Automatic message-based XP
-- **Level Roles** - Auto-assign roles by level
-- **Custom Configuration** - Personalize leveling
-
-### Giveaways & Events
-- **Multiple Winners** - Support multi-winner giveaways
-- **Auto Picking** - Automatic winner selection
-- **Reroll System** - Pick new winners if needed
-
-### Birthday System
-- **Birthday Tracking** - Never miss a birthday
-- **Auto Announcements** - Celebrate automatically
-- **Timezone Support** - Accurate worldwide tracking
-
-### Utility Tools
-- **Report System** - Report issues to staff
-- **Todo Lists** - Personal task management
-- **First Message** - Jump to channel's first message
-
-### Welcome System
-- **Welcome Messages** - Greet new members
-- **Auto Roles** - Assign roles on join
-- **Custom Embeds** - Personalized messages
-
- |
-
-
-
-
-## Quick Setup (Recommended for non-coders)
-
-### Video Tutorial
-For a detailed step-by-step setup guide, watch our comprehensive video tutorial:
-[**TitanBot Setup Tutorial**](https://www.youtube.com/@TouchDisc)
-
-## Docker Deployment (Recommended)
-
-TitanBot is fully containerized for easy deployment.
-
-1. **Clone the repository:**
- ```bash
- git clone https://github.com/codebymitch/TitanBot.git
- cd TitanBot
- ```
-
-2. **Configure environment variables:**
- Create a `.env` file from `.env.example` and fill in your bot details and PostgreSQL credentials.
-
-3. **Start the containers:**
- ```bash
- docker-compose up -d
- ```
-
-This will start both the bot and a persistent PostgreSQL database.
-
-### Using GitHub Container Registry
-
-The bot is automatically published to GitHub Container Registry on every push to main.
-
-```bash
-docker pull ghcr.io/codebymitch/titanbot:main
-```
-
-
-## Manual Installation Steps
-
-### Prerequisites
-- Node.js 18.0.0 or higher
-- PostgreSQL server (recommended) or memory storage fallback
-- Discord bot application with proper intents
-
-1. **Clone the Repository**
- ```bash
- git clone https://github.com/codebymitch/TitanBot.git
- cd TitanBot
- ```
-
-2. **Install Dependencies**
- ```bash
- npm install
- ```
-
-3. **Configure Environment Variables**
- ```bash
- cp .env.example .env
- ```
- Edit `.env` with your configuration (only the following variables require configuration, leave remaining variables as default):
- ```env
- # Discord Bot Configuration
- DISCORD_TOKEN=your_discord_bot_token_here
- CLIENT_ID=your_discord_client_id_here
- GUILD_ID=your_discord_guild_id_here
-
- # PostgreSQL Configuration (Primary Database)
- POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot
- POSTGRES_HOST=localhost
- POSTGRES_PORT=5432
- POSTGRES_DB=titanbot
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=yourpassword
- ```
-
- Production note:
- - `NODE_ENV=production`
- - `LOG_LEVEL=warn` for a clean production console (critical issues + startup status)
- - `LOG_LEVEL=info` if you want more detailed operational logs
- - If your chosen `PORT` is already used, TitanBot automatically tries the next port(s)
-
- Environment options reference:
- - `NODE_ENV`: `development`, `production`, `test` (any non-`production` value is treated as non-production)
- - `LOG_LEVEL`: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly`
- - Accepted aliases for `LOG_LEVEL` in this bot: `warns`, `warning`, `warnings` → `warn`
-
- Recommended production `.env` (easy mode + default mode):
- ```env
- NODE_ENV=production
- LOG_LEVEL=warn
- WEB_HOST=0.0.0.0
- PORT=3000
- PORT_RETRY_ATTEMPTS=5
- ```
- This gives clear startup/online status messages while keeping logs simple for non-technical operators.
- If port `3000` is busy, the bot tries the next available ports automatically (up to `PORT_RETRY_ATTEMPTS`).
-
-4. **Setup PostgreSQL Database** (Optional but recommended)
- ```bash
- # Create database and user
- createdb titanbot
- createuser titanbot
- psql -c "ALTER USER titanbot PASSWORD 'yourpassword';"
- psql -c "GRANT ALL PRIVILEGES ON DATABASE titanbot TO titanbot;"
- ```
-
-5. **Test Database Connection**
- ```bash
- npm run test-postgres
- ```
-
-6. **Start the Bot**
- ```bash
- npm start
- ```
-
-
-## Required Bot Intents
-TitanBot requires the following Discord intents:
-- **Guilds**
-- **Guild Messages**
-- **Message Content**
-- **Guild Members**
-- **Guild Message Reactions**
-- **Guild Voice States**
-- **Direct Messages**
-- **Bot**
-- **Applications.commands**
-
-### Required Permissions
-- **View Channels**
-- **Send Messages**
-- **Embed Links**
-- **Attach Files**
-- **Read Message History**
-- **Manage Messages**
-- **Manage Channels**
-- **Manage Roles**
-- **Kick Members**
-- **Manage Messages**
-- **Ban Members**
-- **Moderate Members**
-- **Connect**
-
-
-## Contributing
-
-We welcome contributions to TitanBot! Here's how you can help:
-
-1. **Fork the repository**
-2. **Create a feature branch**
-3. **Make your changes**
-4. **Test thoroughly**
-5. **Submit a pull request**
-
-### Development Guidelines
-- Follow existing code style
-- Add proper error handling
-- Include documentation for new features
-- Test with PostgreSQL and memory storage
-
-## License
-
-TitanBot is released under the MIT License. See [LICENSE](LICENSE) for details.
-
-## Thank You
-
-Thank you for choosing TitanBot for your Discord server! We're constantly working to improve and add new features based on community feedback.
-
-*Last updated: May 2026*
+**NH_starlightsercurity** is a powerful, feature-rich Discord bot designed to enhance your server experience with comprehensive moderation tools, utility features, and much more. Built with modern Discord.js v14 and PostgreSQL for optimal performance and data persistence.
diff --git a/SECURITY.md b/SECURITY.md
index cc4d3a8b4..e427d9bff 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,16 +1,16 @@
-# Security Policy — TitanBot
+# Security Policy — NH_starlightsercurity
We take security seriously. If you discover a vulnerability, please follow the policy below so we can triage and address it safely.
## Summary / Self-hosting clarity
-TitanBot is distributed as open-source software for self-hosting. We (the maintainers) do not operate self-hosted instances and do not have access to user data or configurations on third-party deployments. Self-hosters are responsible for securing and operating their instances. This policy describes how to report vulnerabilities affecting the project code and guidance for self-hosters who discover security issues in their deployment.
+NH_starlightsercurity is distributed as open-source software for self-hosting. We (the maintainers) do not operate self-hosted instances and do not have access to user data or configurations on third-party deployments. Self-hosters are responsible for securing and operating their instances. This policy describes how to report vulnerabilities affecting the project code and guidance for self-hosters who discover security issues in their deployment.
## Reporting a Vulnerability (preferred)
- Preferred channel: Open a private GitHub Security Advisory for this repository (recommended).
- If you cannot use GitHub Advisories, open a ticket: https:
- Do NOT open a public issue with exploit details.
-If you found a problem in your self-hosted instance that appears to be due to misconfiguration, please contact the instance operator/host first. If you believe the issue is caused by a vulnerability in TitanBot code, follow the reporting steps above and indicate whether the report comes from a self-hosted deployment.
+If you found a problem in your self-hosted instance that appears to be due to misconfiguration, please contact the instance operator/host first. If you believe the issue is caused by a vulnerability in NH_starlightsercurity code, follow the reporting steps above and indicate whether the report comes from a self-hosted deployment.
## Response timelines (what to expect)
- Acknowledgement: within 72 hours.
@@ -39,7 +39,7 @@ If you found a problem in your self-hosted instance that appears to be due to mi
- Contact info for follow-up.
## Incident reporting from self-hosted deployments
-If you operate a self-hosted TitanBot instance and suffer a security incident:
+If you operate a self-hosted NH_starlightsercurity instance and suffer a security incident:
- Immediately rotate any exposed secrets (bot token, DB credentials, API keys).
- Take a snapshot of logs/configuration for investigation (avoid sharing secrets).
- If you need upstream help, file a private security advisory and include sanitized reproduction steps and the TitanBot version/commit.
@@ -66,11 +66,8 @@ If you operate a self-hosted TitanBot instance and suffer a security incident:
- We may assign a CVE or coordinate with CERT/other bodies for high-severity issues.
## Privacy & Telemetry
-- TitanBot does not phone home or collect usage data by default. (If you plan to add telemetry, it must be opt-in, documented, and transparent.)
+- NH_starlightsercurity does not phone home or collect usage data by default. (If you plan to add telemetry, it must be opt-in, documented, and transparent.)
- Maintainers do not receive data from self-hosted instances. If you choose to enable any telemetry, document what is collected and how to opt out.
-## Contact
-- Preferred: GitHub Security Advisory for this repository
-- Ticket: https:
-Thank you for helping keep TitanBot safe.
+Thank you for helping keep the NH_starlightsercurity bot - safe.
diff --git a/index.js b/index.js
index 252369712..dd9d63ca3 100644
--- a/index.js
+++ b/index.js
@@ -1,2 +1,3 @@
import './src/app.js';
+
diff --git a/package-lock.json b/package-lock.json
index 94e61bfc1..70023ff9c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "titanbot-custom",
- "version": "2.0.0",
+ "name": "starlight-security",
+ "version": "1.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "name": "titanbot-custom",
- "version": "2.0.0",
+ "name": "starlight-security",
+ "version": "1.1.1",
"dependencies": {
"@discordjs/rest": "^2.6.1",
"axios": "^1.15.2",
diff --git a/package.json b/package.json
index a241fb08b..730a2143e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "titanbot-custom",
+ "name": "starlight-security",
"version": "1.1.1",
- "description": "Modular Ultimate Community Bot by Touchpoint Support",
+ "description": "Starlight Security - Advanced Discord Bot",
"main": "src/app.js",
"type": "module",
"scripts": {
diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js
index 4ad58be10..1e50ce597 100644
--- a/src/commands/Core/help.js
+++ b/src/commands/Core/help.js
@@ -1,234 +1,27 @@
-import {
- SlashCommandBuilder,
- ActionRowBuilder,
- ButtonBuilder,
- ButtonStyle,
-} from "discord.js";
+import { SlashCommandBuilder, MessageFlags } from 'discord.js';
+import { createInitialHelpMenu } from '../../utils/helpMenuHelper.js';
import { InteractionHelper } from '../../utils/interactionHelper.js';
-import { createEmbed } from "../../utils/embeds.js";
-import {
- createSelectMenu,
-} from "../../utils/components.js";
-import fs from "fs/promises";
-import path from "path";
-import { fileURLToPath } from "url";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const CATEGORY_SELECT_ID = "help-category-select";
-const ALL_COMMANDS_ID = "help-all-commands";
-const BUG_REPORT_BUTTON_ID = "help-bug-report";
-const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000;
-
-const CATEGORY_ICONS = {
- Core: "ℹ️",
- Moderation: "🛡️",
- Economy: "💰",
- Fun: "🎮",
- Leveling: "📊",
- Utility: "🔧",
- Ticket: "🎫",
- Welcome: "👋",
- Giveaway: "🎉",
- Counter: "🔢",
- Tools: "🛠️",
- Search: "🔍",
- Reaction_Roles: "🎭",
- Community: "👥",
- Birthday: "🎂",
- Config: "⚙️",
-};
-
-
-
-
-
-export async function createInitialHelpMenu(client) {
- const commandsPath = path.join(__dirname, "../../commands");
- const categoryDirs = (
- await fs.readdir(commandsPath, { withFileTypes: true })
- )
- .filter((dirent) => dirent.isDirectory())
- .map((dirent) => dirent.name)
- .sort();
-
- const options = [
- {
- label: "📋 All Commands",
- description: "View all available commands with pagination",
- value: ALL_COMMANDS_ID,
- },
- ...categoryDirs.map((category) => {
- const categoryName =
- category.charAt(0).toUpperCase() +
- category.slice(1).toLowerCase();
- const icon = CATEGORY_ICONS[categoryName] || "🔍";
- return {
- label: `${icon} ${categoryName}`,
- description: `View commands in the ${categoryName} category`,
- value: category,
- };
- }),
- ];
-
- const botName = client?.user?.username || "Bot";
- const embed = createEmbed({
- title: `🤖 ${botName} Help Center`,
- description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.",
- color: 'primary'
- });
-
- embed.addFields(
- {
- name: "🛡️ **Moderation**",
- value: "Server moderation, user management, and enforcement tools",
- inline: true
- },
- {
- name: "💰 **Economy**",
- value: "Currency system, shops, and virtual economy",
- inline: true
- },
- {
- name: "🎮 **Fun**",
- value: "Games, entertainment, and interactive commands",
- inline: true
- },
- {
- name: "📊 **Leveling**",
- value: "User levels, XP system, and progression tracking",
- inline: true
- },
- {
- name: "🎫 **Tickets**",
- value: "Support ticket system for server management",
- inline: true
- },
- {
- name: "🎉 **Giveaways**",
- value: "Automated giveaway management and distribution",
- inline: true
- },
- {
- name: "👋 **Welcome**",
- value: "Member welcome messages and onboarding",
- inline: true
- },
- {
- name: "🎂 **Birthdays**",
- value: "Birthday tracking and celebration features",
- inline: true
- },
- {
- name: "👥 **Community**",
- value: "Community tools, applications, and member engagement",
- inline: true
- },
- {
- name: "⚙️ **Config**",
- value: "Server and bot configuration management commands",
- inline: true
- },
- {
- name: "🔢 **Counter**",
- value: "Live counter channel setup and counter controls",
- inline: true
- },
- {
- name: "🎙️ **Join to Create**",
- value: "Dynamic voice channel creation and management",
- inline: true
- },
- {
- name: "🎭 **Reaction Roles**",
- value: "Self-assignable roles using reaction-role systems",
- inline: true
- },
- {
- name: "✅ **Verification**",
- value: "Member verification workflows and access gating",
- inline: true
- },
- {
- name: "🔧 **Utilities**",
- value: "Useful tools and server utilities",
- inline: true
- }
- );
-
- embed.setFooter({
- text: "Made with ❤️"
- });
- embed.setTimestamp();
-
- const bugReportButton = new ButtonBuilder()
- .setCustomId(BUG_REPORT_BUTTON_ID)
- .setLabel("Report Bug")
- .setStyle(ButtonStyle.Danger);
-
- const supportButton = new ButtonBuilder()
- .setLabel("Support Server")
- .setURL("https://discord.gg/QnWNz2dKCE")
- .setStyle(ButtonStyle.Link);
-
- const touchpointButton = new ButtonBuilder()
- .setLabel("Learn from Touchpoint")
- .setURL("https://www.youtube.com/@TouchDisc")
- .setStyle(ButtonStyle.Link);
-
- const selectRow = createSelectMenu(
- CATEGORY_SELECT_ID,
- "Select to view the commands",
- options,
- );
-
- const buttonRow = new ActionRowBuilder().addComponents([
- bugReportButton,
- supportButton,
- touchpointButton,
- ]);
-
- return {
- embeds: [embed],
- components: [buttonRow, selectRow],
- };
-}
export default {
data: new SlashCommandBuilder()
- .setName("help")
- .setDescription("Displays the help menu with all available commands"),
+ .setName('help')
+ .setDescription('Show the nh_starlightsercurity command list and categories'),
async execute(interaction, guildConfig, client) {
-
- const { MessageFlags } = await import('discord.js');
- await InteractionHelper.safeDefer(interaction);
-
- const { embeds, components } = await createInitialHelpMenu(client);
+ const activeClient = client || interaction.client;
+ const isPrefix = interaction._isPrefix === true;
+ if (!isPrefix) {
+ await InteractionHelper.safeDefer(interaction, {
+ flags: MessageFlags.Ephemeral,
+ });
+ }
+
+ const { embeds, components } = await createInitialHelpMenu(activeClient);
await InteractionHelper.safeEditReply(interaction, {
embeds,
components,
+ flags: isPrefix ? undefined : MessageFlags.Ephemeral,
});
-
- setTimeout(async () => {
- try {
- const closedEmbed = createEmbed({
- title: "Help menu closed",
- description: "Help menu has been closed, use /help again.",
- color: "secondary",
- });
-
- await InteractionHelper.safeEditReply(interaction, {
- embeds: [closedEmbed],
- components: [],
- });
- } catch (error) {
-
- }
- }, HELP_MENU_TIMEOUT_MS);
},
};
-
-
diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js
deleted file mode 100644
index 16cd240eb..000000000
--- a/src/commands/Economy/balance.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-export default {
- data: new SlashCommandBuilder()
- .setName('balance')
- .setDescription("Check your or someone else's balance")
- .addUserOption(option =>
- option
- .setName('user')
- .setDescription('User to check balance for')
- .setRequired(false)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const targetUser = interaction.options.getUser("user") || interaction.user;
- const guildId = interaction.guildId;
-
- logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId });
-
- if (targetUser.bot) {
- throw createError(
- "Bot user queried for balance",
- ErrorTypes.VALIDATION,
- "Bots don't have an economy balance."
- );
- }
-
- const userData = await getEconomyData(client, guildId, targetUser.id);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load economy data. Please try again later.",
- { userId: targetUser.id, guildId }
- );
- }
-
- const maxBank = getMaxBankCapacity(userData);
-
- const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0;
- const bank = typeof userData.bank === 'number' ? userData.bank : 0;
-
- const embed = createEmbed({
- title: `💰 ${targetUser.username}'s Balance`,
- description: `Here is the current financial status for ${targetUser.username}.`,
- })
- .addFields(
- {
- name: "💵 Cash",
- value: `$${wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: "🏦 Bank",
- value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`,
- inline: true,
- },
- {
- name: "💎 Total",
- value: `$${(wallet + bank).toLocaleString()}`,
- inline: true,
- }
- )
- .setFooter({
- text: `Requested by ${interaction.user.tag}`,
- iconURL: interaction.user.displayAvatarURL(),
- });
-
- logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'balance' })
-};
-
-
-
-
diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js
deleted file mode 100644
index 260ebc656..000000000
--- a/src/commands/Economy/beg.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { botConfig } from '../../config/bot.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const COOLDOWN = 30 * 60 * 1000;
-const MIN_WIN = 50;
-const MAX_WIN = 200;
-const SUCCESS_CHANCE = 0.7;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('beg')
- .setDescription('Beg for a small amount of money'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
-
- let userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const lastBeg = userData.lastBeg || 0;
- const remainingTime = lastBeg + COOLDOWN - Date.now();
-
- if (remainingTime > 0) {
- const minutes = Math.floor(remainingTime / 60000);
- const seconds = Math.floor((remainingTime % 60000) / 1000);
-
- let timeMessage =
- minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`;
-
- throw createError(
- "Beg cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You are tired from begging! Try again in **${timeMessage}**.`,
- { remainingTime, minutes, seconds, cooldownType: 'beg' }
- );
- }
-
- const success = Math.random() < SUCCESS_CHANCE;
-
- let replyEmbed;
- let newCash = userData.wallet;
-
- if (success) {
- const amountWon =
- Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN;
-
- newCash += amountWon;
-
- const successMessages = [
- `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`,
- `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`,
- `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`,
- `You found **$${amountWon.toLocaleString()}** under a park bench.`,
- ];
-
- replyEmbed = MessageTemplates.SUCCESS.DATA_UPDATED(
- "begging",
- successMessages[
- Math.floor(Math.random() * successMessages.length)
- ]
- );
- } else {
- const failMessages = [
- "The police chased you off. You got nothing.",
- "Someone yelled, 'Get a job!' and walked past.",
- "A squirrel stole the single coin you had.",
- "You tried to beg, but you were too embarrassed and gave up.",
- ];
-
- replyEmbed = MessageTemplates.ERRORS.INSUFFICIENT_FUNDS(
- "nothing",
- "You failed to get any money from begging."
- );
- replyEmbed.data.description = failMessages[Math.floor(Math.random() * failMessages.length)];
- }
-
- userData.wallet = newCash;
-userData.lastBeg = Date.now();
-
- await setEconomyData(client, guildId, userId, userData);
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] });
- }, { command: 'beg' })
-};
-
-
diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js
deleted file mode 100644
index 72f4c62bb..000000000
--- a/src/commands/Economy/buy.js
+++ /dev/null
@@ -1,163 +0,0 @@
-import { SlashCommandBuilder, MessageFlags } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { shopItems } from '../../config/shop/items.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { getGuildConfig } from '../../services/guildConfig.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const SHOP_ITEMS = shopItems;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('buy')
- .setDescription('Buy an item from the shop')
- .addStringOption(option =>
- option
- .setName('item_id')
- .setDescription('ID of the item to buy')
- .setRequired(true)
- )
- .addIntegerOption(option =>
- option
- .setName('quantity')
- .setDescription('Quantity to buy (default: 1)')
- .setRequired(false)
- .setMinValue(1)
- .setMaxValue(10)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const itemId = interaction.options.getString("item_id").toLowerCase();
- const quantity = interaction.options.getInteger("quantity") || 1;
-
- const item = SHOP_ITEMS.find(i => i.id === itemId);
-
- if (!item) {
- throw createError(
- `Item ${itemId} not found`,
- ErrorTypes.VALIDATION,
- `The item ID \`${itemId}\` does not exist in the shop.`,
- { itemId }
- );
- }
-
- if (quantity < 1) {
- throw createError(
- "Invalid quantity",
- ErrorTypes.VALIDATION,
- "You must purchase a quantity of 1 or more.",
- { quantity }
- );
- }
-
- const totalCost = item.price * quantity;
-
- const guildConfig = await getGuildConfig(client, guildId);
- const PREMIUM_ROLE_ID = guildConfig.premiumRoleId;
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (userData.wallet < totalCost) {
- throw createError(
- "Insufficient funds",
- ErrorTypes.VALIDATION,
- `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`,
- { required: totalCost, current: userData.wallet, itemId, quantity }
- );
- }
-
- if (item.type === "role" && itemId === "premium_role") {
- if (!PREMIUM_ROLE_ID) {
- throw createError(
- "Premium role not configured",
- ErrorTypes.CONFIGURATION,
- "The **Premium Shop Role** has not been configured by a server administrator yet.",
- { itemId }
- );
- }
- if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) {
- throw createError(
- "Role already owned",
- ErrorTypes.VALIDATION,
- `You already have the **${item.name}** role.`,
- { itemId, roleId: PREMIUM_ROLE_ID }
- );
- }
- if (quantity > 1) {
- throw createError(
- "Invalid quantity for role",
- ErrorTypes.VALIDATION,
- `You can only purchase the **${item.name}** role once.`,
- { itemId, quantity }
- );
- }
- }
-
- userData.wallet -= totalCost;
-
- let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`;
-
- if (item.type === "role" && itemId === "premium_role") {
- const member = interaction.member;
-
- const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID);
-
- if (!role) {
- throw createError(
- "Role not found",
- ErrorTypes.CONFIGURATION,
- "The configured premium role no longer exists in this guild.",
- { roleId: PREMIUM_ROLE_ID }
- );
- }
-
- try {
- await member.roles.add(
- role,
- `Purchased role: ${item.name}`,
- );
- successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`;
- } catch (roleError) {
- userData.wallet += totalCost;
- await setEconomyData(client, guildId, userId, userData);
- throw createError(
- "Role assignment failed",
- ErrorTypes.DISCORD_API,
- "Successfully deducted money, but failed to grant the role. Your cash has been refunded.",
- { roleId: PREMIUM_ROLE_ID, originalError: roleError.message }
- );
- }
- } else if (item.type === "upgrade") {
- userData.upgrades[itemId] = true;
- successDescription += `\n\n**✨ Your upgrade is now active!**`;
- } else if (item.type === "consumable") {
- userData.inventory[itemId] =
- (userData.inventory[itemId] || 0) + quantity;
- }
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = successEmbed(
- "💰 Purchase Successful",
- successDescription,
- ).addFields({
- name: "New Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] });
- }, { command: 'buy' })
-};
-
-
-
-
-
diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js
deleted file mode 100644
index 2403fe461..000000000
--- a/src/commands/Economy/crime.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const CRIME_COOLDOWN = 60 * 60 * 1000;
-const MIN_CRIME_AMOUNT = 100;
-const MAX_CRIME_AMOUNT = 2000;
-const FAILURE_RATE = 0.4;
-const JAIL_TIME = 2 * 60 * 60 * 1000;
-
-const CRIME_TYPES = [
- { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 },
- { name: "Burglary", min: 300, max: 1000, risk: 0.4 },
- { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 },
- { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 },
- { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 },
-];
-
-export default {
- data: new SlashCommandBuilder()
- .setName('crime')
- .setDescription('Commit a crime to earn money (risky)')
- .addStringOption(option =>
- option
- .setName('type')
- .setDescription('Type of crime to commit')
- .setRequired(true)
- .addChoices(
- { name: 'Pickpocketing', value: 'pickpocketing' },
- { name: 'Burglary', value: 'burglary' },
- { name: 'Bank Heist', value: 'bank-heist' },
- { name: 'Art Theft', value: 'art-theft' },
- { name: 'Cybercrime', value: 'cybercrime' },
- )
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- await InteractionHelper.safeDefer(interaction);
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- const userData = await getEconomyData(client, guildId, userId);
- const lastCrime = userData.cooldowns?.crime || 0;
- const isJailed = userData.jailedUntil && userData.jailedUntil > now;
-
- if (isJailed) {
- const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60));
- throw createError(
- "User is in jail",
- ErrorTypes.RATE_LIMIT,
- `You're in jail for ${timeLeft} more minutes!`,
- { jailTimeRemaining: userData.jailedUntil - now }
- );
- }
-
- if (now < lastCrime + CRIME_COOLDOWN) {
- const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60));
- throw createError(
- "Crime cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to wait ${timeLeft} more minutes before committing another crime.`,
- { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' }
- );
- }
-
- const crimeType = interaction.options.getString("type").toLowerCase();
- const crime = CRIME_TYPES.find(
- c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType
- );
-
- if (!crime) {
- throw createError(
- "Invalid crime type",
- ErrorTypes.VALIDATION,
- "Please select a valid crime type.",
- { crimeType }
- );
- }
-
- const isSuccess = Math.random() > crime.risk;
- const amountEarned = isSuccess
- ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min
- : 0;
-
- userData.cooldowns = userData.cooldowns || {};
- userData.cooldowns.crime = now;
-
- if (isSuccess) {
- userData.wallet = (userData.wallet || 0) + amountEarned;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = successEmbed(
- "Crime Successful!",
- `You successfully committed ${crime.name} and earned **${amountEarned}** coins!`
- );
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- } else {
- const fine = Math.floor(amountEarned * 0.2);
- userData.wallet = Math.max(0, (userData.wallet || 0) - fine);
- userData.jailedUntil = now + JAIL_TIME;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = errorEmbed(
- "Crime Failed!",
- `You were caught while attempting ${crime.name} and have been sent to jail! ` +
- `You were fined ${fine} coins and will be in jail for 2 hours.`
- );
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }
- }, { command: 'crime' })
-};
-
-
diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js
deleted file mode 100644
index ba3d9bee3..000000000
--- a/src/commands/Economy/daily.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { getGuildConfig } from '../../services/guildConfig.js';
-import { formatDuration } from '../../utils/helpers.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const DAILY_COOLDOWN = 24 * 60 * 60 * 1000;
-const DAILY_AMOUNT = 1000;
-const PREMIUM_BONUS_PERCENTAGE = 0.1;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('daily')
- .setDescription('Claim your daily cash reward'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId });
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data for daily",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const lastDaily = userData.lastDaily || 0;
-
- if (now < lastDaily + DAILY_COOLDOWN) {
- const timeRemaining = lastDaily + DAILY_COOLDOWN - now;
- throw createError(
- "Daily cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`,
- { timeRemaining, cooldownType: 'daily' }
- );
- }
-
- const guildConfig = await getGuildConfig(client, guildId);
- const PREMIUM_ROLE_ID = guildConfig.premiumRoleId;
-
- let earned = DAILY_AMOUNT;
- let bonusMessage = "";
- let hasPremiumRole = false;
-
- if (
- PREMIUM_ROLE_ID &&
- interaction.member &&
- interaction.member.roles.cache.has(PREMIUM_ROLE_ID)
- ) {
- const bonusAmount = Math.floor(
- DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE,
- );
- earned += bonusAmount;
- bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`;
- hasPremiumRole = true;
- }
-
- userData.wallet = (userData.wallet || 0) + earned;
- userData.lastDaily = now;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, {
- userId,
- guildId,
- amount: earned,
- newWallet: userData.wallet,
- hasPremium: hasPremiumRole,
- timestamp: new Date().toISOString()
- });
-
- const embed = successEmbed(
- "✅ Daily Claimed!",
- `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}`
- )
- .addFields({
- name: "New Cash Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- })
- .setFooter({
- text: hasPremiumRole
- ? `Next claim in 24 hours. (Premium Active)`
- : `Next claim in 24 hours.`,
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'daily' })
-};
-
-
-
-
diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js
deleted file mode 100644
index 79cab9b42..000000000
--- a/src/commands/Economy/deposit.js
+++ /dev/null
@@ -1,144 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-export default {
- data: new SlashCommandBuilder()
- .setName('deposit')
- .setDescription('Deposit money from your wallet into your bank')
- .addStringOption(option =>
- option
- .setName('amount')
- .setDescription('Amount to deposit (number or "all")')
- .setRequired(true)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const amountInput = interaction.options.getString("amount");
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const maxBank = getMaxBankCapacity(userData);
- let depositAmount;
-
- if (amountInput.toLowerCase() === "all") {
- depositAmount = userData.wallet;
- } else {
- depositAmount = parseInt(amountInput);
-
- if (isNaN(depositAmount) || depositAmount <= 0) {
- throw createError(
- "Invalid deposit amount",
- ErrorTypes.VALIDATION,
- `Please enter a valid number or 'all'. You entered: \`${amountInput}\``,
- { amountInput, userId }
- );
- }
- }
-
- if (depositAmount === 0) {
- throw createError(
- "Zero deposit amount",
- ErrorTypes.VALIDATION,
- "You have no cash to deposit.",
- { userId, walletBalance: userData.wallet }
- );
- }
-
- if (depositAmount > userData.wallet) {
- depositAmount = userData.wallet;
- await interaction.followUp({
- embeds: [
- MessageTemplates.ERRORS.INVALID_INPUT(
- "deposit amount",
- `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**`
- )
- ],
- flags: ["Ephemeral"],
- });
- }
-
- const availableSpace = maxBank - userData.bank;
-
- if (availableSpace <= 0) {
- throw createError(
- "Bank is full",
- ErrorTypes.VALIDATION,
- `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`,
- { maxBank, currentBank: userData.bank, userId }
- );
- }
-
- if (depositAmount > availableSpace) {
- const originalDepositAmount = depositAmount;
- depositAmount = availableSpace;
-
- if (amountInput.toLowerCase() !== "all") {
- await interaction.followUp({
- embeds: [
- MessageTemplates.ERRORS.INVALID_INPUT(
- "deposit amount",
- `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.`
- )
- ],
- flags: ["Ephemeral"],
- });
- }
- }
-
- if (depositAmount === 0) {
- throw createError(
- "No space or cash for deposit",
- ErrorTypes.VALIDATION,
- "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.",
- { depositAmount, availableSpace, walletBalance: userData.wallet }
- );
- }
-
- userData.wallet -= depositAmount;
- userData.bank += depositAmount;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = MessageTemplates.SUCCESS.DATA_UPDATED(
- "deposit",
- `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.`
- )
- .addFields(
- {
- name: "💵 New Cash Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: "🏦 New Bank Balance",
- value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`,
- inline: true,
- },
- );
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'deposit' })
-};
-
-
-
-
-
diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js
deleted file mode 100644
index d96711690..000000000
--- a/src/commands/Economy/eleaderboard.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed } from '../../utils/embeds.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-export default {
- data: new SlashCommandBuilder()
- .setName("eleaderboard")
- .setDescription("View the server's top 10 richest users.")
- .setDMPermission(false),
-
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const guildId = interaction.guildId;
-
- logger.debug(`[ECONOMY] Leaderboard requested`, { guildId });
-
- const prefix = `economy:${guildId}:`;
-
- let allKeys = await client.db.list(prefix);
-
- if (!Array.isArray(allKeys)) {
- allKeys = [];
- }
-
- if (allKeys.length === 0) {
- throw createError(
- "No economy data found",
- ErrorTypes.VALIDATION,
- "No economy data found for this server."
- );
- }
-
- let allUserData = [];
-
- for (const key of allKeys) {
- const userId = key.replace(prefix, "");
- const userData = await client.db.get(key);
-
- if (userData) {
- allUserData.push({
- userId: userId,
- net_worth: (userData.wallet || 0) + (userData.bank || 0),
- });
- }
- }
-
- allUserData.sort((a, b) => b.net_worth - a.net_worth);
-
- const topUsers = allUserData.slice(0, 10);
- const userRank =
- allUserData.findIndex((u) => u.userId === interaction.user.id) +
- 1;
- const rankEmoji = ["🥇", "🥈", "🥉"];
- const leaderboardEntries = [];
-
- for (let i = 0; i < topUsers.length; i++) {
- const user = topUsers[i];
- const rank = i + 1;
- const emoji = rankEmoji[i] || `**#${rank}**`;
-
- leaderboardEntries.push(
- `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`,
- );
- }
-
- logger.info(`[ECONOMY] Leaderboard generated`, {
- guildId,
- userCount: allUserData.length,
- userRank
- });
-
- const description = leaderboardEntries.length > 0
- ? leaderboardEntries.join("\n")
- : "No economy data is available for this server yet.";
-
- const embed = createEmbed({
- title: `Economy Leaderboard`,
- description,
- footer: `Your Rank: ${userRank > 0 ? `#${userRank}` : "No ranking data available"}`,
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'eleaderboard' })
-};
-
-
-
-
-
diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js
deleted file mode 100644
index 367ad453e..000000000
--- a/src/commands/Economy/fish.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const FISH_COOLDOWN = 45 * 60 * 1000;
-const BASE_MIN_REWARD = 300;
-const BASE_MAX_REWARD = 900;
-const FISHING_ROD_MULTIPLIER = 1.5;
-
-const FISH_TYPES = [
- { name: 'Bass', emoji: '🐟', rarity: 'common' },
- { name: 'Salmon', emoji: '🐟', rarity: 'common' },
- { name: 'Trout', emoji: '🐟', rarity: 'common' },
- { name: 'Tuna', emoji: '🐟', rarity: 'uncommon' },
- { name: 'Swordfish', emoji: '🐟', rarity: 'uncommon' },
- { name: 'Octopus', emoji: '🐙', rarity: 'rare' },
- { name: 'Lobster', emoji: '🦞', rarity: 'rare' },
- { name: 'Shark', emoji: '🦈', rarity: 'epic' },
- { name: 'Whale', emoji: '🐋', rarity: 'legendary' },
-];
-
-const CATCH_MESSAGES = [
- "You cast your line into the crystal clear waters...",
- "You wait patiently as your bobber floats...",
- "After a few minutes of waiting, you feel a tug...",
- "The water ripples as something takes your bait...",
- "You reel in your catch with expert precision...",
-];
-
-export default {
- data: new SlashCommandBuilder()
- .setName('fish')
- .setDescription('Go fishing to catch fish and earn money'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- const userData = await getEconomyData(client, guildId, userId);
- const lastFish = userData.lastFish || 0;
- const hasFishingRod = userData.inventory["fishing_rod"] || 0;
-
- if (now < lastFish + FISH_COOLDOWN) {
- const remaining = lastFish + FISH_COOLDOWN - now;
- const hours = Math.floor(remaining / (1000 * 60 * 60));
- const minutes = Math.floor(
- (remaining % (1000 * 60 * 60)) / (1000 * 60),
- );
-
- throw createError(
- "Fishing cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`,
- { remaining, cooldownType: 'fish' }
- );
- }
-
-
- const rand = Math.random();
- let fishCaught;
-
- if (rand < 0.5) {
-
- fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)];
- } else if (rand < 0.75) {
-
- fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)];
- } else if (rand < 0.9) {
-
- fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)];
- } else if (rand < 0.98) {
-
- fishCaught = FISH_TYPES.find(f => f.rarity === 'epic');
- } else {
-
- fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary');
- }
-
- const baseEarned = Math.floor(
- Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1)
- ) + BASE_MIN_REWARD;
-
- let finalEarned = baseEarned;
- let multiplierMessage = "";
-
-
- if (hasFishingRod > 0) {
- finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER);
- multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`;
- }
-
- const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)];
-
- userData.wallet += finalEarned;
- userData.lastFish = now;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const rarityColors = {
- common: '#95A5A6',
- uncommon: '#2ECC71',
- rare: '#3498DB',
- epic: '#9B59B6',
- legendary: '#F1C40F'
- };
-
- const embed = createEmbed({
- title: '🎣 Fishing Success!',
- description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`,
- color: rarityColors[fishCaught.rarity]
- })
- .addFields(
- {
- name: "💵 New Cash Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: "🐟 Rarity",
- value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1),
- inline: true,
- }
- )
- .setFooter({ text: `Next fishing trip available in 45 minutes.` });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'fish' })
-};
diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js
deleted file mode 100644
index d0bb7c3bd..000000000
--- a/src/commands/Economy/gamble.js
+++ /dev/null
@@ -1,136 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const BASE_WIN_CHANCE = 0.4;
-const CLOVER_WIN_BONUS = 0.1;
-const CHARM_WIN_BONUS = 0.08;
-const PAYOUT_MULTIPLIER = 2.0;
-const GAMBLE_COOLDOWN = 5 * 60 * 1000;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('gamble')
- .setDescription('Gamble your money for a chance to win more')
- .addIntegerOption(option =>
- option
- .setName('amount')
- .setDescription('Amount of cash to gamble')
- .setRequired(true)
- .setMinValue(1)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const betAmount = interaction.options.getInteger("amount");
- const now = Date.now();
-
- const userData = await getEconomyData(client, guildId, userId);
- const lastGamble = userData.lastGamble || 0;
- let cloverCount = userData.inventory["lucky_clover"] || 0;
- let charmCount = userData.inventory["lucky_charm"] || 0;
-
- if (now < lastGamble + GAMBLE_COOLDOWN) {
- const remaining = lastGamble + GAMBLE_COOLDOWN - now;
- const minutes = Math.floor(remaining / (1000 * 60));
- const seconds = Math.floor((remaining % (1000 * 60)) / 1000);
-
- throw createError(
- "Gamble cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`,
- { remaining, cooldownType: 'gamble' }
- );
- }
-
- if (userData.wallet < betAmount) {
- throw createError(
- "Insufficient cash for gamble",
- ErrorTypes.VALIDATION,
- `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`,
- { required: betAmount, current: userData.wallet }
- );
- }
-
- let winChance = BASE_WIN_CHANCE;
- let cloverMessage = "";
- let usedClover = false;
- let usedCharm = false;
-
-
- if (cloverCount > 0) {
- winChance += CLOVER_WIN_BONUS;
- userData.inventory["lucky_clover"] -= 1;
- cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`;
- usedClover = true;
- }
-
- else if (charmCount > 0) {
- winChance += CHARM_WIN_BONUS;
- userData.inventory["lucky_charm"] -= 1;
- cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`;
- usedCharm = true;
- }
-
- const win = Math.random() < winChance;
- let cashChange = 0;
- let resultEmbed;
-
- if (win) {
- const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER);
-cashChange = amountWon;
-
- resultEmbed = successEmbed(
- "🎉 You Won!",
- `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`,
- );
- } else {
-cashChange = -betAmount;
-
- resultEmbed = errorEmbed(
- "💔 You Lost...",
- `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`,
- );
- }
-
- userData.wallet = (userData.wallet || 0) + cashChange;
-userData.lastGamble = now;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const newCash = userData.wallet;
-
- resultEmbed.addFields({
- name: "💵 New Cash Balance",
- value: `$${newCash.toLocaleString()}`,
- inline: true,
- });
-
- if (usedClover) {
- resultEmbed.setFooter({
- text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`,
- });
- } else if (usedCharm) {
- resultEmbed.setFooter({
- text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`,
- });
- } else {
- resultEmbed.setFooter({
- text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`,
- });
- }
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] });
- }, { command: 'gamble' })
-};
-
-
-
-
diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js
deleted file mode 100644
index 7369c8388..000000000
--- a/src/commands/Economy/inventory.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { shopItems } from '../../config/shop/items.js';
-import { getEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const SHOP_ITEMS = shopItems;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('inventory')
- .setDescription('View your economy inventory'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
-
- logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId });
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data for inventory",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const inventory = userData.inventory || {};
-
- let inventoryDescription = "Your inventory is currently empty.";
-
- if (Object.keys(inventory).length > 0) {
- inventoryDescription = Object.entries(inventory)
- .filter(
- ([itemId, quantity]) => {
- const item = SHOP_ITEMS.find(i => i.id === itemId);
- return quantity > 0 && item;
- }
- )
- .map(
- ([itemId, quantity]) => {
- const item = SHOP_ITEMS.find(i => i.id === itemId);
- return `**${item.name}:** ${quantity}x`;
- }
- )
- .join("\n");
- }
-
- logger.info(`[ECONOMY] Inventory retrieved`, {
- userId,
- guildId,
- itemCount: Object.keys(inventory).length
- });
-
- const embed = createEmbed({
- title: `📦 ${interaction.user.username}'s Inventory`,
- description: inventoryDescription,
- }).setThumbnail(interaction.user.displayAvatarURL());
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'inventory' })
-};
-
-
-
-
diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js
deleted file mode 100644
index f8dd54cba..000000000
--- a/src/commands/Economy/mine.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const MINE_COOLDOWN = 60 * 60 * 1000;
-const BASE_MIN_REWARD = 400;
-const BASE_MAX_REWARD = 1200;
-const PICKAXE_MULTIPLIER = 1.2;
-const DIAMOND_PICKAXE_MULTIPLIER = 2.0;
-
-const MINE_LOCATIONS = [
- "abandoned gold mine",
- "dark, damp cave",
- "backyard rock quarry",
- "volcanic obsidian vent",
- "deep-sea mineral trench",
-];
-
-export default {
- data: new SlashCommandBuilder()
- .setName('mine')
- .setDescription('Go mining to earn money'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- const userData = await getEconomyData(client, guildId, userId);
- const lastMine = userData.lastMine || 0;
- const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0;
- const hasPickaxe = userData.inventory["pickaxe"] || 0;
-
- if (now < lastMine + MINE_COOLDOWN) {
- const remaining = lastMine + MINE_COOLDOWN - now;
- const hours = Math.floor(remaining / (1000 * 60 * 60));
- const minutes = Math.floor(
- (remaining % (1000 * 60 * 60)) / (1000 * 60),
- );
-
- throw createError(
- "Mining cooldown active",
- ErrorTypes.RATE_LIMIT,
- `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`,
- { remaining, cooldownType: 'mine' }
- );
- }
-
- const baseEarned =
- Math.floor(
- Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1),
- ) + BASE_MIN_REWARD;
-
- let finalEarned = baseEarned;
- let multiplierMessage = "";
-
- if (hasDiamondPickaxe > 0) {
- finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER);
- multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`;
- } else if (hasPickaxe > 0) {
- finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER);
- multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`;
- }
-
- const location =
- MINE_LOCATIONS[
- Math.floor(Math.random() * MINE_LOCATIONS.length)
- ];
-
- userData.wallet += finalEarned;
-userData.lastMine = now;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = successEmbed(
- "💰 Mining Expedition Successful!",
- `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`,
- )
- .addFields({
- name: "💵 New Cash Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- })
- .setFooter({ text: `Next mine available in 1 hour.` });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'mine' })
-};
-
-
-
-
diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js
deleted file mode 100644
index 53ae62d4e..000000000
--- a/src/commands/Economy/modules/shop_browse.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js';
-import { shopItems } from '../../../config/shop/items.js';
-import { getColor } from '../../../config/bot.js';
-import { logger } from '../../../utils/logger.js';
-
-export default {
- async execute(interaction, config, client) {
- try {
- const TARGET_MAX_PAGES = 3;
- const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES));
- const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE);
- let currentPage = 1;
-
- const createShopEmbed = (page) => {
- const startIndex = (page - 1) * ITEMS_PER_PAGE;
- const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE);
- const embed = new EmbedBuilder()
- .setTitle('🛒 Store')
- .setColor(getColor('primary'))
- .setDescription('Use `/buy item_id: quantity:` to purchase an item.');
- pageItems.forEach(item => {
- embed.addFields({
- name: `${item.name} (${item.id})`,
- value: `🏷️ **Type:** ${item.type}\n💚 **Price:** $${item.price.toLocaleString()}\n${item.description}`,
- inline: false,
- });
- });
- embed.setFooter({ text: `Page ${page}/${totalPages}` });
- return embed;
- };
-
- const createShopComponents = (page) => {
- if (totalPages <= 1) return [];
- return [
- new ActionRowBuilder().addComponents(
- new ButtonBuilder()
- .setCustomId('shop_prev')
- .setLabel('⬅️ Previous')
- .setStyle(ButtonStyle.Secondary)
- .setDisabled(page === 1),
- new ButtonBuilder()
- .setCustomId('shop_next')
- .setLabel('Next ➡️')
- .setStyle(ButtonStyle.Secondary)
- .setDisabled(page === totalPages),
- ),
- ];
- };
-
- const message = await interaction.reply({
- embeds: [createShopEmbed(currentPage)],
- components: createShopComponents(currentPage),
- flags: 0,
- });
-
- const collector = message.createMessageComponentCollector({
- componentType: ComponentType.Button,
- time: 300000,
- });
-
- collector.on('collect', async (buttonInteraction) => {
- if (buttonInteraction.user.id !== interaction.user.id) {
- await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop browse` to get your own shop view.', flags: 64 });
- return;
- }
- const { customId } = buttonInteraction;
- if (customId === 'shop_prev' || customId === 'shop_next') {
- await buttonInteraction.deferUpdate();
- if (customId === 'shop_prev' && currentPage > 1) currentPage--;
- else if (customId === 'shop_next' && currentPage < totalPages) currentPage++;
- await buttonInteraction.editReply({
- embeds: [createShopEmbed(currentPage)],
- components: createShopComponents(currentPage),
- });
- }
- });
-
- collector.on('end', async () => {
- try {
- const disabledComponents = createShopComponents(currentPage);
- disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true)));
- await message.edit({ components: disabledComponents });
- } catch (_) {}
- });
- } catch (error) {
- logger.error('shop_browse error:', error);
- await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral });
- }
- },
-};
diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js
deleted file mode 100644
index aaf52f33f..000000000
--- a/src/commands/Economy/modules/shop_config_setrole.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { PermissionsBitField } from 'discord.js';
-import { errorEmbed, successEmbed } from '../../../utils/embeds.js';
-import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js';
-import { InteractionHelper } from '../../../utils/interactionHelper.js';
-import { logger } from '../../../utils/logger.js';
-
-export default {
- async execute(interaction, config, client) {
- if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) {
- return InteractionHelper.safeReply(interaction, {
- embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the premium role.')],
- ephemeral: true,
- });
- }
-
- const role = interaction.options.getRole('role');
- const guildId = interaction.guildId;
-
- try {
- const currentConfig = await getGuildConfig(client, guildId);
- currentConfig.premiumRoleId = role.id;
- await setGuildConfig(client, guildId, currentConfig);
-
- return InteractionHelper.safeReply(interaction, {
- embeds: [successEmbed('✅ Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)],
- ephemeral: true,
- });
- } catch (error) {
- logger.error('shop_config_setrole error:', error);
- return InteractionHelper.safeReply(interaction, {
- embeds: [errorEmbed('System Error', 'Could not save the guild configuration.')],
- ephemeral: true,
- });
- }
- },
-};
diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js
deleted file mode 100644
index 40f6424be..000000000
--- a/src/commands/Economy/pay.js
+++ /dev/null
@@ -1,158 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import EconomyService from '../../services/economyService.js';
-
-export default {
- data: new SlashCommandBuilder()
- .setName('pay')
- .setDescription('Pay another user some of your cash')
- .addUserOption(option =>
- option
- .setName('user')
- .setDescription('User to pay')
- .setRequired(true)
- )
- .addIntegerOption(option =>
- option
- .setName('amount')
- .setDescription('Amount to pay')
- .setRequired(true)
- .setMinValue(1)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const senderId = interaction.user.id;
- const receiver = interaction.options.getUser("user");
- const amount = interaction.options.getInteger("amount");
- const guildId = interaction.guildId;
-
- logger.debug(`[ECONOMY] Pay command initiated`, {
- senderId,
- receiverId: receiver.id,
- amount,
- guildId
- });
-
- if (receiver.bot) {
- throw createError(
- "Cannot pay bot",
- ErrorTypes.VALIDATION,
- "You cannot pay a bot.",
- { receiverId: receiver.id, isBot: true }
- );
- }
-
- if (receiver.id === senderId) {
- throw createError(
- "Cannot pay self",
- ErrorTypes.VALIDATION,
- "You cannot pay yourself.",
- { senderId, receiverId: receiver.id }
- );
- }
-
- if (amount <= 0) {
- throw createError(
- "Invalid payment amount",
- ErrorTypes.VALIDATION,
- "Amount must be greater than zero.",
- { amount, senderId }
- );
- }
-
- const [senderData, receiverData] = await Promise.all([
- getEconomyData(client, guildId, senderId),
- getEconomyData(client, guildId, receiver.id)
- ]);
-
- if (!senderData) {
- throw createError(
- "Failed to load sender economy data",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId: senderId, guildId }
- );
- }
-
- if (!receiverData) {
- throw createError(
- "Failed to load receiver economy data",
- ErrorTypes.DATABASE,
- "Failed to load the receiver's economy data. Please try again later.",
- { userId: receiver.id, guildId }
- );
- }
-
-
-
- const result = await EconomyService.transferMoney(
- client,
- guildId,
- senderId,
- receiver.id,
- amount
- );
-
-
- const updatedSenderData = await getEconomyData(client, guildId, senderId);
- const updatedReceiverData = await getEconomyData(client, guildId, receiver.id);
-
- const embed = MessageTemplates.SUCCESS.DATA_UPDATED(
- "payment",
- `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!`
- )
- .addFields(
- {
- name: "💳 Payment Amount",
- value: `$${amount.toLocaleString()}`,
- inline: true,
- },
- {
- name: "💵 Your New Balance",
- value: `$${updatedSenderData.wallet.toLocaleString()}`,
- inline: true,
- },
- )
- .setFooter({
- text: `Paid to ${receiver.tag}`,
- iconURL: receiver.displayAvatarURL(),
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
-
- logger.info(`[ECONOMY] Payment sent successfully`, {
- senderId,
- receiverId: receiver.id,
- amount,
- senderBalance: updatedSenderData.wallet,
- receiverBalance: updatedReceiverData.wallet
- });
-
- try {
- const receiverEmbed = createEmbed({
- title: "💰 Incoming Payment!",
- description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.`
- }).addFields({
- name: "Your New Cash",
- value: `$${updatedReceiverData.wallet.toLocaleString()}`,
- inline: true,
- });
- await receiver.send({ embeds: [receiverEmbed] });
- } catch (e) {
- logger.warn(`Could not DM user ${receiver.id}: ${e.message}`);
- }
- }, { command: 'pay' })
-};
-
-
-
-
-
diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js
deleted file mode 100644
index d6ae21496..000000000
--- a/src/commands/Economy/rob.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const ROB_COOLDOWN = 4 * 60 * 60 * 1000;
-const BASE_ROB_SUCCESS_CHANCE = 0.25;
-const ROB_PERCENTAGE = 0.15;
-const FINE_PERCENTAGE = 0.1;
-
-export default {
- data: new SlashCommandBuilder()
- .setName('rob')
- .setDescription('Attempt to rob another user (very risky)')
- .addUserOption(option =>
- option
- .setName('user')
- .setDescription('User to rob')
- .setRequired(true)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const robberId = interaction.user.id;
- const victimUser = interaction.options.getUser("user");
- const guildId = interaction.guildId;
- const now = Date.now();
-
- if (robberId === victimUser.id) {
- throw createError(
- "Cannot rob self",
- ErrorTypes.VALIDATION,
- "You cannot rob yourself.",
- { robberId, victimId: victimUser.id }
- );
- }
-
- if (victimUser.bot) {
- throw createError(
- "Cannot rob bot",
- ErrorTypes.VALIDATION,
- "You cannot rob a bot.",
- { victimId: victimUser.id, isBot: true }
- );
- }
-
- const robberData = await getEconomyData(client, guildId, robberId);
- const victimData = await getEconomyData(client, guildId, victimUser.id);
-
- if (!robberData || !victimData) {
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load economy data. Please try again later.",
- { robberId: !!robberData, victimId: !!victimData, guildId }
- );
- }
-
- const lastRob = robberData.lastRob || 0;
-
- if (now < lastRob + ROB_COOLDOWN) {
- const remaining = lastRob + ROB_COOLDOWN - now;
- const hours = Math.floor(remaining / (1000 * 60 * 60));
- const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
-
- throw createError(
- "Robbery cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`,
- { remaining, hours, minutes, cooldownType: 'rob' }
- );
- }
-
- if (victimData.wallet < 500) {
- throw createError(
- "Victim too poor",
- ErrorTypes.VALIDATION,
- `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`,
- { victimWallet: victimData.wallet, required: 500 }
- );
- }
-
- const hasSafe = victimData.inventory["personal_safe"] || 0;
-
- if (hasSafe > 0) {
- robberData.lastRob = now;
- await setEconomyData(client, guildId, robberId, robberData);
-
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- MessageTemplates.ERRORS.CONFIGURATION_REQUIRED(
- "robbery protection",
- `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.`
- )
- ],
- });
- }
-
- const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE;
- let resultEmbed;
-
- if (isSuccessful) {
- const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE);
-
- robberData.wallet = (robberData.wallet || 0) + amountStolen;
- victimData.wallet = (victimData.wallet || 0) - amountStolen;
-
- resultEmbed = MessageTemplates.SUCCESS.DATA_UPDATED(
- "robbery",
- `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!`
- );
- } else {
- const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE);
-
- if ((robberData.wallet || 0) < fineAmount) {
- robberData.wallet = 0;
- } else {
- robberData.wallet = (robberData.wallet || 0) - fineAmount;
- }
-
- resultEmbed = MessageTemplates.ERRORS.INSUFFICIENT_PERMISSIONS(
- "robbery failed",
- `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`
- );
- }
-
- robberData.lastRob = now;
-
- await setEconomyData(client, guildId, robberId, robberData);
- await setEconomyData(client, guildId, victimUser.id, victimData);
-
- resultEmbed
- .addFields(
- {
- name: `Your New Cash (${interaction.user.username})`,
- value: `$${robberData.wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: `Victim's New Cash (${victimUser.username})`,
- value: `$${victimData.wallet.toLocaleString()}`,
- inline: true,
- },
- )
- .setFooter({ text: `Next robbery available in 4 hours.` });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] });
- }, { command: 'rob' })
-};
-
-
-
diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js
deleted file mode 100644
index 6ebdfe458..000000000
--- a/src/commands/Economy/shop.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { SlashCommandBuilder, MessageFlags } from 'discord.js';
-import { errorEmbed } from '../../utils/embeds.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-import shopBrowse from './modules/shop_browse.js';
-import shopConfigSetrole from './modules/shop_config_setrole.js';
-
-export default {
- data: new SlashCommandBuilder()
- .setName('shop')
- .setDescription('Economy shop commands.')
- .addSubcommand(subcommand =>
- subcommand
- .setName('browse')
- .setDescription('Browse the economy shop.'),
- )
- .addSubcommandGroup(group =>
- group
- .setName('config')
- .setDescription('Configure shop settings. (Manage Server required)')
- .addSubcommand(subcommand =>
- subcommand
- .setName('setrole')
- .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.')
- .addRoleOption(option =>
- option
- .setName('role')
- .setDescription('The role to grant for Premium Role purchases.')
- .setRequired(true),
- ),
- ),
- ),
-
- async execute(interaction, config, client) {
- try {
- const subcommandGroup = interaction.options.getSubcommandGroup(false);
- const subcommand = interaction.options.getSubcommand();
-
- if (subcommand === 'browse') {
- return await shopBrowse.execute(interaction, config, client);
- }
-
- if (subcommandGroup === 'config' && subcommand === 'setrole') {
- return await shopConfigSetrole.execute(interaction, config, client);
- }
-
- return InteractionHelper.safeReply(interaction, {
- embeds: [errorEmbed('Error', 'Unknown subcommand.')],
- flags: MessageFlags.Ephemeral,
- });
- } catch (error) {
- logger.error('shop command error:', error);
- await InteractionHelper.safeReply(interaction, {
- content: '❌ An error occurred while running the shop command.',
- flags: MessageFlags.Ephemeral,
- }).catch(() => {});
- }
- },
-};
\ No newline at end of file
diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js
deleted file mode 100644
index 3d4791cf9..000000000
--- a/src/commands/Economy/slut.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const SLUT_COOLDOWN = 45 * 60 * 1000;
-
-const SLUT_ACTIVITIES = [
- { name: "Cam Stream", min: 120, max: 450, risk: 0.2 },
- { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 },
- { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 },
- { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 },
- { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 },
-];
-
-const POSITIVE_OUTCOMES = [
- "Your stream blew up and tips poured in.",
- "A VIP booking paid far above average.",
- "Your after-hours shift was packed and profitable.",
- "Premium requests came through and your payout jumped.",
-];
-
-const FINE_OUTCOMES = [
- "Venue security issued a compliance fine.",
- "A moderation strike triggered a platform fee.",
- "You were flagged and had to pay a penalty.",
-];
-
-const ROBBED_OUTCOMES = [
- "A fake buyer chargeback wiped part of your earnings.",
- "A scam booking cleaned out a chunk of your cash.",
- "You got baited by a fraud account and lost money.",
-];
-
-const LOSS_OUTCOMES = [
- "The set flopped and you had to cover operating costs.",
- "You burned budget on prep and made no return.",
- "The shift went sideways and left you in the red.",
-];
-
-function randomInt(min, max) {
- return Math.floor(Math.random() * (max - min + 1)) + min;
-}
-
-function randomChoice(items) {
- return items[Math.floor(Math.random() * items.length)];
-}
-
-function resolveOutcome(activity, wallet) {
- const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2);
- const fineChance = 0.22;
- const robbedChance = 0.2;
- const roll = Math.random();
-
- if (roll < successChance) {
- const amount = randomInt(activity.min, activity.max);
- return {
- type: 'payout',
- delta: amount,
- message: randomChoice(POSITIVE_OUTCOMES),
- title: `💰 ${activity.name} - Payout`
- };
- }
-
- const remainingAfterSuccess = roll - successChance;
-
- if (remainingAfterSuccess < fineChance) {
- const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4)));
- const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2)));
- const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0;
- return {
- type: 'fine',
- delta: -amount,
- message: randomChoice(FINE_OUTCOMES),
- title: `🚨 ${activity.name} - Fined`
- };
- }
-
- if (remainingAfterSuccess < fineChance + robbedChance) {
- const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35)));
- const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1)));
- const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0;
- return {
- type: 'robbed',
- delta: -amount,
- message: randomChoice(ROBBED_OUTCOMES),
- title: `🕵️ ${activity.name} - Robbed`
- };
- }
-
- const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3)));
- const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15)));
- const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0;
- return {
- type: 'loss',
- delta: -amount,
- message: randomChoice(LOSS_OUTCOMES),
- title: `❌ ${activity.name} - Loss`
- };
-}
-
-export default {
- data: new SlashCommandBuilder()
- .setName('slut')
- .setDescription('Take a risky provocative job for random payout or loss'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId });
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data for slut command",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const lastSlut = userData.lastSlut || 0;
-
- if (now - lastSlut < SLUT_COOLDOWN) {
- const remainingTime = lastSlut + SLUT_COOLDOWN - now;
- throw createError(
- "Slut cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`,
- { timeRemaining: remainingTime, cooldownType: 'slut' }
- );
- }
-
- const activity = randomChoice(SLUT_ACTIVITIES);
-
- const outcome = resolveOutcome(activity, userData.wallet || 0);
-
- userData.lastSlut = now;
- userData.totalSluts = (userData.totalSluts || 0) + 1;
- userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta);
- userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta);
-
- if (outcome.type !== 'payout') {
- userData.failedSluts = (userData.failedSluts || 0) + 1;
- }
-
- userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta);
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, {
- userId,
- guildId,
- activity: activity.name,
- outcomeType: outcome.type,
- amountDelta: outcome.delta,
- newWallet: userData.wallet,
- timestamp: new Date().toISOString()
- });
-
- const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`;
- const summaryLines = [
- `${outcome.message}`,
- `💸 **Net Result:** ${amountLabel}`,
- `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`,
- `📊 **Total Sessions:** ${userData.totalSluts}`,
- `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`,
- `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}`
- ];
-
- const embed = createEmbed({
- title: outcome.title,
- description: summaryLines.join('\n'),
- color: outcome.delta >= 0 ? 'success' : 'error',
- timestamp: true
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'slut' })
-};
-
-
-
-
-
diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js
deleted file mode 100644
index 2fc0b46a1..000000000
--- a/src/commands/Economy/withdraw.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { MessageTemplates } from '../../utils/messageTemplates.js';
-
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-export default {
- data: new SlashCommandBuilder()
- .setName('withdraw')
- .setDescription('Withdraw money from your bank to your wallet')
- .addIntegerOption(option =>
- option
- .setName('amount')
- .setDescription('Amount to withdraw')
- .setRequired(true)
- .setMinValue(1)
- ),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- await InteractionHelper.safeDefer(interaction);
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const amountInput = interaction.options.getInteger("amount");
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- let withdrawAmount = amountInput;
-
- if (withdrawAmount <= 0) {
- throw createError(
- "Invalid withdrawal amount",
- ErrorTypes.VALIDATION,
- "You must withdraw a positive amount.",
- { amount: withdrawAmount, userId }
- );
- }
-
- if (withdrawAmount > userData.bank) {
- withdrawAmount = userData.bank;
- }
-
- if (withdrawAmount === 0) {
- throw createError(
- "Empty bank account",
- ErrorTypes.VALIDATION,
- "Your bank account is empty.",
- { userId, bankBalance: userData.bank }
- );
- }
-
- userData.wallet += withdrawAmount;
- userData.bank -= withdrawAmount;
-
- await setEconomyData(client, guildId, userId, userData);
-
- const embed = MessageTemplates.SUCCESS.DATA_UPDATED(
- "withdrawal",
- `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.`
- )
- .addFields(
- {
- name: "💵 New Cash Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: "🏦 New Bank Balance",
- value: `$${userData.bank.toLocaleString()}`,
- inline: true,
- },
- );
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'withdraw' })
-};
\ No newline at end of file
diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js
deleted file mode 100644
index ca39b2077..000000000
--- a/src/commands/Economy/work.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import { SlashCommandBuilder } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { getEconomyData, setEconomyData } from '../../utils/economy.js';
-import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js';
-import { logger } from '../../utils/logger.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
-
-const WORK_COOLDOWN = 30 * 60 * 1000;
-const MIN_WORK_AMOUNT = 50;
-const MAX_WORK_AMOUNT = 300;
-const LAPTOP_MULTIPLIER = 1.5;
-const WORK_JOBS = [
- "Software Developer",
- "Barista",
- "Janitor",
- "YouTuber",
- "Discord Bot Developer",
- "Cashier",
- "Pizza Delivery Driver",
- "Librarian",
- "Gardener",
- "Data Analyst",
-];
-
-export default {
- data: new SlashCommandBuilder()
- .setName('work')
- .setDescription('Work to earn some money'),
-
- execute: withErrorHandling(async (interaction, config, client) => {
- const deferred = await InteractionHelper.safeDefer(interaction);
- if (!deferred) return;
-
- const userId = interaction.user.id;
- const guildId = interaction.guildId;
- const now = Date.now();
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (!userData) {
- throw createError(
- "Failed to load economy data for work",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId });
-
- const lastWork = userData.lastWork || 0;
- const inventory = userData.inventory || {};
- const extraWorkShifts = inventory["extra_work"] || 0;
- const hasLaptop = inventory["laptop"] || 0;
-
- let cooldownActive = now < lastWork + WORK_COOLDOWN;
- let usedConsumable = false;
-
- if (cooldownActive) {
- if (extraWorkShifts > 0) {
- inventory["extra_work"] = (inventory["extra_work"] || 0) - 1;
- usedConsumable = true;
- } else {
- const remaining = lastWork + WORK_COOLDOWN - now;
- throw createError(
- "Work cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`,
- { timeRemaining: remaining, cooldownType: 'work' }
- );
- }
- }
-
- let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT;
- const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)];
-
-
- let multiplierMessage = "";
- if (hasLaptop > 0) {
- earned = Math.floor(earned * LAPTOP_MULTIPLIER);
- multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!";
- }
-
- userData.wallet = (userData.wallet || 0) + earned;
- userData.lastWork = now;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Work completed`, {
- userId,
- guildId,
- amount: earned,
- job,
- usedConsumable,
- hasLaptop: hasLaptop > 0,
- newWallet: userData.wallet,
- timestamp: new Date().toISOString()
- });
-
- const embed = successEmbed(
- "💼 Work Complete!",
- `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}`
- )
- .addFields(
- {
- name: "💰 New Balance",
- value: `$${userData.wallet.toLocaleString()}`,
- inline: true,
- },
- {
- name: "⏰ Next Work",
- value: ``,
- inline: true,
- }
- )
- .setFooter({
- text: `Requested by ${interaction.user.tag}`,
- iconURL: interaction.user.displayAvatarURL(),
- });
-
- await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
- }, { command: 'work' })
-};
-
-
-
-
diff --git a/src/commands/Fun/8ball.js b/src/commands/Fun/8ball.js
new file mode 100644
index 000000000..32fde1905
--- /dev/null
+++ b/src/commands/Fun/8ball.js
@@ -0,0 +1,111 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { createEmbed, successEmbed } from '../../utils/embeds.js';
+import { logger } from '../../utils/logger.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+import { handleInteractionError } from '../../utils/errorHandler.js';
+
+const RESPONSES = {
+ affirmative: [
+ "Yes",
+ "Certainly",
+ "It is certain",
+ "It is decidedly so",
+ "Without a doubt",
+ "Absolutely",
+ "You may rely on it",
+ "As I see it, yes",
+ "Most likely",
+ "Outlook good",
+ "Signs point to yes"
+ ],
+ noncommittal: [
+ "Reply hazy, try again",
+ "Ask again later",
+ "Better not tell you now",
+ "Cannot predict now",
+ "Concentrate and ask again",
+ "Don't count on it",
+ "Maybe",
+ "Uncertain",
+ "Possibly"
+ ],
+ negative: [
+ "Don't count on it",
+ "My reply is no",
+ "My sources say no",
+ "Outlook not so good",
+ "Very doubtful",
+ "Absolutely not",
+ "No way",
+ "Not a chance",
+ "Forget about it"
+ ]
+};
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("8ball")
+ .setDescription("Ask the magic 8 ball a question")
+ .addStringOption((option) =>
+ option
+ .setName("question")
+ .setDescription("Your question for the magic 8 ball")
+ .setRequired(true)
+ ),
+ category: "fun",
+
+ async execute(interaction) {
+ try {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`8ball interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId
+ });
+ return;
+ }
+
+ const question = interaction.options.getString("question");
+
+ // Get random response
+ const allResponses = [
+ ...RESPONSES.affirmative,
+ ...RESPONSES.noncommittal,
+ ...RESPONSES.negative
+ ];
+ const response = allResponses[Math.floor(Math.random() * allResponses.length)];
+
+ // Determine color based on response type
+ let responseType = "noncommittal";
+ if (RESPONSES.affirmative.includes(response)) {
+ responseType = "affirmative";
+ } else if (RESPONSES.negative.includes(response)) {
+ responseType = "negative";
+ }
+
+ const colors = {
+ affirmative: "#00FF00",
+ noncommittal: "#FFD700",
+ negative: "#FF0000"
+ };
+
+ const embed = createEmbed({
+ title: "🔮 Magic 8 Ball",
+ description: `**Question:** ${question}\n\n**Answer:** ${response}`,
+ color: colors[responseType],
+ });
+
+ await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
+
+ logger.info(`User used 8ball`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ question,
+ response
+ });
+ } catch (error) {
+ logger.error("8ball command error:", error);
+ await handleInteractionError(interaction, error, { subtype: "8ball_failed" });
+ }
+ }
+};
diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js
index ad1659249..630e581f4 100644
--- a/src/commands/Moderation/cases.js
+++ b/src/commands/Moderation/cases.js
@@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js';
import { getModerationCases } from '../../utils/moderation.js';
import { logger } from '../../utils/logger.js';
import { InteractionHelper } from '../../utils/interactionHelper.js';
+
export default {
data: new SlashCommandBuilder()
.setName('cases')
@@ -42,9 +43,12 @@ export default {
return;
}
+ let targetUser = null;
+ let filterType = 'all';
+
try {
- const filterType = interaction.options.getString('filter') || 'all';
- const targetUser = interaction.options.getUser('user');
+ filterType = interaction.options.getString('filter') || 'all';
+ targetUser = interaction.options.getUser('user');
const limit = interaction.options.getInteger('limit') || 10;
const filters = {
@@ -55,11 +59,22 @@ export default {
const cases = await getModerationCases(interaction.guild.id, filters);
- if (cases.length === 0) {
- throw new Error(targetUser
- ? `No moderation cases found for ${targetUser.tag}`
- : `No ${filterType === 'all' ? '' : filterType} cases found in this server.`
- );
+ if (!cases || cases.length === 0) {
+ const noCaseMessage = targetUser
+ ? `No cases found for user ${targetUser.tag} (ID: ${targetUser.id}).`
+ : `No cases found for filter "${filterType === 'all' ? 'All' : filterType}" in this server.`;
+
+ // Plain embed for empty state (avoid error-styled template)
+ const infoEmbed = createEmbed({
+ title: '📋 No cases found',
+ description: noCaseMessage,
+ color: 0x3498db
+ });
+
+ return InteractionHelper.safeEditReply(interaction, {
+ embeds: [infoEmbed],
+ flags: MessageFlags.Ephemeral
+ });
}
const CASES_PER_PAGE = 5;
@@ -126,7 +141,7 @@ export default {
const collector = message.createMessageComponentCollector({
componentType: ComponentType.Button,
-time: 120000
+ time: 120000
});
collector.on('collect', async (buttonInteraction) => {
@@ -163,16 +178,19 @@ time: 120000
components: [disabledRow]
});
} catch (error) {
+ logger.debug('Could not disable cases buttons (message may have been deleted):', error.message);
}
});
} catch (error) {
logger.error('Error in cases command:', error);
+
+ // Surface error details in the reply for easier debugging
return InteractionHelper.safeEditReply(interaction, {
embeds: [
errorEmbed(
'System Error',
- 'An error occurred while retrieving moderation cases. Please try again later.'
+ `An error occurred: \`${error.message || error}\`\nPlease check this error in your database configuration.`
)
],
flags: MessageFlags.Ephemeral
@@ -180,7 +198,3 @@ time: 120000
}
}
};
-
-
-
-
diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js
index 7705db393..f2922029a 100644
--- a/src/commands/Moderation/lock.js
+++ b/src/commands/Moderation/lock.js
@@ -1,111 +1,45 @@
-import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { logEvent } from '../../utils/moderation.js';
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
+import { errorEmbed, successEmbed } from '../../utils/embeds.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
import { logger } from '../../utils/logger.js';
-import { getColor } from '../../config/bot.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
export default {
data: new SlashCommandBuilder()
- .setName("lock")
- .setDescription(
- "Locks the current channel (prevents @everyone from sending messages).",
- )
-.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
- category: "moderation",
-
- async execute(interaction, config, client) {
- const deferSuccess = await InteractionHelper.safeDefer(interaction);
- if (!deferSuccess) {
- logger.warn(`Lock interaction defer failed`, {
- userId: interaction.user.id,
- guildId: interaction.guildId,
- commandName: 'lock'
- });
- return;
- }
-
- if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels))
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Permission Denied",
- "You need the `Manage Channels` permission to lock channels.",
- ),
- ],
- });
-
- const channel = interaction.channel;
- const everyoneRole = interaction.guild.roles.everyone;
+ .setName("lock")
+ .setDescription("Lock the channel for all roles")
+ .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
- try {
- const currentPermissions = channel.permissionsFor(everyoneRole);
- if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) {
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Channel Already Locked",
- `${channel} is already locked.`,
- ),
- ],
- });
- }
+ async execute(interaction, config, client) {
+ const isPrefix = interaction._isPrefix === true;
- await channel.permissionOverwrites.edit(
- everyoneRole,
- { SendMessages: false },
-{ type: 0, reason: `Channel locked by ${interaction.user.tag}` },
- );
-
- const lockEmbed = createEmbed(
- "🔒 Channel Locked (Action Log)",
- `${channel} has been locked down by ${interaction.user}.`,
- )
-.setColor(getColor('moderation'))
- .addFields(
- { name: "Channel", value: channel.toString(), inline: true },
- {
- name: "Moderator",
- value: `${interaction.user.tag} (${interaction.user.id})`,
- inline: true,
- },
- );
-
- await logEvent({
- client,
- guild: interaction.guild,
- event: {
- action: "Channel Locked",
- target: channel.toString(),
- executor: `${interaction.user.tag} (${interaction.user.id})`,
- metadata: {
- channelId: channel.id,
- category: channel.parent?.name || 'None',
- moderatorId: interaction.user.id
- }
+ if (!isPrefix) {
+ await InteractionHelper.safeDefer(interaction);
}
- });
- await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- successEmbed(
- `🔒 **Channel Locked**`,
- `${channel} is now locked down. No one can speak here now.`,
- ),
- ],
- });
- } catch (error) {
- logger.error('Lock command error:', error);
- await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "An unexpected error occurred while trying to lock the channel. Check my permissions (I need 'Manage Channels').",
- ),
- ],
- });
+ const channel = interaction.channel;
+ const guild = interaction.guild;
+
+ try {
+ const overwrites = channel.permissionOverwrites.cache;
+ const roleIds = [...overwrites.keys(), guild.roles.everyone.id];
+
+ for (const id of roleIds) {
+ try {
+ await channel.permissionOverwrites.edit(id, { SendMessages: false });
+ } catch (e) {
+ logger.debug(`Skipping role ${id}: ${e.message}`);
+ }
+ }
+
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [successEmbed("🔒 Channel locked successfully.")],
+ flags: isPrefix ? undefined : MessageFlags.Ephemeral,
+ });
+ } catch (error) {
+ logger.error("Critical lock error:", error);
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed("Error", "Failed to process lock command.")],
+ });
+ }
}
- }
};
-
-
-
diff --git a/src/commands/Moderation/mute.js b/src/commands/Moderation/mute.js
new file mode 100644
index 000000000..c3a6eec4b
--- /dev/null
+++ b/src/commands/Moderation/mute.js
@@ -0,0 +1,253 @@
+import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
+import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
+import { logModerationAction } from '../../utils/moderation.js';
+import { logger } from '../../utils/logger.js';
+import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+
+// Duration parser for prefix commands - handles both "2d" and "2 days" formats
+function parseDurationFromText(text) {
+ const durationMap = {
+ 'm': 1, 'minute': 1, 'minutes': 1,
+ 'h': 60, 'hour': 60, 'hours': 60,
+ 'd': 1440, 'day': 1440, 'days': 1440,
+ 'w': 10080, 'week': 10080, 'weeks': 10080,
+ 'M': 43200, 'month': 43200, 'months': 43200,
+ 'y': 525600, 'year': 525600, 'years': 525600
+ };
+
+ const match = text.toLowerCase().match(/^(\d+)\s*([a-z]+)$/);
+ if (!match) return null;
+
+ const amount = parseInt(match[1]);
+ const unit = match[2];
+ const multiplier = durationMap[unit];
+
+ return multiplier ? amount * multiplier : null;
+}
+
+// Parse duration from multiple words (e.g., "2 days reason" -> 2880 minutes)
+function parseDurationFromMultipleWords(text) {
+ if (!text) return null;
+ const words = text.toLowerCase().split(/\s+/);
+ if (words.length < 2) return null;
+
+ const firstWord = words[0];
+ const secondWord = words[1];
+
+ // Check if first word is a number and second word is a time unit
+ if (/^\d+$/.test(firstWord)) {
+ const durationMap = {
+ 'm': 1, 'minute': 1, 'minutes': 1,
+ 'h': 60, 'hour': 60, 'hours': 60,
+ 'd': 1440, 'day': 1440, 'days': 1440,
+ 'w': 10080, 'week': 10080, 'weeks': 10080,
+ 'M': 43200, 'month': 43200, 'months': 43200,
+ 'y': 525600, 'year': 525600, 'years': 525600
+ };
+
+ const multiplier = durationMap[secondWord];
+ if (multiplier) {
+ return {
+ minutes: parseInt(firstWord) * multiplier,
+ wordsUsed: 2
+ };
+ }
+ }
+ return null;
+}
+
+const durationChoices = [
+ { name: "5 minutes (5m)", value: 5 },
+ { name: "10 minutes (10m)", value: 10 },
+ { name: "30 minutes (30m)", value: 30 },
+ { name: "1 hour (1h)", value: 60 },
+ { name: "3 hours (3h)", value: 180 },
+ { name: "6 hours (6h)", value: 360 },
+ { name: "12 hours (12h)", value: 720 },
+ { name: "1 day (1d)", value: 1440 },
+ { name: "2 days (2d)", value: 2880 },
+ { name: "3 days (3d)", value: 4320 },
+ { name: "1 week (1w)", value: 10080 },
+ { name: "2 weeks (2w)", value: 20160 },
+ { name: "1 month (1M)", value: 43200 },
+ { name: "1 year (1y)", value: 525600 },
+];
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("mute")
+ .setDescription("Mute a user for a specific duration.")
+ .addUserOption((option) =>
+ option
+ .setName("target")
+ .setDescription("User to mute")
+ .setRequired(true),
+ )
+ .addIntegerOption(
+ (option) =>
+ option
+ .setName("duration")
+ .setDescription("Duration of the mute (select from list)")
+ .setRequired(false)
+ .addChoices(...durationChoices),
+ )
+ .addStringOption((option) =>
+ option
+ .setName("custom_duration")
+ .setDescription("Custom duration (e.g., 5m, 1h, 2d, 1w)")
+ .setRequired(false)
+ )
+ .addStringOption((option) =>
+ option.setName("reason").setDescription("Reason for the mute"),
+ )
+ .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
+ category: "moderation",
+
+ async execute(interaction, config, client) {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`Mute interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ commandName: 'mute'
+ });
+ return;
+ }
+
+ try {
+ if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) {
+ throw new TitanBotError(
+ "User lacks permission",
+ ErrorTypes.PERMISSION,
+ "You need the `Moderate Members` permission to set a mute."
+ );
+ }
+
+ const targetUser = interaction.options.getUser("target");
+ const member = interaction.options.getMember("target");
+ let durationMinutes = interaction.options.getInteger("duration");
+ const customDuration = interaction.options.getString("custom_duration");
+ let reason = interaction.options.getString("reason") || "No reason";
+
+ // Parse custom duration if provided (e.g., "5m", "1h", "2d")
+ if (!durationMinutes && customDuration) {
+ const parsed = parseDurationFromText(customDuration);
+ if (parsed) {
+ durationMinutes = parsed;
+ } else {
+ throw new TitanBotError(
+ "Invalid duration format",
+ ErrorTypes.VALIDATION,
+ `Invalid duration format: "${customDuration}". Use format like 5m, 1h, 2d, 1w, 1M, 1y`
+ );
+ }
+ }
+
+ // Handle prefix command: parse duration from text (e.g., "2d", "1h", "2 days")
+ if (interaction._isPrefix && !durationMinutes && reason) {
+ const reasonText = reason;
+ // First try compact format (2d, 1h)
+ const firstWord = reasonText.split(' ')[0];
+ let parsed = parseDurationFromText(firstWord);
+ if (parsed) {
+ durationMinutes = parsed;
+ // Remove duration from reason
+ const parts = reasonText.split(' ');
+ reason = parts.slice(1).join(' ') || "No reason";
+ } else {
+ // Try spaced format (2 days, 1 hour)
+ const multiWordParsed = parseDurationFromMultipleWords(reasonText);
+ if (multiWordParsed) {
+ durationMinutes = multiWordParsed.minutes;
+ // Remove duration from reason
+ const parts = reasonText.split(' ');
+ reason = parts.slice(multiWordParsed.wordsUsed).join(' ') || "No reason";
+ }
+ }
+ }
+
+ // Validate duration
+ if (!durationMinutes) {
+ throw new TitanBotError(
+ "Missing duration",
+ ErrorTypes.VALIDATION,
+ "Please specify a duration using dropdown (e.g., 1 day), custom format (e.g., 5m, 1h, 2d), or prefix format."
+ );
+ }
+
+ if (targetUser.id === interaction.user.id) {
+ throw new TitanBotError(
+ "Cannot mute self",
+ ErrorTypes.VALIDATION,
+ "You cannot mute yourself."
+ );
+ }
+ if (targetUser.id === client.user.id) {
+ throw new TitanBotError(
+ "Cannot mute bot",
+ ErrorTypes.VALIDATION,
+ "You cannot mute the bot."
+ );
+ }
+ if (!member) {
+ throw new TitanBotError(
+ "Target not found",
+ ErrorTypes.USER_INPUT,
+ "The target user is not currently in this server."
+ );
+ }
+
+ if (!member.moderatable) {
+ throw new TitanBotError(
+ "Cannot mute member",
+ ErrorTypes.PERMISSION,
+ "I cannot mute this user. They might have a higher role than me or you."
+ );
+ }
+
+ const durationMs = durationMinutes * 60 * 1000;
+ await member.timeout(durationMs, reason);
+
+ const durationDisplay =
+ durationChoices.find((c) => c.value === durationMinutes)
+ ?.name || `${durationMinutes} minutes`;
+
+ const caseId = await logModerationAction({
+ client,
+ guild: interaction.guild,
+ event: {
+ action: "Member Muted",
+ target: `${targetUser.tag} (${targetUser.id})`,
+ executor: `${interaction.user.tag} (${interaction.user.id})`,
+ reason: `${reason}\nDuration: ${durationDisplay}`,
+ duration: durationDisplay,
+ metadata: {
+ userId: targetUser.id,
+ moderatorId: interaction.user.id,
+ durationMinutes,
+ timeoutEnds: new Date(Date.now() + durationMs).toISOString()
+ }
+ }
+ });
+
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [
+ successEmbed(
+ `🔇 **Muted** ${targetUser.tag} for ${durationDisplay}.`,
+ `**Reason:** ${reason}\n**Case ID:** #${caseId}`,
+ ),
+ ],
+ });
+ } catch (error) {
+ logger.error('Mute command error:', error);
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [
+ errorEmbed(
+ error.userMessage || "An unexpected error occurred during the mute action. Please check my role permissions.",
+ ),
+ ],
+ });
+ }
+ }
+};
diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js
index 1420ea75f..d5a7b1783 100644
--- a/src/commands/Moderation/purge.js
+++ b/src/commands/Moderation/purge.js
@@ -1,135 +1,88 @@
-import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { logEvent } from '../../utils/moderation.js';
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
+import { errorEmbed, successEmbed } from '../../utils/embeds.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
import { logger } from '../../utils/logger.js';
-import { checkRateLimit } from '../../utils/rateLimiter.js';
-import { getColor } from '../../config/bot.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
export default {
data: new SlashCommandBuilder()
- .setName("purge")
- .setDescription("Delete a specific amount of messages")
- .addIntegerOption((option) =>
- option
- .setName("amount")
- .setDescription("Number of messages (1-100)")
- .setRequired(true),
- )
-.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
- category: "moderation",
+ .setName("purge")
+ .setDescription("Delete a specific amount of messages")
+ .addIntegerOption((option) =>
+ option.setName("amount")
+ .setDescription("Number of messages (1-100)")
+ .setRequired(true)
+ )
+ .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
- async execute(interaction, config, client) {
- const deferSuccess = await InteractionHelper.safeDefer(interaction);
- if (!deferSuccess) {
- logger.warn(`Purge interaction defer failed`, {
- userId: interaction.user.id,
- guildId: interaction.guildId,
- commandName: 'purge'
- });
- return;
- }
+ async execute(interaction, config, client) {
+ const isPrefix = interaction._isPrefix === true;
- if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages))
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Permission Denied",
- "You need the `Manage Messages` permission to purge messages.",
- ),
- ],
- });
+ if (!isPrefix) {
+ await InteractionHelper.safeDefer(interaction);
+ }
- const amount = interaction.options.getInteger("amount");
- const channel = interaction.channel;
+ let amount = interaction.options.getInteger("amount") ?? 10;
+
+ // Validate amount
+ if (!amount) {
+ return InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed("Missing Parameter", "Please specify the amount of messages to delete.")],
+ });
+ }
- if (amount < 1 || amount > 100)
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Invalid Amount",
- "Please specify a number between 1 and 100.",
- ),
- ],
- });
+ if (amount < 1) amount = 1;
+ if (amount > 500) amount = 500; // Increased from 100 to 500
- try {
-
- const rateLimitKey = `purge_${interaction.user.id}`;
- const isAllowed = await checkRateLimit(rateLimitKey, 5, 60000);
- if (!isAllowed) {
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- warningEmbed(
- "You're purging messages too fast. Please wait a minute before trying again.",
- "⏳ Rate Limited"
- ),
- ],
- flags: MessageFlags.Ephemeral,
- });
- }
+ const channel = interaction.channel;
- const fetched = await channel.messages.fetch({ limit: amount });
- const deleted = await channel.bulkDelete(fetched, true);
- const deletedCount = deleted.size;
+ try {
+ let deletedCount = 0;
+ let remaining = amount;
+
+ // Purge in batches to handle more than 100 messages
+ while (remaining > 0) {
+ const batchSize = Math.min(remaining, 100);
+ // Fetch batchSize + 1 to account for the command message itself
+ const fetched = await channel.messages.fetch({ limit: batchSize + 1 });
+ const messagesToDelete = Array.from(fetched.values()).slice(0, batchSize);
- const purgeEmbed = createEmbed(
- "🗑️ Messages Purged (Action Log)",
- `${deletedCount} messages were deleted by ${interaction.user}.`,
- )
-.setColor(getColor('moderation'))
- .addFields(
- { name: "Channel", value: channel.toString(), inline: true },
- {
- name: "Moderator",
- value: `${interaction.user.tag} (${interaction.user.id})`,
- inline: true,
- },
- { name: "Count", value: `${deletedCount} messages`, inline: false },
- );
+ if (messagesToDelete.length === 0) {
+ break;
+ }
- await logEvent({
- client,
- guild: interaction.guild,
- event: {
- action: "Messages Purged",
- target: `${channel} (${deletedCount} messages)`,
- executor: `${interaction.user.tag} (${interaction.user.id})`,
- reason: `Deleted ${deletedCount} messages`,
- metadata: {
- channelId: channel.id,
- messageCount: deletedCount,
- requestedAmount: amount,
- moderatorId: interaction.user.id
- }
- }
- });
+ if (messagesToDelete.length === 1) {
+ await messagesToDelete[0].delete().catch(() => {});
+ deletedCount += 1;
+ } else {
+ const deleted = await channel.bulkDelete(messagesToDelete, true).catch(() => {
+ // Fallback to deleting one by one if bulk delete fails
+ return Promise.all(
+ messagesToDelete.map(m => m.delete().catch(() => {}))
+ ).then(() => ({ size: messagesToDelete.length }));
+ });
+ deletedCount += deleted?.size || 0;
+ }
+
+ remaining -= messagesToDelete.length;
+ }
+
+ if (deletedCount === 0) {
+ return InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed("No messages found", "There are no messages available to delete.")],
+ });
+ }
- await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- successEmbed(`🗑️ Deleted ${deletedCount} messages in ${channel}.`),
- ],
-flags: MessageFlags.Ephemeral,
- });
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [successEmbed(`🗑️ Successfully deleted ${deletedCount} messages.`)],
+ flags: isPrefix ? undefined : MessageFlags.Ephemeral,
+ });
- setTimeout(() => {
- interaction.deleteReply().catch(err =>
- logger.debug('Failed to auto-delete purge response:', err)
- );
- }, 3000);
- } catch (error) {
- logger.error('Purge command error:', error);
- await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.",
- ),
- ],
- flags: MessageFlags.Ephemeral,
- });
+ setTimeout(() => interaction.deleteReply().catch(() => {}), 3000);
+ } catch (error) {
+ logger.error("Purge error:", error);
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed("Error", "Failed to delete messages. (Older than 14 days or system messages.)")],
+ });
+ }
}
- }
};
-
-
-
diff --git a/src/commands/Moderation/quarantine.js b/src/commands/Moderation/quarantine.js
new file mode 100644
index 000000000..38e002a00
--- /dev/null
+++ b/src/commands/Moderation/quarantine.js
@@ -0,0 +1,50 @@
+import { SlashCommandBuilder, PermissionsBitField, Colors } from 'discord.js';
+import { pgDb } from '../../utils/database.js';
+import { logger } from '../../utils/logger.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName('quarantine')
+ .setDescription('Quarantine a member')
+ .addUserOption(option => option.setName('user').setDescription('Member to quarantine').setRequired(true)),
+
+ async execute(interaction) {
+ await interaction.deferReply({ ephemeral: true });
+
+ const member = interaction.options.getMember('user');
+ if (!member) return interaction.editReply({ content: 'Member not found.' });
+
+ if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) {
+ return interaction.editReply({ content: 'You do not have permission.' });
+ }
+
+ // Đảm bảo bảng tồn tại
+ try {
+ await pgDb.pool.query(`CREATE TABLE IF NOT EXISTS quarantine_data (user_id VARCHAR(20) PRIMARY KEY, roles TEXT NOT NULL)`);
+ } catch (e) { /* Table may already exist */ }
+
+ let role = interaction.guild.roles.cache.find(r => r.name === 'Quarantine');
+ if (!role) {
+ role = await interaction.guild.roles.create({
+ name: 'Quarantine',
+ color: Colors.Red,
+ reason: 'Auto-created Quarantine role'
+ });
+ }
+
+ const rolesToSave = member.roles.cache.filter(r => r.id !== interaction.guild.id && r.id !== role.id).map(r => r.id);
+
+ try {
+ await pgDb.pool.query(
+ 'INSERT INTO quarantine_data (user_id, roles) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET roles = $2',
+ [member.id, JSON.stringify(rolesToSave)]
+ );
+
+ await member.roles.set([role.id]);
+ await interaction.editReply({ content: `Successfully quarantined ${member.user.tag}.` });
+ } catch (error) {
+ logger.error('Quarantine database error:', error);
+ await interaction.editReply({ content: 'Failed to apply quarantine. Check role hierarchy.' });
+ }
+ }
+};
diff --git a/src/commands/Moderation/quarantinesetup.js b/src/commands/Moderation/quarantinesetup.js
new file mode 100644
index 000000000..8df6ee28e
--- /dev/null
+++ b/src/commands/Moderation/quarantinesetup.js
@@ -0,0 +1,47 @@
+import { SlashCommandBuilder, PermissionsBitField, Colors } from 'discord.js';
+import { logger } from '../../utils/logger.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName('setup-quarantine')
+ .setDescription('Create and setup the Quarantine role'),
+
+ async execute(interaction) {
+ // Only allow administrators to run this
+ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) {
+ return interaction.reply({ content: 'You need Administrator permissions!', ephemeral: true });
+ }
+
+ await interaction.deferReply({ ephemeral: true });
+
+ try {
+ // Get bot's highest role position
+ const botMember = await interaction.guild.members.fetch(interaction.client.user.id);
+ const botTopRolePosition = botMember.roles.highest.position;
+
+ // Create the role
+ const role = await interaction.guild.roles.create({
+ name: 'Quarantine',
+ color: Colors.Red,
+ reason: 'Automated setup for Quarantine system',
+ position: botTopRolePosition - 1 // Place it 1 position below the bot's top role
+ });
+
+ // Iterate through channels and deny viewing permissions
+ const channels = interaction.guild.channels.cache;
+ for (const [channelId, channel] of channels) {
+ // Skip category channels if you want, or just apply to all
+ if (channel.permissionOverwrites) {
+ await channel.permissionOverwrites.create(role, {
+ ViewChannel: false
+ }).catch(err => logger.warn(`Failed to update permissions for ${channel.name}: ${err.message}`));
+ }
+ }
+
+ await interaction.editReply(`Quarantine role created (Red) and channels secured. Role ID: ${role.id}`);
+ } catch (error) {
+ logger.error('Quarantine setup error:', error);
+ await interaction.editReply('An error occurred while setting up the quarantine system.');
+ }
+ }
+};
diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js
index 04f205a3f..973d799cd 100644
--- a/src/commands/Moderation/timeout.js
+++ b/src/commands/Moderation/timeout.js
@@ -6,14 +6,74 @@ import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js';
import { InteractionHelper } from '../../utils/interactionHelper.js';
+
+// Duration parser for prefix commands - handles both "2d" and "2 days" formats
+function parseDurationFromText(text) {
+ const durationMap = {
+ 'm': 1, 'minute': 1, 'minutes': 1,
+ 'h': 60, 'hour': 60, 'hours': 60,
+ 'd': 1440, 'day': 1440, 'days': 1440,
+ 'w': 10080, 'week': 10080, 'weeks': 10080,
+ 'M': 43200, 'month': 43200, 'months': 43200,
+ 'y': 525600, 'year': 525600, 'years': 525600
+ };
+
+ const match = text.toLowerCase().match(/^(\d+)\s*([a-z]+)$/);
+ if (!match) return null;
+
+ const amount = parseInt(match[1]);
+ const unit = match[2];
+ const multiplier = durationMap[unit];
+
+ return multiplier ? amount * multiplier : null;
+}
+
+// Parse duration from multiple words (e.g., "2 days reason" -> 2880 minutes)
+function parseDurationFromMultipleWords(text) {
+ if (!text) return null;
+ const words = text.toLowerCase().split(/\s+/);
+ if (words.length < 2) return null;
+
+ const firstWord = words[0];
+ const secondWord = words[1];
+
+ // Check if first word is a number and second word is a time unit
+ if (/^\d+$/.test(firstWord)) {
+ const durationMap = {
+ 'm': 1, 'minute': 1, 'minutes': 1,
+ 'h': 60, 'hour': 60, 'hours': 60,
+ 'd': 1440, 'day': 1440, 'days': 1440,
+ 'w': 10080, 'week': 10080, 'weeks': 10080,
+ 'M': 43200, 'month': 43200, 'months': 43200,
+ 'y': 525600, 'year': 525600, 'years': 525600
+ };
+
+ const multiplier = durationMap[secondWord];
+ if (multiplier) {
+ return {
+ minutes: parseInt(firstWord) * multiplier,
+ wordsUsed: 2
+ };
+ }
+ }
+ return null;
+}
+
const durationChoices = [
- { name: "5 minutes", value: 5 },
- { name: "10 minutes", value: 10 },
- { name: "30 minutes", value: 30 },
- { name: "1 hour", value: 60 },
- { name: "6 hours", value: 360 },
- { name: "1 day", value: 1440 },
- { name: "1 week", value: 10080 },
+ { name: "5 minutes (5m)", value: 5 },
+ { name: "10 minutes (10m)", value: 10 },
+ { name: "30 minutes (30m)", value: 30 },
+ { name: "1 hour (1h)", value: 60 },
+ { name: "3 hours (3h)", value: 180 },
+ { name: "6 hours (6h)", value: 360 },
+ { name: "12 hours (12h)", value: 720 },
+ { name: "1 day (1d)", value: 1440 },
+ { name: "2 days (2d)", value: 2880 },
+ { name: "3 days (3d)", value: 4320 },
+ { name: "1 week (1w)", value: 10080 },
+ { name: "2 weeks (2w)", value: 20160 },
+ { name: "1 month (1M)", value: 43200 },
+ { name: "1 year (1y)", value: 525600 },
];
export default {
data: new SlashCommandBuilder()
@@ -29,14 +89,20 @@ export default {
(option) =>
option
.setName("duration")
- .setDescription("Duration of the timeout")
- .setRequired(true)
-.addChoices(...durationChoices),
+ .setDescription("Duration of the timeout (select from list)")
+ .setRequired(false)
+ .addChoices(...durationChoices),
+ )
+ .addStringOption((option) =>
+ option
+ .setName("custom_duration")
+ .setDescription("Custom duration (e.g., 5m, 1h, 2d, 1w)")
+ .setRequired(false)
)
.addStringOption((option) =>
option.setName("reason").setDescription("Reason for the timeout"),
)
-.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
+ .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
category: "moderation",
async execute(interaction, config, client) {
@@ -61,8 +127,55 @@ export default {
const targetUser = interaction.options.getUser("target");
const member = interaction.options.getMember("target");
- const durationMinutes = interaction.options.getInteger("duration");
- const reason = interaction.options.getString("reason") || "No reason provided";
+ let durationMinutes = interaction.options.getInteger("duration");
+ const customDuration = interaction.options.getString("custom_duration");
+ let reason = interaction.options.getString("reason") || "No reason";
+
+ // Parse custom duration if provided (e.g., "5m", "1h", "2d")
+ if (!durationMinutes && customDuration) {
+ const parsed = parseDurationFromText(customDuration);
+ if (parsed) {
+ durationMinutes = parsed;
+ } else {
+ throw new TitanBotError(
+ "Invalid duration format",
+ ErrorTypes.VALIDATION,
+ `Invalid duration format: "${customDuration}". Use format like 5m, 1h, 2d, 1w, 1M, 1y`
+ );
+ }
+ }
+
+ // Handle prefix command: parse duration from text (e.g., "2d", "1h", "2 days")
+ if (interaction._isPrefix && !durationMinutes && reason) {
+ const reasonText = reason;
+ // First try compact format (2d, 1h)
+ const firstWord = reasonText.split(' ')[0];
+ let parsed = parseDurationFromText(firstWord);
+ if (parsed) {
+ durationMinutes = parsed;
+ // Remove duration from reason
+ const parts = reasonText.split(' ');
+ reason = parts.slice(1).join(' ') || "No reason";
+ } else {
+ // Try spaced format (2 days, 1 hour)
+ const multiWordParsed = parseDurationFromMultipleWords(reasonText);
+ if (multiWordParsed) {
+ durationMinutes = multiWordParsed.minutes;
+ // Remove duration from reason
+ const parts = reasonText.split(' ');
+ reason = parts.slice(multiWordParsed.wordsUsed).join(' ') || "No reason";
+ }
+ }
+ }
+
+ // Validate duration
+ if (!durationMinutes) {
+ throw new TitanBotError(
+ "Missing duration",
+ ErrorTypes.VALIDATION,
+ "Please specify a duration using dropdown (e.g., 1 day), custom format (e.g., 5m, 1h, 2d), or prefix format."
+ );
+ }
if (targetUser.id === interaction.user.id) {
throw new TitanBotError(
diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js
index 876dc3e56..229ce891b 100644
--- a/src/commands/Moderation/unlock.js
+++ b/src/commands/Moderation/unlock.js
@@ -1,126 +1,41 @@
-import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js';
-import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js';
-import { logEvent } from '../../utils/moderation.js';
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js';
+import { errorEmbed, successEmbed } from '../../utils/embeds.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
import { logger } from '../../utils/logger.js';
-import { getColor } from '../../config/bot.js';
-import { InteractionHelper } from '../../utils/interactionHelper.js';
export default {
data: new SlashCommandBuilder()
.setName("unlock")
- .setDescription(
- "Unlocks the current channel (allows @everyone to send messages again).",
- )
-.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
- category: "moderation",
+ .setDescription("Unlock the channel for all roles")
+ .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
async execute(interaction, config, client) {
- const deferSuccess = await InteractionHelper.safeDefer(interaction);
- if (!deferSuccess) {
- logger.warn(`Unlock interaction defer failed`, {
- userId: interaction.user.id,
- guildId: interaction.guildId,
- commandName: 'unlock'
- });
- return;
- }
-
- if (
- !interaction.member.permissions.has(
- PermissionFlagsBits.ManageChannels,
- )
- )
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Permission Denied",
- "You need the `Manage Channels` permission to unlock channels.",
- ),
- ],
- });
-
+ await InteractionHelper.safeDefer(interaction);
const channel = interaction.channel;
- const everyoneRole = interaction.guild.roles.everyone;
+ const guild = interaction.guild;
try {
- const currentPermissions = channel.permissionsFor(everyoneRole);
- if (
- currentPermissions.has(PermissionFlagsBits.SendMessages) ===
- true ||
- currentPermissions.has(PermissionFlagsBits.SendMessages) ===
- null
- ) {
- return await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "Channel Already Unlocked",
- `${channel} is not explicitly locked (everyone can already send messages).`,
- ),
- ],
- });
- }
-
- await channel.permissionOverwrites.edit(
- everyoneRole,
- { SendMessages: true },
- {
- type: 0,
- reason: `Channel unlocked by ${interaction.user.tag}`,
-},
- );
-
- const unlockEmbed = createEmbed(
- "🔓 Channel Unlocked (Action Log)",
- `${channel} has been unlocked by ${interaction.user}.`,
- )
-.setColor(getColor('success'))
- .addFields(
- {
- name: "Channel",
- value: channel.toString(),
- inline: true,
- },
- {
- name: "Moderator",
- value: `${interaction.user.tag} (${interaction.user.id})`,
- inline: true,
- },
- );
-
- await logEvent({
- client,
- guild: interaction.guild,
- event: {
- action: "Channel Unlocked",
- target: channel.toString(),
- executor: `${interaction.user.tag} (${interaction.user.id})`,
- metadata: {
- channelId: channel.id,
- category: channel.parent?.name || 'None'
- }
+ const overwrites = channel.permissionOverwrites.cache;
+ const roleIds = [...overwrites.keys(), guild.roles.everyone.id];
+
+ for (const id of roleIds) {
+ try {
+ await channel.permissionOverwrites.edit(id, { SendMessages: null });
+ } catch (e) {
+ logger.debug(`Skipping role ${id}: ${e.message}`);
}
- });
+ }
await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- successEmbed(
- `🔓 **Channel Unlocked**`,
- `${channel} is now unlocked. You may speak now.`,
- ),
- ],
+ embeds: [successEmbed("🔓 Channel unlocked successfully.")],
+ flags: MessageFlags.Ephemeral,
});
+
} catch (error) {
- logger.error('Unlock command error:', error);
+ logger.error("Critical unlock error:", error);
await InteractionHelper.safeEditReply(interaction, {
- embeds: [
- errorEmbed(
- "An unexpected error occurred while trying to unlock the channel. Check my permissions (I need 'Manage Channels').",
- ),
- ],
+ embeds: [errorEmbed("Error", "Failed to process unlock command.")],
});
}
}
};
-
-
-
diff --git a/src/commands/Moderation/unquarantine.js b/src/commands/Moderation/unquarantine.js
new file mode 100644
index 000000000..fd22f88b3
--- /dev/null
+++ b/src/commands/Moderation/unquarantine.js
@@ -0,0 +1,40 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { pgDb } from '../../utils/database.js';
+import { logger } from '../../utils/logger.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName('unquarantine')
+ .setDescription('Remove quarantine and restore roles')
+ .addUserOption(option => option.setName('user').setDescription('Member to unquarantine').setRequired(true)),
+
+ async execute(interaction) {
+ await interaction.deferReply({ ephemeral: true });
+
+ const target = interaction.options.getMember('user');
+ if (!target) return interaction.editReply({ content: 'Member not found.' });
+
+ try {
+ const res = await pgDb.pool.query('SELECT roles FROM quarantine_data WHERE user_id = $1', [target.id]);
+
+ if (res.rows.length === 0) {
+ return interaction.editReply({ content: 'This user is not in quarantine database.' });
+ }
+
+ let oldRoles;
+ try {
+ oldRoles = JSON.parse(res.rows[0].roles);
+ } catch (parseError) {
+ return interaction.editReply({ content: 'Failed to parse quarantine data. The data may be corrupted.' });
+ }
+
+ await target.roles.set(oldRoles);
+ await pgDb.pool.query('DELETE FROM quarantine_data WHERE user_id = $1', [target.id]);
+
+ await interaction.editReply({ content: `Successfully unquarantined ${target.user.tag}.` });
+ } catch (error) {
+ logger.error('Unquarantine database error:', error);
+ await interaction.editReply({ content: 'Database error or missing permissions.' });
+ }
+ }
+};
diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js
index 571214ece..be575c929 100644
--- a/src/commands/Moderation/warn.js
+++ b/src/commands/Moderation/warn.js
@@ -18,7 +18,7 @@ export default {
.addStringOption((o) =>
o
.setName("reason")
- .setRequired(true)
+ .setRequired(false)
.setDescription("Reason for the warning"),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
@@ -42,7 +42,7 @@ export default {
const target = interaction.options.getUser("target");
const member = interaction.options.getMember("target");
- const reason = interaction.options.getString("reason");
+ const reason = interaction.options.getString("reason") || "No reason";
const moderator = interaction.user;
const guildId = interaction.guildId;
@@ -50,7 +50,6 @@ export default {
throw new Error("The target user is not currently in this server.");
}
-
const result = await WarningService.addWarning({
guildId,
userId: target.id,
@@ -81,6 +80,9 @@ export default {
warningId: result.id
}
}
+ }).catch(logErr => {
+ logger.warn('Failed to log warn action:', logErr);
+ // Continue anyway - don't fail the command because of logging
});
await InteractionHelper.safeEditReply(interaction, {
diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js
index 834bdd1da..94bdf33c9 100644
--- a/src/commands/Moderation/warnings.js
+++ b/src/commands/Moderation/warnings.js
@@ -33,6 +33,11 @@ export default {
try {
const target = interaction.options.getUser("target");
const guildId = interaction.guildId;
+
+ // Validate target
+ if (!target) {
+ throw new Error("Please specify a user to check warnings for.");
+ }
const validWarnings = await WarningService.getWarnings(guildId, target.id);
@@ -57,13 +62,19 @@ export default {
const warningFields = validWarnings
.map((w, i) => {
- const discordTimestamp = Math.floor(w.timestamp / 1000);
- return {
- name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`,
- value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`,
- inline: false,
- };
+ try {
+ const discordTimestamp = Math.floor(w.timestamp / 1000);
+ return {
+ name: `[#${i + 1}] Reason: ${w.reason ? w.reason.substring(0, 100) : "No reason"}`,
+ value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`,
+ inline: false,
+ };
+ } catch (err) {
+ logger.warn(`Error processing warning ${i + 1}:`, err);
+ return null;
+ }
})
+ .filter(f => f !== null)
.slice(0, 25);
embed.addFields(warningFields);
@@ -82,6 +93,9 @@ export default {
totalWarnings: totalWarns
}
}
+ }).catch(logErr => {
+ logger.warn('Failed to log warnings event:', logErr);
+ // Continue anyway - don't fail the command because of logging
});
await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
diff --git a/src/commands/Search/movie.js b/src/commands/Search/movie.js
index a4e72ebc0..dc84a430a 100644
--- a/src/commands/Search/movie.js
+++ b/src/commands/Search/movie.js
@@ -8,7 +8,6 @@ import { getGuildConfig } from '../../services/guildConfig.js';
import { getColor } from '../../config/bot.js';
const TMDB_API_KEY = process.env.TMDB_API_KEY || '4e44d9029b1270a757cddc766a1bcb63';
- "4e44d9029b1270a757cddc766a1bcb63";
const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";
const MAX_RESULTS = 5;
diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js
index e2020025b..1a5b2df1c 100644
--- a/src/commands/Search/urban.js
+++ b/src/commands/Search/urban.js
@@ -76,8 +76,8 @@ export default {
}
const definition = response.data.list[0];
- const cleanDefinition = definition.definition.replace(/\[|\]/g, '');
- const cleanExample = definition.example.replace(/\[|\]/g, '');
+ const cleanDefinition = (definition.definition || '').replace(/\[|\]/g, '');
+ const cleanExample = (definition.example || '').replace(/\[|\]/g, '');
const formattedDefinition = cleanDefinition
.replace(/\n\s*\n/g, '\n\n')
diff --git a/src/commands/Utility/afk.js b/src/commands/Utility/afk.js
new file mode 100644
index 000000000..e009d2301
--- /dev/null
+++ b/src/commands/Utility/afk.js
@@ -0,0 +1,79 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { createEmbed, successEmbed, errorEmbed } from '../../utils/embeds.js';
+import { logger } from '../../utils/logger.js';
+import { handleInteractionError } from '../../utils/errorHandler.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+import { getFromDb, setInDb } from '../../utils/database.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("afk")
+ .setDescription("Set your AFK status with a message")
+ .addStringOption((option) =>
+ option
+ .setName("message")
+ .setDescription("Your AFK message (optional)")
+ .setRequired(false)
+ ),
+ category: "utility",
+
+ async execute(interaction) {
+ try {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`AFK interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ commandName: 'afk'
+ });
+ return;
+ }
+
+ const message = (() => {\n // Support both slash command and prefix command\n let msg = interaction.options?.getString(\"message\");\n \n // For prefix commands, get message from reason\n if (interaction._isPrefix) {\n const args = interaction.options?.getString?.('reason');\n msg = args || \"I'm AFK right now!\";\n }\n \n return msg || \"I'm AFK right now!\";\n })();
+ const userId = interaction.user.id;
+ const guildId = interaction.guildId;
+
+ // Store AFK status in database
+ const afkKey = `afk:${guildId}:${userId}`;
+ const afkData = {
+ userId,
+ guildId,
+ message,
+ timestamp: Date.now(),
+ username: interaction.user.username
+ };
+
+ await setInDb(afkKey, afkData);
+
+ // Also update nickname if bot has permissions
+ try {
+ if (interaction.member && interaction.guild.members.me?.permissions.has('ChangeNickname')) {
+ const currentNick = interaction.member.nickname || interaction.user.username;
+ if (!currentNick.includes('[AFK]')) {
+ await interaction.member.setNickname(`[AFK] ${currentNick.substring(0, 27)}`);
+ }
+ }
+ } catch (err) {
+ logger.warn('Could not update nickname for AFK:', err.message);
+ }
+
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [
+ successEmbed(
+ '💤 AFK Status Set',
+ `Your message: **${message}**\n\nI'll let people know you're away!`
+ )
+ ]
+ });
+
+ logger.info(`User set AFK status`, {
+ userId,
+ guildId,
+ message
+ });
+ } catch (error) {
+ logger.error('AFK command error:', error);
+ await handleInteractionError(interaction, error, { subtype: 'afk_failed' });
+ }
+ }
+};
diff --git a/src/commands/Utility/editafk.js b/src/commands/Utility/editafk.js
new file mode 100644
index 000000000..0376bc4c4
--- /dev/null
+++ b/src/commands/Utility/editafk.js
@@ -0,0 +1,94 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { successEmbed, errorEmbed } from '../../utils/embeds.js';
+import { logger } from '../../utils/logger.js';
+import { handleInteractionError } from '../../utils/errorHandler.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+import { getFromDb, setInDb } from '../../utils/database.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("editafk")
+ .setDescription("Edit or remove your AFK status")
+ .addStringOption((option) =>
+ option
+ .setName("message")
+ .setDescription("New AFK message (leave empty to remove AFK)")
+ .setRequired(false)
+ ),
+ category: "utility",
+
+ async execute(interaction) {
+ try {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`EditAFK interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ commandName: 'editafk'
+ });
+ return;
+ }
+
+ const newMessage = interaction.options.getString("message");
+ const userId = interaction.user.id;
+ const guildId = interaction.guildId;
+ const afkKey = `afk:${guildId}:${userId}`;
+
+ // Check if user is AFK
+ const currentAFK = await getFromDb(afkKey);
+ if (!currentAFK) {
+ return await InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed('Not AFK', 'You are not currently AFK. Use /afk to set your status.')]
+ });
+ }
+
+ if (!newMessage) {
+ // Remove AFK status
+ await setInDb(afkKey, null);
+
+ // Remove [AFK] from nickname
+ try {
+ if (interaction.member?.nickname?.includes('[AFK]')) {
+ const newNick = interaction.member.nickname.replace('[AFK] ', '');
+ await interaction.member.setNickname(newNick);
+ }
+ } catch (err) {
+ logger.warn('Could not update nickname for AFK removal:', err.message);
+ }
+
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [successEmbed('💤 AFK Removed', 'You are no longer AFK!')]
+ });
+ } else {
+ // Update AFK message
+ const afkData = {
+ userId,
+ guildId,
+ message: newMessage,
+ timestamp: Date.now(),
+ username: interaction.user.username
+ };
+
+ await setInDb(afkKey, afkData);
+
+ await InteractionHelper.safeEditReply(interaction, {
+ embeds: [
+ successEmbed(
+ '💤 AFK Updated',
+ `New message: **${newMessage}**`
+ )
+ ]
+ });
+ }
+
+ logger.info(`User edited AFK status`, {
+ userId,
+ guildId,
+ newMessage: newMessage || 'REMOVED'
+ });
+ } catch (error) {
+ logger.error('EditAFK command error:', error);
+ await handleInteractionError(interaction, error, { subtype: 'editafk_failed' });
+ }
+ }
+};
diff --git a/src/commands/Utility/prefix.js b/src/commands/Utility/prefix.js
new file mode 100644
index 000000000..9104c4aab
--- /dev/null
+++ b/src/commands/Utility/prefix.js
@@ -0,0 +1,44 @@
+import { SlashCommandBuilder } from 'discord.js';
+import { createEmbed } from '../../utils/embeds.js';
+import { logger } from '../../utils/logger.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+import { handleInteractionError } from '../../utils/errorHandler.js';
+import { BotConfig } from '../../config/bot.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("prefix")
+ .setDescription("Get the bot prefix for this server"),
+ category: "utility",
+
+ async execute(interaction) {
+ try {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`Prefix interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId
+ });
+ return;
+ }
+
+ const prefix = BotConfig.prefix || 'nh!';
+
+ const embed = createEmbed({
+ title: "Server Prefix",
+ description: `The prefix for this server is: \`${prefix}\`\n\nExample: \`${prefix}help\``,
+ color: "blurple",
+ });
+
+ await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
+
+ logger.info(`User checked prefix`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId
+ });
+ } catch (error) {
+ logger.error("Prefix command error:", error);
+ await handleInteractionError(interaction, error, { subtype: "prefix_failed" });
+ }
+ }
+};
diff --git a/src/commands/Utility/snipe.js b/src/commands/Utility/snipe.js
new file mode 100644
index 000000000..8ec544926
--- /dev/null
+++ b/src/commands/Utility/snipe.js
@@ -0,0 +1,97 @@
+import { SlashCommandBuilder, ChannelType } from 'discord.js';
+import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js';
+import { logger } from '../../utils/logger.js';
+import { handleInteractionError } from '../../utils/errorHandler.js';
+import { InteractionHelper } from '../../utils/interactionHelper.js';
+import { getFromDb, setInDb } from '../../utils/database.js';
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("snipe")
+ .setDescription("Get the last deleted message in this channel")
+ .addIntegerOption((option) =>
+ option
+ .setName("index")
+ .setDescription("Which deleted message to retrieve (1 = most recent)")
+ .setRequired(false)
+ ),
+ category: "utility",
+
+ async execute(interaction) {
+ try {
+ const deferSuccess = await InteractionHelper.safeDefer(interaction);
+ if (!deferSuccess) {
+ logger.warn(`Snipe interaction defer failed`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ channelId: interaction.channelId
+ });
+ return;
+ }
+
+ const index = interaction.options.getInteger("index") || 1;
+ const channelId = interaction.channelId;
+ const snipeKey = `snipe:${interaction.guildId}:${channelId}`;
+
+ // Get sniped messages list
+ const snipedMessages = (await getFromDb(snipeKey, [])) || [];
+
+ if (snipedMessages.length === 0) {
+ return InteractionHelper.safeEditReply(interaction, {
+ embeds: [errorEmbed("Nothing to Snipe", "There are no deleted messages in this channel.")],
+ });
+ }
+
+ if (index < 1 || index > snipedMessages.length) {
+ return InteractionHelper.safeEditReply(interaction, {
+ embeds: [
+ errorEmbed(
+ "Invalid Index",
+ `Please provide an index between 1 and ${snipedMessages.length}.`
+ ),
+ ],
+ });
+ }
+
+ const message = snipedMessages[index - 1];
+ const timestamp = Math.floor(message.timestamp / 1000);
+
+ const embed = createEmbed({
+ title: `Sniped Message`,
+ description: message.content || "(No content)",
+ color: "blurple",
+ timestamp: new Date(message.timestamp),
+ })
+ .addFields({
+ name: "Author",
+ value: `${message.author || "Unknown"}`,
+ inline: true,
+ })
+ .addFields({
+ name: "Deleted",
+ value: ``,
+ inline: true,
+ });
+
+ if (message.attachments && message.attachments.length > 0) {
+ embed.addFields({
+ name: "Attachments",
+ value: message.attachments.join("\n"),
+ inline: false,
+ });
+ }
+
+ await InteractionHelper.safeEditReply(interaction, { embeds: [embed] });
+
+ logger.info(`User sniped message`, {
+ userId: interaction.user.id,
+ guildId: interaction.guildId,
+ channelId,
+ index,
+ });
+ } catch (error) {
+ logger.error("Snipe command error:", error);
+ await handleInteractionError(interaction, error, { subtype: "snipe_failed" });
+ }
+ }
+};
diff --git a/src/config/aliases.js b/src/config/aliases.js
new file mode 100644
index 000000000..605076760
--- /dev/null
+++ b/src/config/aliases.js
@@ -0,0 +1,10 @@
+/**
+ * @deprecated Use src/utils/commandAliases.js — kept for imports that expect this path.
+ */
+export {
+ MANUAL_ALIASES as COMMAND_MAP,
+ MANUAL_ALIASES,
+ COMMAND_ALIASES_BY_COMMAND,
+ buildCommandAliasMap,
+ registerPrefixAliases,
+} from '../utils/commandAliases.js';
diff --git a/src/config/bot.js b/src/config/bot.js
index 36e588cd4..c10133a71 100644
--- a/src/config/bot.js
+++ b/src/config/bot.js
@@ -25,13 +25,22 @@ export const botConfig = {
activities: [
{
// Text users will see (example: "Playing /help | Titan Bot").
- name: "Made with ❤️",
+ name: "https://foxname.top/",
// Activity type number (0 = Playing).
type: 0,
},
],
},
+ // Default prefix for nh!ban-style commands (per-guild override in guild config).
+ prefix: process.env.BOT_PREFIX || 'nh!',
+
+ // Public brand name (help menu, footers, presence text).
+ brand: {
+ name: 'nh_starlightsercurity',
+ tagline: 'Security & moderation bot',
+ },
+
// =========================
// COMMAND BEHAVIOR
// =========================
@@ -88,7 +97,7 @@ export const botConfig = {
embeds: {
colors: {
// Main brand colors.
- primary: "#336699",
+ primary: "#BC9EFF",
secondary: "#2F3136",
// Standard status colors for success/error/warning/info messages.
@@ -136,7 +145,7 @@ export const botConfig = {
},
footer: {
// Default footer text used in bot embeds.
- text: "Titan Bot",
+ text: "nh_starlightsercurity",
// Footer icon URL (null = no icon).
icon: null,
},
diff --git a/src/config/postgres.js b/src/config/postgres.js
index b70d1875a..2ae95a6b9 100644
--- a/src/config/postgres.js
+++ b/src/config/postgres.js
@@ -49,15 +49,15 @@ const validatedTables = Object.fromEntries(
export const pgConfig = {
- url: process.env.POSTGRES_URL || 'postgresql://localhost:5432/titanbot',
+ url: process.env.POSTGRES_URL || process.env.DATABASE_URL || 'postgresql://localhost:5432/titanbot',
options: {
- host: process.env.POSTGRES_HOST || 'localhost',
- port: parseInt(process.env.POSTGRES_PORT) || 5432,
- database: process.env.POSTGRES_DB || 'titanbot',
- user: process.env.POSTGRES_USER || 'postgres',
- password: (process.env.POSTGRES_PASSWORD || '').toString(),
+ host: process.env.POSTGRES_HOST || process.env.PGHOST || 'localhost',
+ port: parseInt(process.env.POSTGRES_PORT || process.env.PGPORT) || 5432,
+ database: process.env.POSTGRES_DB || process.env.PGDATABASE || 'titanbot',
+ user: process.env.POSTGRES_USER || process.env.PGUSER || 'postgres',
+ password: (process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || '').toString(),
ssl: false,
diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js
index 450ace66b..d3825cb9b 100644
--- a/src/events/interactionCreate.js
+++ b/src/events/interactionCreate.js
@@ -20,6 +20,15 @@ function withTraceContext(context = {}, traceContext = {}) {
};
}
+// Auto-acknowledge button/select interactions to avoid "interaction failed"
+async function autoAcknowledge(interaction) {
+ if (!interaction.deferred && !interaction.replied) {
+ if (interaction.isButton() || interaction.isStringSelectMenu()) {
+ await interaction.deferUpdate().catch(() => {});
+ }
+ }
+}
+
export default {
name: Events.InteractionCreate,
async execute(interaction, client) {
@@ -31,376 +40,52 @@ export default {
try {
InteractionHelper.patchInteractionResponses(interaction);
- if (interaction.isChatInputCommand()) {
- try {
- logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`, {
- event: 'interaction.command.received',
- traceId: interactionTraceContext.traceId,
- guildId: interaction.guildId,
- userId: interaction.user?.id,
- command: interaction.commandName
- });
-
- validateChatInputPayloadOrThrow(interaction, withTraceContext({
- type: 'command_input_validation',
- commandName: interaction.commandName
- }, interactionTraceContext));
-
- const command = client.commands.get(interaction.commandName);
-
- if (!command) {
- throw createError(
- `No command matching ${interaction.commandName} was found.`,
- ErrorTypes.CONFIGURATION,
- 'Sorry, that command does not exist.',
- withTraceContext({ commandName: interaction.commandName }, interactionTraceContext)
- );
- }
-
- const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName);
- if (!abuseProtection.allowed) {
- const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs);
- throw createError(
- `Risky command cooldown active for ${interaction.commandName}`,
- ErrorTypes.RATE_LIMIT,
- `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`,
- withTraceContext({
- commandName: interaction.commandName,
- subtype: 'command_cooldown',
- expected: true,
- cooldownMs: abuseProtection.remainingMs,
- cooldownWindowMs: abuseProtection.policy?.windowMs,
- cooldownMaxAttempts: abuseProtection.policy?.maxAttempts
- }, interactionTraceContext)
- );
- }
-
- let guildConfig = null;
- if (interaction.guild) {
- guildConfig = await getGuildConfig(client, interaction.guild.id, interactionTraceContext);
- if (guildConfig?.disabledCommands?.[interaction.commandName]) {
- throw createError(
- `Command ${interaction.commandName} is disabled in this guild`,
- ErrorTypes.CONFIGURATION,
- 'This command has been disabled for this server.',
- withTraceContext({ commandName: interaction.commandName, guildId: interaction.guild.id }, interactionTraceContext)
- );
- }
- }
+ if (interaction.isButton() || interaction.isStringSelectMenu()) {
+ await autoAcknowledge(interaction);
+ }
- await command.execute(interaction, guildConfig, client);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'command',
- commandName: interaction.commandName
- }, interactionTraceContext));
- }
- } else if (interaction.isAutocomplete()) {
- // Handle autocomplete interactions
- const focusedOption = interaction.options.getFocused(true);
-
- if (interaction.commandName === 'apply' && focusedOption.name === 'application') {
- try {
- const { getApplicationRoles } = await import('../utils/database.js');
- const roles = await getApplicationRoles(client, interaction.guildId);
- const roleName = interaction.options.getString('application', false);
-
- // Filter: only show enabled applications
- const filtered = roles.filter(role =>
- role.enabled !== false &&
- role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '')
- );
-
- await interaction.respond(
- filtered.slice(0, 25).map(role => ({
- name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`,
- value: role.name
- }))
- );
- } catch (error) {
- logger.error('Error handling autocomplete:', {
- error: error.message,
- guildId: interaction.guildId,
- commandName: interaction.commandName
- });
- await interaction.respond([]);
- }
- } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') {
- try {
- const { getApplicationRoles } = await import('../utils/database.js');
- const roles = await getApplicationRoles(client, interaction.guildId);
- const appName = interaction.options.getString('application', false);
-
- // Show all applications (enabled and disabled), but mark disabled ones
- const filtered = roles.filter(role =>
- role.name.toLowerCase().startsWith(appName?.toLowerCase() || '')
- );
-
- await interaction.respond(
- filtered.slice(0, 25).map(role => ({
- name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`,
- value: role.name
- }))
- );
- } catch (error) {
- logger.error('Error handling app-admin autocomplete:', {
- error: error.message,
- guildId: interaction.guildId,
- commandName: interaction.commandName
- });
- await interaction.respond([]);
- }
- } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') {
- try {
- const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js');
- const guildId = interaction.guildId;
- const guild = interaction.guild;
-
- let panels = await getAllReactionRoleMessages(client, guildId);
-
- if (!panels || panels.length === 0) {
- await interaction.respond([]);
- return;
- }
-
- // Filter out panels whose messages no longer exist
- const validPanels = [];
- for (const panel of panels) {
- if (!panel.messageId || !panel.channelId) {
- continue;
- }
-
- const channel = guild.channels.cache.get(panel.channelId);
- if (!channel) {
- await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {});
- continue;
- }
-
- const msg = await channel.messages.fetch(panel.messageId).catch(() => null);
- if (!msg) {
- await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {});
- continue;
- }
- validPanels.push(panel);
- }
-
- if (validPanels.length === 0) {
- await interaction.respond([]);
- return;
- }
-
- const choices = await Promise.all(
- validPanels.slice(0, 25).map(async panel => {
- try {
- const channel = guild.channels.cache.get(panel.channelId);
- if (!channel) return null;
-
- const msg = await channel.messages.fetch(panel.messageId).catch(() => null);
- if (!msg) return null;
-
- const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel';
- const channelName = channel?.name ?? 'unknown';
-
- return {
- name: `${title} (${channelName})`.substring(0, 100),
- value: panel.messageId
- };
- } catch (e) {
- return null;
- }
- })
- );
-
- const validChoices = choices.filter(c => c !== null);
- await interaction.respond(validChoices);
- } catch (error) {
- logger.error('Error handling reactroles autocomplete:', {
- error: error.message,
- guildId: interaction.guildId,
- commandName: interaction.commandName
- });
- await interaction.respond([]);
- }
+ if (interaction.isChatInputCommand()) {
+ const command = client.commands.get(interaction.commandName);
+ if (!command) {
+ logger.warn(`Unknown command: ${interaction.commandName}`);
+ return;
}
- } else if (interaction.isButton()) {
- if (interaction.customId.startsWith('shared_todo_')) {
- const parts = interaction.customId.split('_');
- const buttonType = parts.slice(0, 3).join('_');
- const listId = parts[3];
- const button = client.buttons.get(buttonType);
- if (button) {
- try {
- await button.execute(interaction, client, [listId]);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'button',
- customId: interaction.customId,
- handler: 'todo'
- }, interactionTraceContext));
- }
- } else {
- throw createError(
- `No button handler found for ${buttonType}`,
- ErrorTypes.CONFIGURATION,
- 'This button is not available.',
- withTraceContext({ buttonType }, interactionTraceContext)
- );
- }
+ const abuse = await enforceAbuseProtection(interaction, command, interaction.commandName);
+ if (!abuse.allowed) {
+ await InteractionHelper.safeReply(interaction, {
+ content: `⏱️ Slow down! Try again in ${formatCooldownDuration(abuse.remainingMs)}.`,
+ flags: MessageFlags.Ephemeral,
+ });
return;
}
+ await command.execute(interaction, await getGuildConfig(client, interaction.guildId), client);
+ }
+ else if (interaction.isButton()) {
+ logger.debug(`Button pressed: ${interaction.customId}`);
const [customId, ...args] = interaction.customId.split(':');
+
const button = client.buttons.get(customId);
-
- if (!button) {
- if (!interaction.customId.includes(':')) {
- return;
- }
-
- throw createError(
- `No button handler found for ${customId}`,
- ErrorTypes.CONFIGURATION,
- 'This button is not available.',
- withTraceContext({ customId }, interactionTraceContext)
- );
- }
-
- try {
- await button.execute(interaction, client, args);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'button',
- customId: interaction.customId,
- handler: 'general'
- }, interactionTraceContext));
- }
- } else if (interaction.isStringSelectMenu()) {
+
+ if (button) await button.execute(interaction, client, args);
+ else logger.warn(`No button handler found for: ${customId}`);
+ }
+ else if (interaction.isStringSelectMenu()) {
+ logger.debug(`Select menu: ${interaction.customId}`);
const [customId, ...args] = interaction.customId.split(':');
+
const selectMenu = client.selectMenus.get(customId);
-
- if (!selectMenu) {
- if (!interaction.customId.includes(':')) {
- // No registered handler and no ':' delimiter — this is an inline-collected
- // select menu (e.g. ticket_config_, jointocreate_config_).
- // Return silently so the existing MessageComponentCollector handles it.
- return;
- }
-
- throw createError(
- `No select menu handler found for ${customId}`,
- ErrorTypes.CONFIGURATION,
- 'This select menu is not available.',
- withTraceContext({ customId }, interactionTraceContext)
- );
- }
-
- try {
- await selectMenu.execute(interaction, client, args);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'select_menu',
- customId: interaction.customId
- }, interactionTraceContext));
- }
- } else if (interaction.isModalSubmit()) {
- if (interaction.customId.startsWith('app_modal_')) {
- try {
- await handleApplicationModal(interaction);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'modal',
- customId: interaction.customId,
- handler: 'application'
- }, interactionTraceContext));
- }
- return;
- }
-
- if (interaction.customId.startsWith('app_review_')) {
- try {
- await handleApplicationReviewModal(interaction);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'modal',
- customId: interaction.customId,
- handler: 'application_review'
- }, interactionTraceContext));
- }
- return;
- }
-
- if (interaction.customId.startsWith('jtc_')) {
- logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`, {
- event: 'interaction.modal.inline_skipped',
- traceId: interactionTraceContext.traceId
- });
- return;
- }
-
- const [customId, ...args] = interaction.customId.split(':');
- const modal = client.modals.get(customId);
-
- if (!modal) {
- if (!interaction.customId.includes(':')) {
- // No registered handler and no ':' delimiter — this is an inline-awaited
- // modal (e.g. via awaitModalSubmit). Return silently so the caller handles it.
- return;
- }
-
- throw createError(
- `No modal handler found for ${customId}`,
- ErrorTypes.CONFIGURATION,
- 'This form is not available.',
- withTraceContext({ customId }, interactionTraceContext)
- );
- }
-
- try {
- await modal.execute(interaction, client, args);
- } catch (error) {
- await handleInteractionError(interaction, error, withTraceContext({
- type: 'modal',
- customId: interaction.customId,
- handler: 'general'
- }, interactionTraceContext));
- }
+ if (selectMenu) await selectMenu.execute(interaction, client, args);
+ else logger.warn(`No select menu handler found for: ${customId}`);
}
- } catch (error) {
- logger.error('Unhandled error in interactionCreate:', {
- event: 'interaction.unhandled_error',
- errorCode: 'INTERACTION_UNHANDLED_ERROR',
- error,
- traceId: interactionTraceContext.traceId,
- interactionId: interaction.id,
- guildId: interaction.guildId,
- userId: interaction.user?.id
- });
-
- try {
- const ephemeralErrorMessage = {
- embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')],
- flags: MessageFlags.Ephemeral
- };
- const editErrorMessage = {
- embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')]
- };
-
- if (interaction.deferred) {
- await interaction.editReply(editErrorMessage);
- } else if (interaction.replied) {
- await interaction.followUp(ephemeralErrorMessage);
- } else {
- await interaction.reply(ephemeralErrorMessage);
- }
- } catch (replyError) {
- logger.error('Failed to send fallback error response:', {
- event: 'interaction.error_response_failed',
- errorCode: 'INTERACTION_ERROR_RESPONSE_FAILED',
- error: replyError,
- traceId: interactionTraceContext.traceId
- });
+ else if (interaction.isModalSubmit()) {
+ const [customId, ...args] = interaction.customId.split(':');
+ const modal = client.modals.get(customId);
+ if (modal) await modal.execute(interaction, client, args);
}
+ } catch (error) {
+ await handleInteractionError(interaction, error, withTraceContext({type: 'general'}, interactionTraceContext));
}
});
}
diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js
index b7f9841dc..5842e385b 100644
--- a/src/events/messageCreate.js
+++ b/src/events/messageCreate.js
@@ -1,117 +1,56 @@
-
-
-
-
-
import { Events } from 'discord.js';
import { logger } from '../utils/logger.js';
-import { getLevelingConfig, getUserLevelData } from '../services/leveling.js';
-import { addXp } from '../services/xpSystem.js';
-import { checkRateLimit } from '../utils/rateLimiter.js';
+import { getGuildConfig } from '../services/guildConfig.js';
+import { BotConfig } from '../config/bot.js';
+import { createPrefixInteraction, parsePrefixContent } from '../utils/prefixCommandAdapter.js';
+import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js';
+import { InteractionHelper } from '../utils/interactionHelper.js';
+import { MessageFlags } from 'discord.js';
-const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12;
-const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000;
+const DEFAULT_PREFIX = BotConfig.prefix || 'nh!';
export default {
- name: Events.MessageCreate,
- async execute(message, client) {
- try {
-
- if (message.author.bot || !message.guild) return;
-
- await handleLeveling(message, client);
- } catch (error) {
- logger.error('Error in messageCreate event:', error);
- }
- }
+ name: Events.MessageCreate,
+ async execute(message, client) {
+ if (message.author.bot || !message.guild) return;
+
+ const guildConfig = await getGuildConfig(client, message.guild.id);
+ const prefix = guildConfig.prefix || DEFAULT_PREFIX;
+
+ const parsed = parsePrefixContent(message.content, prefix);
+ if (!parsed) return;
+
+ const command = client.commands.get(parsed.commandName);
+ if (!command) return;
+
+ const resolvedName = command.data?.name ?? parsed.commandName;
+
+ const fakeInteraction = createPrefixInteraction(
+ message,
+ client,
+ command,
+ resolvedName,
+ parsed.args,
+ );
+
+ const abuse = await enforceAbuseProtection(fakeInteraction, command, resolvedName);
+ if (!abuse.allowed) {
+ await InteractionHelper.safeReply(fakeInteraction, {
+ content: `⏱️ Slow down! Try again in ${formatCooldownDuration(abuse.remainingMs)}.`,
+ flags: MessageFlags.Ephemeral,
+ });
+ return;
+ }
+
+ try {
+ await command.execute(fakeInteraction, guildConfig, client);
+ } catch (error) {
+ logger.error(`Prefix command "${resolvedName}" failed:`, error);
+ if (!fakeInteraction.replied) {
+ await message
+ .reply('❌ Command failed. Try the slash version (/) for full features (menus, modals, etc.).')
+ .catch(() => {});
+ }
+ }
+ },
};
-
-
-
-
-
-
-
-
-async function handleLeveling(message, client) {
- try {
- const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`;
- const canProcess = await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS);
- if (!canProcess) {
- return;
- }
-
- const levelingConfig = await getLevelingConfig(client, message.guild.id);
-
- if (!levelingConfig?.enabled) {
- return;
- }
-
-
- if (levelingConfig.ignoredChannels?.includes(message.channel.id)) {
- return;
- }
-
-
- if (levelingConfig.ignoredRoles?.length > 0) {
- const member = await message.guild.members.fetch(message.author.id).catch(() => {
- return null;
- });
- if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) {
- return;
- }
- }
-
-
- if (levelingConfig.blacklistedUsers?.includes(message.author.id)) {
- return;
- }
-
-
- if (!message.content || message.content.trim().length === 0) {
- return;
- }
-
- const userData = await getUserLevelData(client, message.guild.id, message.author.id);
-
-
- const cooldownTime = levelingConfig.xpCooldown || 60;
- const now = Date.now();
- const timeSinceLastMessage = now - (userData.lastMessage || 0);
-
-
- if (timeSinceLastMessage < cooldownTime * 1000) {
- return;
- }
-
-
- const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15;
- const maxXP = levelingConfig.xpRange?.max || levelingConfig.xpPerMessage?.max || 25;
-
-
- const safeMinXP = Math.max(1, minXP);
- const safeMaxXP = Math.max(safeMinXP, maxXP);
-
-
- const xpToGive = Math.floor(Math.random() * (safeMaxXP - safeMinXP + 1)) + safeMinXP;
-
-
- let finalXP = xpToGive;
- if (levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1) {
- finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier);
- }
-
-
- const result = await addXp(client, message.guild, message.member, finalXP);
-
- if (result.success && result.leveledUp) {
- logger.info(
- `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}`
- );
- }
- } catch (error) {
- logger.error('Error handling leveling for message:', error);
- }
-}
-
-
diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js
index cc737ba06..48582fcb9 100644
--- a/src/events/messageDelete.js
+++ b/src/events/messageDelete.js
@@ -2,8 +2,10 @@ import { Events } from 'discord.js';
import { logEvent, EVENT_TYPES } from '../services/loggingService.js';
import { logger } from '../utils/logger.js';
import { getReactionRoleMessage, deleteReactionRoleMessage } from '../services/reactionRoleService.js';
+import { getFromDb, setInDb } from '../utils/database.js';
const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024;
+const MAX_SNIPED_MESSAGES = 10; // Keep last 10 deleted messages
export default {
name: Events.MessageDelete,
@@ -13,6 +15,32 @@ export default {
try {
if (!message.guild) return;
+ // Store for snipe command
+ try {
+ const snipeKey = `snipe:${message.guild.id}:${message.channelId}`;
+ const snipedMessages = (await getFromDb(snipeKey, [])) || [];
+
+ if (Array.isArray(snipedMessages)) {
+ // Add new message to the beginning (most recent)
+ snipedMessages.unshift({
+ id: message.id,
+ author: message.author?.tag || "Unknown",
+ content: message.content || "(No content)",
+ timestamp: message.createdTimestamp,
+ attachments: message.attachments.map(a => a.url)
+ });
+
+ // Keep only last MAX_SNIPED_MESSAGES
+ if (snipedMessages.length > MAX_SNIPED_MESSAGES) {
+ snipedMessages.pop();
+ }
+
+ await setInDb(snipeKey, snipedMessages);
+ }
+ } catch (snipeErr) {
+ logger.warn('Failed to store snipe message:', snipeErr);
+ }
+
try {
const reactionRoleData = await getReactionRoleMessage(message.client, message.guild.id, message.id);
if (reactionRoleData) {
diff --git a/src/handlers/commandLoader.js b/src/handlers/commandLoader.js
index 5273f2934..194abebc6 100644
--- a/src/handlers/commandLoader.js
+++ b/src/handlers/commandLoader.js
@@ -3,6 +3,7 @@ import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { Collection } from 'discord.js';
import { logger } from '../utils/logger.js';
+import { registerPrefixAliases } from '../utils/commandAliases.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -129,6 +130,11 @@ export async function loadCommands(client) {
}
}
+ const aliasCount = registerPrefixAliases(client.commands);
+ if (aliasCount > 0) {
+ logger.info(`Registered ${aliasCount} prefix command aliases`);
+ }
+
logger.info(`Loaded ${uniqueCommands.size} commands`);
return client.commands;
}
diff --git a/src/handlers/countdownButtons.js b/src/handlers/countdownButtons.js
index 2b5e916dd..c9c0a47df 100644
--- a/src/handlers/countdownButtons.js
+++ b/src/handlers/countdownButtons.js
@@ -1,4 +1,4 @@
-import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
+import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js';
import { successEmbed, errorEmbed } from '../utils/embeds.js';
import { logger } from '../utils/logger.js';
@@ -112,7 +112,7 @@ async function countdownButtonHandler(interaction, client, args) {
});
}
- if (!interaction.member.permissions.has("MANAGE_MESSAGES")) {
+ if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) {
return await interaction.reply({
content: 'You need the "Manage Messages" permission to control countdowns.',
flags: ["Ephemeral"],
diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js
index 0c989452a..c423fdeba 100644
--- a/src/handlers/helpButtons.js
+++ b/src/handlers/helpButtons.js
@@ -1,150 +1,2 @@
-import { createEmbed } from '../utils/embeds.js';
-import { createAllCommandsMenu } from './helpSelectMenus.js';
-import { createInitialHelpMenu } from '../commands/Core/help.js';
-import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js';
-import { logger } from '../utils/logger.js';
-
-const COMMAND_LIST_ID = "help-command-list";
-const BACK_BUTTON_ID = "help-back-to-main";
-const PAGINATION_PREFIX = "help-page";
-const BUG_REPORT_BUTTON_ID = "help-bug-report";
-
-export const helpBackButton = {
- name: BACK_BUTTON_ID,
- async execute(interaction, client) {
- try {
- if (!interaction.deferred && !interaction.replied) {
- await interaction.deferUpdate();
- }
-
- const { embeds, components } = await createInitialHelpMenu(client);
- await interaction.editReply({
- embeds,
- components,
- });
- } catch (error) {
- if (error?.code === 40060 || error?.code === 10062) {
- logger.warn('Help back button interaction already acknowledged or expired.', {
- event: 'interaction.help.button.unavailable',
- errorCode: String(error.code),
- customId: interaction.customId,
- interactionId: interaction.id,
- });
- return;
- }
-
- throw error;
- }
- },
-};
-
-export const helpBugReportButton = {
- name: BUG_REPORT_BUTTON_ID,
- async execute(interaction, client) {
- const githubButton = new ButtonBuilder()
- .setLabel('🐛 Report Bug on GitHub')
- .setStyle(ButtonStyle.Link)
- .setURL('https://github.com/codebymitch/TitanBot/issues');
-
- const bugRow = new ActionRowBuilder().addComponents(githubButton);
-
- const bugReportEmbed = createEmbed({
- title: '🐛 Bug Report',
- description: 'Found a bug? Please report it on our GitHub Issues page!\n\n' +
- '**When reporting a bug, please include:**\n' +
- '• 📝 Detailed description of the issue\n' +
- '• 📋 Steps to reproduce the problem\n' +
- '• 📸 Screenshots if applicable\n' +
- '• 💻 Your bot version and environment\n\n' +
- 'This helps us fix issues faster and more effectively!',
- color: 'error'
- });
- bugReportEmbed.setFooter({
- text: 'TitanBot Bug Reporting System',
- iconURL: client.user.displayAvatarURL()
- });
- bugReportEmbed.setTimestamp();
-
- await interaction.reply({
- embeds: [bugReportEmbed],
- components: [bugRow],
- flags: MessageFlags.Ephemeral
- });
- },
-};
-
-export const helpReportCommand = {
- name: COMMAND_LIST_ID,
- categoryName: null,
- async execute(interaction, client) {
-
- }
-};
-
-function getPaginationInfo(components) {
- for (const row of components || []) {
- for (const component of row.components || []) {
- if (component.customId === `${PAGINATION_PREFIX}_page`) {
- const label = component.label || '';
- const match = label.match(/Page\s+(\d+)\s+of\s+(\d+)/i);
- if (match) {
- return {
- currentPage: Number(match[1]),
- totalPages: Number(match[2]),
- };
- }
- }
- }
- }
-
- return { currentPage: 1, totalPages: 1 };
-}
-
-export const helpPaginationButton = {
- name: `${PAGINATION_PREFIX}_next`,
- async execute(interaction, client) {
- try {
- if (!interaction.deferred && !interaction.replied) {
- await interaction.deferUpdate();
- }
-
- const { currentPage, totalPages } = getPaginationInfo(interaction.message?.components);
-
- let nextPage = currentPage;
- switch (interaction.customId) {
- case `${PAGINATION_PREFIX}_first`:
- nextPage = 1;
- break;
- case `${PAGINATION_PREFIX}_prev`:
- nextPage = Math.max(1, currentPage - 1);
- break;
- case `${PAGINATION_PREFIX}_next`:
- nextPage = Math.min(totalPages, currentPage + 1);
- break;
- case `${PAGINATION_PREFIX}_last`:
- nextPage = totalPages;
- break;
- default:
- nextPage = currentPage;
- break;
- }
-
- const { embeds, components } = await createAllCommandsMenu(nextPage, client);
- await interaction.editReply({ embeds, components });
- } catch (error) {
- if (error?.code === 40060 || error?.code === 10062) {
- logger.warn('Help pagination interaction already acknowledged or expired.', {
- event: 'interaction.help.pagination.unavailable',
- errorCode: String(error.code),
- customId: interaction.customId,
- interactionId: interaction.id,
- });
- return;
- }
-
- throw error;
- }
- },
-};
-
-
+/** @deprecated Help buttons live in src/interactions/buttons/help.js */
+export {};
diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js
index b71a26b3d..b06b6f784 100644
--- a/src/handlers/helpSelectMenus.js
+++ b/src/handlers/helpSelectMenus.js
@@ -1,370 +1,14 @@
-import { createEmbed } from '../utils/embeds.js';
-import { createButton, getPaginationRow } from '../utils/components.js';
-import fs from 'fs/promises';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js';
+import {
+ getCategoryEmbedAndPageCount,
+ getAllCommandsEmbedAndPageCount,
+ buildHelpViewComponents,
+ ALL_COMMANDS_ID,
+} from '../utils/helpMenuHelper.js';
import { logger } from '../utils/logger.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const BACK_BUTTON_ID = "help-back-to-main";
-const ALL_COMMANDS_ID = "help-all-commands";
-const PAGINATION_PREFIX = "help-page";
-const CATEGORY_SELECT_ID = "help-category-select";
-const FOOTER_TEXT = "Made with ❤️";
-const SUBCOMMAND_TYPE = 1;
-const SUBCOMMAND_GROUP_TYPE = 2;
-
-const CATEGORY_ICONS = {
- Core: "ℹ️",
- Moderation: "🛡️",
- Economy: "💰",
- Fun: "🎮",
- Leveling: "📊",
- Utility: "🔧",
- Ticket: "🎫",
- Welcome: "👋",
- Giveaway: "🎉",
- Counter: "🔢",
- Tools: "🛠️",
- Search: "🔍",
- Reaction_Roles: "🎭",
- Community: "👥",
- Birthday: "🎂",
- Config: "⚙️",
-};
-
-function buildHelpEntries(command, category) {
- const commandData = normalizeCommandData(command);
- if (!commandData?.name) {
- return [];
- }
-
- const baseName = commandData.name;
- const baseDescription = commandData.description || "No description";
- const options = commandData.options || [];
-
- const entries = [];
-
- for (const option of options) {
- if (!option) continue;
-
- if (option.type === SUBCOMMAND_TYPE) {
- entries.push({
- baseName,
- displayName: `${baseName} ${option.name}`,
- description: option.description || baseDescription,
- category,
- });
- continue;
- }
-
- if (option.type === SUBCOMMAND_GROUP_TYPE) {
- const nestedOptions = option.options || [];
- for (const nested of nestedOptions) {
- if (nested?.type !== SUBCOMMAND_TYPE) continue;
-
- entries.push({
- baseName,
- displayName: `${baseName} ${option.name} ${nested.name}`,
- description: nested.description || option.description || baseDescription,
- category,
- });
- }
- }
- }
-
- if (entries.length === 0) {
- entries.push({
- baseName,
- displayName: baseName,
- description: baseDescription,
- category,
- });
- }
-
- return entries;
-}
-
-function normalizeCommandData(command) {
- const rawData = command?.data;
- if (!rawData) {
- return null;
- }
-
- const jsonData = typeof rawData.toJSON === 'function' ? rawData.toJSON() : rawData;
- if (!jsonData?.name) {
- return null;
- }
-
- return {
- ...jsonData,
- options: Array.isArray(jsonData.options)
- ? jsonData.options.map((option) =>
- typeof option?.toJSON === 'function' ? option.toJSON() : option,
- )
- : [],
- };
-}
-
-async function createCategoryCommandsMenu(category, client) {
- const categoryName =
- category.charAt(0).toUpperCase() + category.slice(1).toLowerCase();
- const icon = CATEGORY_ICONS[categoryName] || "🔍";
-
- const categoryCommands = [];
-
- try {
- const categoryPath = path.join(__dirname, "../commands", category);
- const commandFiles = (await fs.readdir(categoryPath))
- .filter((file) => file.endsWith(".js"))
- .sort();
-
- for (const file of commandFiles) {
- const filePath = path.join(categoryPath, file);
- const commandModule = await import(`file://${filePath}`);
- const command = commandModule.default;
- const commandData = normalizeCommandData(command);
-
- if (commandData) {
- if (
- commandData.name === "help" ||
- commandData.name === "commandlist"
- )
- continue;
-
- categoryCommands.push(...buildHelpEntries(command, categoryName));
- }
- }
- } catch (error) {
- logger.error(
- `Error reading commands from category ${category}:`,
- error,
- );
- }
-
- categoryCommands.sort((a, b) => a.displayName.localeCompare(b.displayName));
-
- let registeredCommands = new Collection();
- try {
- if (client?.application?.commands?.fetch) {
- const commands = await client.application.commands.fetch();
- for (const cmd of commands.values()) {
- registeredCommands.set(cmd.name, cmd);
- }
- }
- } catch (error) {
- logger.error('Error fetching registered commands:', error);
- }
-
- const embed = createEmbed({
- title: `${icon} ${categoryName} Commands`,
- description: categoryCommands.length > 0
- ? `Click any command mention below to use it:`
- : `No commands found in the **${categoryName}** category.`
- });
-
- if (categoryCommands.length > 0) {
- const commandMentions = categoryCommands
- .map((cmd) => {
- const registeredCmd = registeredCommands.get(cmd.baseName);
- if (registeredCmd && registeredCmd.id) {
- return `${cmd.displayName}:${registeredCmd.id}> · ${cmd.description}`;
- }
- return `\`/${cmd.displayName}\` · ${cmd.description}`;
- })
- .join("\n");
-
- const maxLength = 1000;
- if (commandMentions.length <= maxLength) {
- embed.addFields({
- name: "Commands",
- value: commandMentions,
- inline: false,
- });
- } else {
- const chunks = [];
- let currentChunk = "";
- const lines = commandMentions.split("\n");
-
- for (const line of lines) {
- if ((currentChunk + "\n" + line).length > maxLength) {
- if (currentChunk) chunks.push(currentChunk);
- currentChunk = line;
- } else {
- currentChunk += (currentChunk ? "\n" : "") + line;
- }
- }
- if (currentChunk) chunks.push(currentChunk);
-
- chunks.forEach((chunk, index) => {
- embed.addFields({
- name: `Commands (Part ${index + 1})`,
- value: chunk,
- inline: false,
- });
- });
- }
- }
-
- embed.setFooter({ text: FOOTER_TEXT });
- embed.setTimestamp();
-
- const backButton = createButton(
- BACK_BUTTON_ID,
- "Back",
- "primary",
- "⬅️",
- false,
- );
-
- const buttonRow = new ActionRowBuilder().addComponents(backButton);
-
- return {
- embeds: [embed],
- components: [buttonRow],
- };
-}
-
-export async function createAllCommandsMenu(page = 1, client) {
- const commandsPerPage = 45;
- const allCommands = [];
-
- const commandsPath = path.join(__dirname, "../commands");
- const categoryDirs = (
- await fs.readdir(commandsPath, { withFileTypes: true })
- )
- .filter((dirent) => dirent.isDirectory())
- .map((dirent) => dirent.name)
- .sort();
-
- for (const category of categoryDirs) {
- try {
- const categoryPath = path.join(
- __dirname,
- "../commands",
- category,
- );
- const commandFiles = (await fs.readdir(categoryPath))
- .filter((file) => file.endsWith(".js"))
- .sort();
-
- for (const file of commandFiles) {
- const filePath = path.join(categoryPath, file);
- const commandModule = await import(`file://${filePath}`);
- const command = commandModule.default;
- const commandData = normalizeCommandData(command);
-
- if (commandData) {
- if (
- commandData.name === "help" ||
- commandData.name === "commandlist"
- )
- continue;
-
- const categoryName =
- category.charAt(0).toUpperCase() +
- category.slice(1).toLowerCase();
-
- allCommands.push(...buildHelpEntries(command, categoryName));
- }
- }
- } catch (error) {
- logger.error(
- `Error reading commands from category ${category}:`,
- error,
- );
- }
- }
-
- allCommands.sort((a, b) => a.displayName.localeCompare(b.displayName));
-
- let registeredCommands = new Collection();
- try {
- if (client?.application?.commands?.fetch) {
- const commands = await client.application.commands.fetch();
- for (const cmd of commands.values()) {
- registeredCommands.set(cmd.name, cmd);
- }
- }
- } catch (error) {
- logger.error('Error fetching registered commands:', error);
- }
-
- const totalPages = Math.ceil(allCommands.length / commandsPerPage);
- const startIndex = (page - 1) * commandsPerPage;
- const endIndex = startIndex + commandsPerPage;
- const pageCommands = allCommands.slice(startIndex, endIndex);
-
- const embed = createEmbed({
- title: "📋 All Commands",
- description: `(${allCommands.length} total commands, including subcommands)`
- });
-
- embed.setFooter({ text: FOOTER_TEXT });
- embed.setTimestamp();
-
- if (pageCommands.length > 0) {
- const commandMentions = pageCommands.map((cmd) => {
- const registeredCmd = registeredCommands.get(cmd.baseName);
- if (registeredCmd && registeredCmd.id) {
- return `${cmd.displayName}:${registeredCmd.id}> · ${cmd.category}`;
- }
- return `\`/${cmd.displayName}\` · ${cmd.category}`;
- });
-
- const columnCount = pageCommands.length > 20 ? 3 : (pageCommands.length > 10 ? 2 : 1);
- const chunkSize = Math.ceil(commandMentions.length / columnCount);
-
- for (let i = 0; i < columnCount; i++) {
- const chunk = commandMentions
- .slice(i * chunkSize, (i + 1) * chunkSize)
- .join("\n");
-
- if (!chunk) continue;
-
- embed.addFields({
- name: i === 0 ? `Commands (Page ${page})` : "Commands (cont.)",
- value: chunk,
- inline: columnCount > 1,
- });
- }
- }
-
- const components = [];
-
- if (totalPages > 1) {
- const paginationRow = getPaginationRow(
- PAGINATION_PREFIX,
- page,
- totalPages,
- );
- components.push(paginationRow);
- }
-
- const backButton = createButton(
- BACK_BUTTON_ID,
- "Back",
- "primary",
- "⬅️",
- false,
- );
-
- const buttonRow = new ActionRowBuilder().addComponents(backButton);
- components.push(buttonRow);
-
- return {
- embeds: [embed],
- components,
- currentPage: page,
- totalPages,
- };
-}
+import { MessageFlags } from 'discord.js';
export const helpCategorySelectMenu = {
- name: CATEGORY_SELECT_ID,
+ name: 'help-category-select',
async execute(interaction, client) {
try {
if (!interaction.deferred && !interaction.replied) {
@@ -372,42 +16,34 @@ export const helpCategorySelectMenu = {
}
const selectedCategory = interaction.values[0];
+ let result;
if (selectedCategory === ALL_COMMANDS_ID) {
- const { embeds, components } = await createAllCommandsMenu(1, client);
- await interaction.editReply({
- embeds,
- components,
- });
+ result = await getAllCommandsEmbedAndPageCount(1, client);
} else {
- const { embeds, components } = await createCategoryCommandsMenu(selectedCategory, client);
- await interaction.editReply({
- embeds,
- components,
- });
+ result = await getCategoryEmbedAndPageCount(selectedCategory, 1, client);
}
+
+ const { embed, totalPages } = result;
+ const components = await buildHelpViewComponents(client, 1, totalPages, selectedCategory);
+
+ await interaction.editReply({
+ embeds: [embed],
+ components,
+ });
} catch (error) {
+ logger.error('Error in help category select menu handler:', error);
+
if (error?.code === 40060 || error?.code === 10062) {
- logger.warn('Help category select interaction already acknowledged or expired.', {
- event: 'interaction.help.select.unavailable',
- errorCode: String(error.code),
- customId: interaction.customId,
- interactionId: interaction.id,
- });
return;
}
- logger.error('Error in help category select menu handler:', error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
- content: 'An error occurred while loading help categories.',
+ content: '❌ An error occurred while loading help categories.',
flags: MessageFlags.Ephemeral,
});
}
}
},
};
-
-
-
-
diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help.js
index 9e7a01126..b0388057e 100644
--- a/src/interactions/buttons/help.js
+++ b/src/interactions/buttons/help.js
@@ -1,19 +1,35 @@
import {
- helpBackButton,
- helpBugReportButton,
- helpPaginationButton,
-} from '../../handlers/helpButtons.js';
+ getCategoryEmbedAndPageCount,
+ getAllCommandsEmbedAndPageCount,
+ buildHelpViewComponents,
+} from '../../utils/helpMenuHelper.js';
+import { logger } from '../../utils/logger.js';
-const paginationIds = [
- 'help-page_first',
- 'help-page_prev',
- 'help-page_next',
- 'help-page_last',
-];
+export default {
+ name: 'help',
+ async execute(interaction, client, args) {
+ try {
+ // customId: help:back|next::
+ const targetPage = parseInt(args[1], 10) || 1;
+ const category = args[2] || 'help-all-commands';
-const paginationInteractions = paginationIds.map((name) => ({
- name,
- execute: helpPaginationButton.execute,
-}));
+ let result;
+ if (category === 'help-all-commands') {
+ result = await getAllCommandsEmbedAndPageCount(targetPage, client);
+ } else {
+ result = await getCategoryEmbedAndPageCount(category, targetPage, client);
+ }
-export default [helpBackButton, helpBugReportButton, ...paginationInteractions];
\ No newline at end of file
+ const { embed, totalPages, currentPage } = result;
+ const components = await buildHelpViewComponents(client, currentPage, totalPages, category);
+
+ await interaction.editReply({
+ embeds: [embed],
+ components,
+ });
+ } catch (error) {
+ logger.error('Error in help button handler:', error);
+ await interaction.editReply({ content: '❌ An error occurred while updating the help menu.' });
+ }
+ }
+};
diff --git a/src/interactions/selectMenus/helpCategory.js b/src/interactions/selectMenus/helpCategory.js
index ce224ea56..2419d1803 100644
--- a/src/interactions/selectMenus/helpCategory.js
+++ b/src/interactions/selectMenus/helpCategory.js
@@ -1,3 +1 @@
-import { helpCategorySelectMenu } from '../../handlers/helpSelectMenus.js';
-
-export default helpCategorySelectMenu;
\ No newline at end of file
+export { helpCategorySelectMenu as default } from '../../handlers/helpSelectMenus.js';
diff --git a/src/services/economy.js b/src/services/economy.js
deleted file mode 100644
index da439a355..000000000
--- a/src/services/economy.js
+++ /dev/null
@@ -1,306 +0,0 @@
-import { BotConfig } from "../config/bot.js";
-import { getEconomyKey } from '../utils/database.js';
-import { getFromDb, setInDb } from '../utils/database.js';
-import { logger } from '../utils/logger.js';
-import { normalizeEconomyData } from '../utils/schemas.js';
-
-const BASE_BANK_CAPACITY = BotConfig.economy.baseBankCapacity;
-const BANK_CAPACITY_PER_LEVEL = BotConfig.economy.bankCapacityPerLevel || 5000;
-
-const DEFAULT_ECONOMY_DATA = {
- wallet: 0,
- bank: 0,
- bankLevel: 0,
- dailyStreak: 0,
- lastDaily: 0,
- lastWork: 0,
- lastCrime: 0,
- lastRob: 0,
- inventory: {},
- cooldowns: {}
-};
-
-
-
-
-
-
-export function getMaxBankCapacity(userData) {
- if (!userData) return BASE_BANK_CAPACITY;
-
- const bankLevel = userData.bankLevel || 0;
- return BASE_BANK_CAPACITY + (bankLevel * BANK_CAPACITY_PER_LEVEL);
-}
-
-
-
-
-
-
-
-
-export async function getEconomyData(client, guildId, userId) {
- try {
- const key = getEconomyKey(guildId, userId);
-
- const data = await getFromDb(key, DEFAULT_ECONOMY_DATA);
-
- return normalizeEconomyData(data, DEFAULT_ECONOMY_DATA);
- } catch (error) {
- logger.error(`Error getting economy data for user ${userId} in guild ${guildId}:`, error);
-
- if (error.message && (error.message.includes('ECONNREFUSED') || error.message.includes('connection'))) {
- if (client._economyFallback) {
- const key = getEconomyKey(guildId, userId);
- const cached = client._economyFallback.get(key);
- return normalizeEconomyData(cached, DEFAULT_ECONOMY_DATA);
- }
- }
-
- return normalizeEconomyData({}, DEFAULT_ECONOMY_DATA);
- }
-}
-
-
-
-
-
-
-
-
-
-export async function setEconomyData(client, guildId, userId, newData) {
- let mergedData = null;
- try {
- const key = getEconomyKey(guildId, userId);
-
- const existingData = await getEconomyData(client, guildId, userId);
- mergedData = normalizeEconomyData(
- { ...existingData, ...newData },
- DEFAULT_ECONOMY_DATA
- );
-
- await setInDb(key, mergedData);
- return true;
- } catch (error) {
- logger.error('Error saving economy data:', error);
-
- if (error.message && (error.message.includes('ECONNREFUSED') || error.message.includes('connection'))) {
- logger.warn(`PostgreSQL unavailable, using memory fallback for guild ${guildId}`);
-
- if (!client._economyFallback) {
- client._economyFallback = new Map();
- }
- client._economyFallback.set(key, mergedData);
- return true;
- }
-
- return false;
- }
-}
-
-
-
-
-
-
-
-
-
-
-export async function addMoney(client, guildId, userId, amount, type = 'wallet') {
- try {
- if (amount <= 0) {
- return { success: false, error: 'Amount must be positive' };
- }
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (type === 'bank') {
- const maxBank = getMaxBankCapacity(userData);
- if ((userData.bank || 0) + amount > maxBank) {
- return {
- success: false,
- error: 'Bank capacity exceeded',
- current: userData.bank || 0,
- max: maxBank
- };
- }
- userData.bank = (userData.bank || 0) + amount;
- } else {
- userData.wallet = (userData.wallet || 0) + amount;
- }
-
- await setEconomyData(client, guildId, userId, userData);
-
- return {
- success: true,
- newBalance: type === 'bank' ? userData.bank : userData.wallet,
- ...(type === 'bank' ? { maxBank: getMaxBankCapacity(userData) } : {})
- };
- } catch (error) {
- logger.error(`Error adding money to ${type} for user ${userId} in guild ${guildId}:`, error);
- return { success: false, error: 'An error occurred while processing your request' };
- }
-}
-
-
-
-
-
-
-
-
-
-
-export async function removeMoney(client, guildId, userId, amount, type = 'wallet') {
- try {
- if (amount <= 0) {
- return { success: false, error: 'Amount must be positive' };
- }
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (type === 'bank') {
- if ((userData.bank || 0) < amount) {
- return {
- success: false,
- error: 'Insufficient funds in bank',
- current: userData.bank || 0,
- required: amount
- };
- }
- userData.bank = (userData.bank || 0) - amount;
- } else {
- if ((userData.wallet || 0) < amount) {
- return {
- success: false,
- error: 'Insufficient funds in wallet',
- current: userData.wallet || 0,
- required: amount
- };
- }
- userData.wallet = (userData.wallet || 0) - amount;
- }
-
- await setEconomyData(client, guildId, userId, userData);
-
- return {
- success: true,
- newBalance: type === 'bank' ? userData.bank : userData.wallet
- };
- } catch (error) {
- logger.error(`Error removing money from ${type} for user ${userId} in guild ${guildId}:`, error);
- return { success: false, error: 'An error occurred while processing your request' };
- }
-}
-
-
-
-
-
-
-
-
-
-
-export async function transferMoney(client, guildId, userId, amount, direction) {
- try {
- if (amount <= 0) {
- return { success: false, error: 'Amount must be positive' };
- }
-
- const userData = await getEconomyData(client, guildId, userId);
-
- if (direction === 'deposit') {
- if (userData.wallet < amount) {
- return {
- success: false,
- error: 'Insufficient funds in wallet',
- current: userData.wallet,
- required: amount
- };
- }
-
- const maxBank = getMaxBankCapacity(userData);
- if (userData.bank + amount > maxBank) {
- return {
- success: false,
- error: 'Bank capacity exceeded',
- current: userData.bank,
- max: maxBank,
- required: amount
- };
- }
-
- userData.wallet -= amount;
- userData.bank += amount;
- userData.lastDeposit = Date.now();
-
- } else if (direction === 'withdraw') {
- if (userData.bank < amount) {
- return {
- success: false,
- error: 'Insufficient funds in bank',
- current: userData.bank,
- required: amount
- };
- }
-
- userData.bank -= amount;
- userData.wallet += amount;
- userData.lastWithdraw = Date.now();
-
- } else {
- return { success: false, error: 'Invalid transfer direction' };
- }
-
- await setEconomyData(client, guildId, userId, userData);
-
- return {
- success: true,
- wallet: userData.wallet,
- bank: userData.bank,
- ...(direction === 'deposit' ? { maxBank: getMaxBankCapacity(userData) } : {})
- };
-
- } catch (error) {
- logger.error(`Error transferring money (${direction}) for user ${userId} in guild ${guildId}:`, error);
- return { success: false, error: 'An error occurred while processing your request' };
- }
-}
-
-
-
-
-
-
-
-
-
-
-export async function checkCooldown(client, guildId, userId, action, cooldownTime) {
- try {
- const userData = await getEconomyData(client, guildId, userId);
- const now = Date.now();
- const lastAction = userData.lastAction || {};
- const lastTime = lastAction[action] || 0;
- const timeLeft = (lastTime + cooldownTime) - now;
-
- if (timeLeft > 0) {
- return { onCooldown: true, timeLeft };
- }
-
- lastAction[action] = now;
- userData.lastAction = lastAction;
- await setEconomyData(client, guildId, userId, userData);
-
- return { onCooldown: false };
-
- } catch (error) {
- logger.error(`Error checking cooldown for ${action} for user ${userId} in guild ${guildId}:`, error);
- return { onCooldown: true, timeLeft: cooldownTime };
- }
-}
-
-
diff --git a/src/services/economyService.js b/src/services/economyService.js
deleted file mode 100644
index aaffcb241..000000000
--- a/src/services/economyService.js
+++ /dev/null
@@ -1,558 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-import { logger } from '../utils/logger.js';
-import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../utils/economy.js';
-import { createError, ErrorTypes } from '../utils/errorHandler.js';
-import { wrapServiceClassMethods } from '../utils/serviceErrorBoundary.js';
-
-class EconomyService {
-
-
- static DAILY_COOLDOWN = 24 * 60 * 60 * 1000;
- static WORK_COOLDOWN = 30 * 60 * 1000;
- static GAMBLE_COOLDOWN = 5 * 60 * 1000;
- static CRIME_COOLDOWN = 60 * 60 * 1000;
- static ROB_COOLDOWN = 4 * 60 * 60 * 1000;
- static MINE_COOLDOWN = 60 * 60 * 1000;
- static FISH_COOLDOWN = 45 * 60 * 1000;
- static BEG_COOLDOWN = 30 * 60 * 1000;
-
- static DAILY_AMOUNT = 1000;
- static MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
-
- static assertSafeBalance(value, context = {}) {
- if (!Number.isSafeInteger(value) || value < 0 || value > this.MAX_SAFE_INTEGER) {
- throw createError(
- "Invalid balance state",
- ErrorTypes.VALIDATION,
- "Operation would create an invalid account balance.",
- { value, ...context }
- );
- }
- }
-
-
-
-
-
-
-
-
- static async claimDaily(client, guildId, userId) {
- logger.debug(`[ECONOMY_SERVICE] claimDaily requested`, { userId, guildId });
-
- const userData = await getEconomyData(client, guildId, userId);
- if (!userData) {
- logger.error(`[ECONOMY_SERVICE] Failed to load economy data for daily`);
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load your economy data. Please try again later.",
- { userId, guildId }
- );
- }
-
- const now = Date.now();
- const lastDaily = userData.lastDaily || 0;
- const remaining = lastDaily + this.DAILY_COOLDOWN - now;
-
- if (remaining > 0) {
- logger.warn(`[ECONOMY_SERVICE] Daily cooldown active`, {
- userId,
- timeRemaining: remaining
- });
- throw createError(
- "Daily cooldown active",
- ErrorTypes.RATE_LIMIT,
- `You need to wait before claiming daily again. Try again in **${this.formatDuration(remaining)}**.`,
- { remaining, cooldownType: 'daily' }
- );
- }
-
- const earned = this.DAILY_AMOUNT;
- const nextWallet = (userData.wallet || 0) + earned;
- this.assertSafeBalance(nextWallet, { operation: 'claimDaily', userId, guildId });
- userData.wallet = nextWallet;
- userData.lastDaily = now;
-
- try {
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, {
- userId,
- guildId,
- amount: earned,
- newWallet: userData.wallet,
- timestamp: new Date().toISOString(),
- source: 'claim_daily'
- });
-
- return {
- earned,
- newWallet: userData.wallet,
- nextClaimTime: new Date(now + this.DAILY_COOLDOWN)
- };
- } catch (error) {
- logger.error(`[ECONOMY_SERVICE] Failed to save daily claim`, error, {
- userId,
- guildId,
- amount: earned
- });
- throw createError(
- "Failed to save daily claim",
- ErrorTypes.DATABASE,
- "Failed to process your daily. Please try again.",
- { userId, guildId }
- );
- }
- }
-
-
-
-
-
-
-
-
-
-
- static async transferMoney(client, guildId, senderId, receiverId, amount) {
- logger.debug(`[ECONOMY_SERVICE] transferMoney requested`, {
- senderId,
- receiverId,
- amount,
- guildId
- });
-
-
- if (amount <= 0) {
- throw createError(
- "Invalid transfer amount",
- ErrorTypes.VALIDATION,
- "Amount must be greater than zero.",
- { amount, senderId }
- );
- }
-
- if (senderId === receiverId) {
- throw createError(
- "Cannot pay self",
- ErrorTypes.VALIDATION,
- "You cannot pay yourself.",
- { senderId, receiverId }
- );
- }
-
- this.validateAmount(amount, { operation: 'transfer', senderId, receiverId });
-
-
- const [senderData, receiverData] = await Promise.all([
- getEconomyData(client, guildId, senderId),
- getEconomyData(client, guildId, receiverId)
- ]);
-
- if (!senderData || !receiverData) {
- logger.error(`[ECONOMY_SERVICE] Failed to load economy data for transfer`, {
- senderLoaded: !!senderData,
- receiverLoaded: !!receiverData
- });
- throw createError(
- "Failed to load economy data",
- ErrorTypes.DATABASE,
- "Failed to load economy data. Please try again later.",
- { senderId, receiverId, guildId }
- );
- }
-
-
- if (senderData.wallet < amount) {
- logger.warn(`[ECONOMY_SERVICE] Insufficient funds for transfer`, {
- senderId,
- required: amount,
- available: senderData.wallet
- });
- throw createError(
- "Insufficient funds",
- ErrorTypes.VALIDATION,
- `You only have **$${senderData.wallet.toLocaleString()}** in cash.`,
- { required: amount, available: senderData.wallet, senderId }
- );
- }
-
-
- const walletBefore = senderData.wallet;
- const senderNext = (senderData.wallet || 0) - amount;
- const receiverNext = (receiverData.wallet || 0) + amount;
-
- this.assertSafeBalance(senderNext, { operation: 'transfer.sender', senderId, amount });
- this.assertSafeBalance(receiverNext, { operation: 'transfer.receiver', receiverId, amount });
-
- senderData.wallet = senderNext;
- receiverData.wallet = receiverNext;
-
- try {
- // Step 1: Deduct from sender
- await setEconomyData(client, guildId, senderId, senderData);
-
- try {
- // Step 2: Add to receiver
- await setEconomyData(client, guildId, receiverId, receiverData);
- } catch (receiverError) {
- // ROLLBACK: Try to restore sender's money if receiver update fails
- logger.error(`[ECONOMY_CRITICAL] Failed to credit receiver ${receiverId}. Attempting rollback for sender ${senderId}...`, receiverError);
-
- senderData.wallet = walletBefore;
- try {
- await setEconomyData(client, guildId, senderId, senderData);
- logger.info(`[ECONOMY_ROLLBACK] Successfully rolled back sender ${senderId} after receiver credit failure.`);
- } catch (rollbackError) {
- logger.error(`[ECONOMY_FATAL] ROLLBACK FAILED for sender ${senderId}! Data is now inconsistent.`, rollbackError);
- // At this point, manual intervention is needed.
- }
-
- throw receiverError;
- }
-
- logger.info(`[ECONOMY_TRANSACTION] Money transferred`, {
- type: 'transfer',
- senderId,
- receiverId,
- guildId,
- amount,
- senderNewBalance: senderData.wallet,
- receiverNewBalance: receiverData.wallet,
- timestamp: new Date().toISOString()
- });
-
- return {
- success: true,
- senderNewBalance: senderData.wallet,
- receiverNewBalance: receiverData.wallet
- };
- } catch (error) {
- logger.error(`[ECONOMY_SERVICE] Transfer execution failed, DATA MAY BE INCONSISTENT`, error, {
- senderId,
- receiverId,
- amount,
- guildId,
- senderBefore: walletBefore,
- senderAfter: senderData.wallet,
- receiverAfter: receiverData.wallet
- });
- throw createError(
- "Failed to save transfer",
- ErrorTypes.DATABASE,
- "Failed to process transfer. Please try again.",
- { senderId, receiverId, amount }
- );
- }
- }
-
-
-
-
-
-
-
-
-
-
- static async addMoney(client, guildId, userId, amount, source = 'unknown') {
- if (amount <= 0) {
- throw createError(
- "Invalid amount",
- ErrorTypes.VALIDATION,
- "Amount must be positive",
- { amount, userId, source }
- );
- }
-
- this.validateAmount(amount, { operation: 'addMoney', userId, source });
-
- const userData = await getEconomyData(client, guildId, userId);
- const balanceBefore = userData.wallet || 0;
- const nextWallet = balanceBefore + amount;
- this.assertSafeBalance(nextWallet, { operation: 'addMoney', userId, source, amount });
- userData.wallet = nextWallet;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Money added`, {
- userId,
- guildId,
- amount,
- source,
- balanceBefore,
- balanceAfter: userData.wallet,
- delta: amount,
- timestamp: new Date().toISOString()
- });
-
- return userData;
- }
-
-
-
-
-
-
-
-
-
-
- static async removeMoney(client, guildId, userId, amount, reason = 'unknown') {
- if (amount <= 0) {
- throw createError(
- "Invalid amount",
- ErrorTypes.VALIDATION,
- "Amount must be positive",
- { amount, userId, reason }
- );
- }
-
- this.validateAmount(amount, { operation: 'removeMoney', userId, reason });
-
- const userData = await getEconomyData(client, guildId, userId);
- const balanceBefore = userData.wallet || 0;
-
- if (balanceBefore < amount) {
- throw createError(
- "Insufficient funds",
- ErrorTypes.VALIDATION,
- `You only have **$${balanceBefore.toLocaleString()}**.`,
- { required: amount, available: balanceBefore, reason }
- );
- }
-
- userData.wallet = balanceBefore - amount;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Money removed`, {
- userId,
- guildId,
- amount,
- reason,
- balanceBefore,
- balanceAfter: userData.wallet,
- delta: -amount,
- timestamp: new Date().toISOString()
- });
-
- return userData;
- }
-
-
-
-
-
-
-
-
-
- static async depositToBank(client, guildId, userId, amount) {
- this.validateAmount(amount, { operation: 'deposit', userId });
-
- const userData = await getEconomyData(client, guildId, userId);
- const maxBank = getMaxBankCapacity(userData);
-
- if (userData.wallet < amount) {
- throw createError(
- "Insufficient cash",
- ErrorTypes.VALIDATION,
- `You only have **$${userData.wallet.toLocaleString()}** in cash.`,
- { required: amount, available: userData.wallet }
- );
- }
-
- const currentBank = userData.bank || 0;
- if (currentBank + amount > maxBank) {
- throw createError(
- "Bank capacity exceeded",
- ErrorTypes.VALIDATION,
- `Your bank can only hold **$${maxBank.toLocaleString()}**. You would exceed capacity by **$${(currentBank + amount - maxBank).toLocaleString()}**.`,
- { capacity: maxBank, current: currentBank, requested: amount }
- );
- }
-
- const nextWallet = userData.wallet - amount;
- const nextBank = (userData.bank || 0) + amount;
-
- this.assertSafeBalance(nextWallet, { operation: 'deposit.wallet', userId, amount });
- this.assertSafeBalance(nextBank, { operation: 'deposit.bank', userId, amount });
-
- userData.wallet = nextWallet;
- userData.bank = nextBank;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Money deposited to bank`, {
- userId,
- guildId,
- amount,
- walletAfter: userData.wallet,
- bankAfter: userData.bank,
- timestamp: new Date().toISOString()
- });
-
- return userData;
- }
-
-
-
-
-
-
-
-
-
- static async withdrawFromBank(client, guildId, userId, amount) {
- this.validateAmount(amount, { operation: 'withdraw', userId });
-
- const userData = await getEconomyData(client, guildId, userId);
- const bank = userData.bank || 0;
-
- if (bank < amount) {
- throw createError(
- "Insufficient bank balance",
- ErrorTypes.VALIDATION,
- `You only have **$${bank.toLocaleString()}** in your bank.`,
- { required: amount, available: bank }
- );
- }
-
- const nextWallet = (userData.wallet || 0) + amount;
- const nextBank = bank - amount;
-
- this.assertSafeBalance(nextWallet, { operation: 'withdraw.wallet', userId, amount });
- this.assertSafeBalance(nextBank, { operation: 'withdraw.bank', userId, amount });
-
- userData.wallet = nextWallet;
- userData.bank = nextBank;
-
- await setEconomyData(client, guildId, userId, userData);
-
- logger.info(`[ECONOMY_TRANSACTION] Money withdrawn from bank`, {
- userId,
- guildId,
- amount,
- walletAfter: userData.wallet,
- bankAfter: userData.bank,
- timestamp: new Date().toISOString()
- });
-
- return userData;
- }
-
-
-
-
-
-
-
-
- static checkCooldown(userData, action, cooldownMs) {
- const lastActionField = `last${action.charAt(0).toUpperCase() + action.slice(1)}`;
- const lastTime = userData[lastActionField] || 0;
- const now = Date.now();
- const remaining = Math.max(0, lastTime + cooldownMs - now);
-
- return {
- isOnCooldown: remaining > 0,
- remaining,
- formatted: this.formatDuration(remaining),
- nextAvailable: new Date(lastTime + cooldownMs)
- };
- }
-
-
-
-
-
-
- static validateAmount(amount, context = {}) {
- if (!Number.isInteger(amount)) {
- throw createError(
- "Invalid amount - not an integer",
- ErrorTypes.VALIDATION,
- "Amount must be a whole number",
- context
- );
- }
-
- if (amount <= 0) {
- throw createError(
- "Invalid amount - not positive",
- ErrorTypes.VALIDATION,
- "Amount must be positive",
- context
- );
- }
-
- if (amount > this.MAX_SAFE_INTEGER) {
- logger.error(`[ECONOMY] Amount exceeds MAX_SAFE_INTEGER`, { amount, context });
- throw createError(
- "Amount too large",
- ErrorTypes.VALIDATION,
- "The amount is too large to process",
- context
- );
- }
- }
-
-
-
-
-
-
- static formatDuration(ms) {
- const totalSeconds = Math.floor(ms / 1000);
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = totalSeconds % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m ${seconds}s`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds}s`;
- }
- return `${seconds}s`;
- }
-
-
-
-
-
-
- static formatCooldownDisplay(ms) {
- const duration = this.formatDuration(ms);
- return `**${duration}**`;
- }
-}
-
-wrapServiceClassMethods(EconomyService, (methodName) => ({
- service: 'EconomyService',
- operation: methodName,
- message: `Economy service operation failed: ${methodName}`,
- userMessage: 'An economy operation failed. Please try again in a moment.'
-}));
-
-export default EconomyService;
diff --git a/src/utils/commandAliases.js b/src/utils/commandAliases.js
new file mode 100644
index 000000000..b81f50c02
--- /dev/null
+++ b/src/utils/commandAliases.js
@@ -0,0 +1,199 @@
+/**
+ * Prefix command aliases (nh!b → nh!ban).
+ * MANUAL_ALIASES override auto-generated aliases when they conflict.
+ */
+
+/** @type {Record} Extra short names per primary command */
+export const COMMAND_ALIASES_BY_COMMAND = {
+ // Core
+ help: ['h', '?'],
+ ping: ['pn'],
+ stats: ['st'],
+ uptime: ['up'],
+ overview: ['ov', 'info'],
+ support: ['sup'],
+ bug: ['bg'],
+
+ // Moderation
+ ban: ['b'],
+ kick: ['k'],
+ timeout: ['t', 'to', 'mute'],
+ untimeout: ['ut', 'unto', 'unmute'],
+ unban: ['ub'],
+ warn: ['w'],
+ warnings: ['warns', 'wn'],
+ purge: ['p', 'clear'],
+ lock: ['l'],
+ unlock: ['ul'],
+ quarantine: ['q', 'qr'],
+ unquarantine: ['uq', 'unq'],
+ 'setup-quarantine': ['squarantine', 'sq', 'qsetup'],
+ massban: ['mban', 'mb'],
+ masskick: ['mkick', 'mk'],
+ dm: ['message', 'msg'],
+ cases: ['c', 'modlog'],
+ usernotes: ['notes', 'un', 'note'],
+
+ // Welcome
+ welcome: ['wel', 'wlc'],
+ goodbye: ['bye', 'gbye'],
+ greet: ['grt'],
+ autorole: ['ar', 'auto'],
+
+ // Verification
+ verify: ['v', 'ver'],
+ verification: ['vrf', 'verif'],
+ autoverify: ['aver', 'averify'],
+
+ // Voice
+ activity: ['act', 'vc'],
+
+ // Utility
+ avatar: ['av', 'pfp'],
+ userinfo: ['ui', 'whois', 'uinfo'],
+ serverinfo: ['si', 'sinfo', 'guildinfo'],
+ weather: ['wth', 'wt'],
+ todo: ['td', 'tasks'],
+ report: ['rp', 'rep'],
+ firstmsg: ['fm', 'first'],
+ wipedata: ['wipe', 'wd'],
+
+ // Tools
+ poll: ['pl'],
+ time: ['tm'],
+ shorten: ['sh', 'url'],
+ calculate: ['calc', 'cal'],
+ countdown: ['cd', 'timer'],
+ hexcolor: ['hex', 'color'],
+ unixtime: ['unix', 'utime'],
+ baseconvert: ['base', 'convert', 'bc'],
+ randomuser: ['ru', 'randuser'],
+ embedbuilder: ['embed', 'emb', 'eb'],
+ generatepassword: ['genpass', 'gpw', 'password', 'pwd'],
+
+ // Ticket
+ ticket: ['tk', 'tkt'],
+ claim: ['clm'],
+ close: ['cls', 'tcl'],
+ priority: ['prio', 'pri'],
+
+ // Server stats / logging / reaction roles
+ serverstats: ['ss', 'counter', 'cnt'],
+ logging: ['log', 'logs'],
+ reactroles: ['rr', 'reactionrole', 'rroles'],
+
+ // Leveling
+ rank: ['rk', 'rnk', 'xp'],
+ leaderboard: ['lb', 'top', 'ld'],
+ level: ['lv', 'lvl'],
+ leveladd: ['lvadd', 'addlvl'],
+ levelremove: ['lvrm', 'remlvl'],
+ levelset: ['lvset', 'setlvl'],
+
+ // Search
+ google: ['g', 'ggl', 'search'],
+ define: ['def', 'dict'],
+ urban: ['ud', 'urb'],
+ movie: ['mv', 'film'],
+
+ // Giveaway
+ gcreate: ['gc', 'gwcreate', 'giveaway'],
+ gend: ['ge', 'gwend'],
+ gdelete: ['gdel', 'gwdel', 'delgw'],
+ greroll: ['gr', 'gwreroll', 'reroll'],
+
+ // Fun
+ flip: ['f', 'cf', 'coin'],
+ roll: ['r', 'dice'],
+ fight: ['ft'],
+ ship: ['shp'],
+ fact: ['fc', 'facts'],
+ mock: ['mck', 'spongebob'],
+ reverse: ['rev'],
+ wanted: ['want', 'poster'],
+
+ // Community / birthday / join
+ apply: ['apl', 'app'],
+ 'app-admin': ['appadmin', 'appa', 'appadm'],
+ birthday: ['bday', 'bd'],
+ jointocreate: ['jtc', 'j2c', 'joinvc'],
+};
+
+/** Manual global map (legacy + cross-command shortcuts) */
+export const MANUAL_ALIASES = Object.fromEntries(
+ Object.entries(COMMAND_ALIASES_BY_COMMAND).flatMap(([command, aliases]) =>
+ aliases.map((alias) => [alias, command]),
+ ),
+);
+
+/**
+ * @param {string[]} commandNames Primary slash command names
+ * @returns {Record} alias → command name
+ */
+export function buildCommandAliasMap(commandNames) {
+ const map = {};
+ const taken = new Set(commandNames.map((n) => n.toLowerCase()));
+
+ const add = (alias, target) => {
+ const key = alias?.toLowerCase?.();
+ const targetKey = target?.toLowerCase?.();
+ if (!key || !targetKey || key === targetKey) return;
+ if (taken.has(key)) return;
+ if (map[key] && map[key] !== targetKey) return;
+ map[key] = targetKey;
+ taken.add(key);
+ };
+
+ for (const name of commandNames) {
+ const extras = COMMAND_ALIASES_BY_COMMAND[name] ?? [];
+ for (const alias of extras) {
+ add(alias, name);
+ }
+
+ // nh!setupquarantine → setup-quarantine
+ if (name.includes('-')) {
+ add(name.replace(/-/g, ''), name);
+ const acronym = name
+ .split('-')
+ .filter(Boolean)
+ .map((part) => part[0])
+ .join('');
+ if (acronym.length >= 2) {
+ add(acronym, name);
+ }
+ }
+ }
+
+ return map;
+}
+
+/**
+ * @param {import('discord.js').Collection} commands
+ * @returns {number}
+ */
+export function registerPrefixAliases(commands) {
+ const primaryCommands = new Map();
+ for (const command of commands.values()) {
+ const name = command?.data?.name;
+ if (name) {
+ primaryCommands.set(name, command);
+ }
+ }
+
+ const aliasMap = buildCommandAliasMap([...primaryCommands.keys()]);
+ let count = 0;
+
+ for (const [alias, targetName] of Object.entries(aliasMap)) {
+ const targetCommand = primaryCommands.get(targetName);
+ if (!targetCommand) continue;
+ if (!commands.has(alias)) {
+ commands.set(alias, targetCommand);
+ count++;
+ }
+ }
+
+ return count;
+}
+
+// Back-compat for messageCreate
+export const COMMAND_MAP = MANUAL_ALIASES;
diff --git a/src/utils/components.js b/src/utils/components.js
index 4babb09f9..cb8619722 100644
--- a/src/utils/components.js
+++ b/src/utils/components.js
@@ -84,7 +84,7 @@ export function createButton(customId, label, style = 'primary', emoji = null, d
try {
button.setEmoji(emoji);
} catch (error) {
-
+ // Invalid emoji format — skip silently
}
}
@@ -111,7 +111,7 @@ export function createLinkButton(label, url, emoji = null) {
try {
button.setEmoji(emoji);
} catch (error) {
-
+ // Invalid emoji format — skip silently
}
}
@@ -142,7 +142,7 @@ export function createButtonRow(buttons) {
));
}
} catch (error) {
-
+ // Skip invalid button definition
continue;
}
}
diff --git a/src/utils/embeds.js b/src/utils/embeds.js
index b3ca90607..c67ff1139 100644
--- a/src/utils/embeds.js
+++ b/src/utils/embeds.js
@@ -50,7 +50,7 @@ export function createEmbed({
embed.setAuthor(author);
}
} catch (error) {
-
+ // Invalid author URL or format — skip silently to avoid crashing the embed
}
}
@@ -63,7 +63,7 @@ export function createEmbed({
embed.setFooter(footer);
}
} catch (error) {
-
+ // Invalid footer iconURL or format — skip silently to avoid crashing the embed
}
}
@@ -76,7 +76,7 @@ export function createEmbed({
embed.setThumbnail(thumbnail.url);
}
} catch (error) {
-
+ // Invalid thumbnail URL — skip silently to avoid crashing the embed
}
}
@@ -89,7 +89,7 @@ export function createEmbed({
embed.setImage(image.url);
}
} catch (error) {
-
+ // Invalid image URL — skip silently to avoid crashing the embed
}
}
@@ -105,7 +105,7 @@ export function createEmbed({
try {
embed.setURL(url);
} catch (error) {
-
+ // Invalid URL format — skip silently to avoid crashing the embed
}
}
diff --git a/src/utils/helpMenuHelper.js b/src/utils/helpMenuHelper.js
new file mode 100644
index 000000000..996bd5443
--- /dev/null
+++ b/src/utils/helpMenuHelper.js
@@ -0,0 +1,281 @@
+import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
+import { createEmbed } from './embeds.js';
+import { createSelectMenu } from './components.js';
+import { BotConfig } from '../config/bot.js';
+import { COMMAND_ALIASES_BY_COMMAND } from './commandAliases.js';
+import fs from 'fs/promises';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export const CATEGORY_SELECT_ID = 'help-category-select';
+export const ALL_COMMANDS_ID = 'help-all-commands';
+
+const BRAND_NAME = BotConfig.brand?.name || 'nh_starlightsercurity';
+const DEFAULT_PREFIX = BotConfig.prefix || 'nh!';
+
+const CATEGORY_ICONS = {
+ Core: 'ℹ️',
+ Moderation: '🛡️',
+ Fun: '🎮',
+ Leveling: '📊',
+ Utility: '🔧',
+ Ticket: '🎫',
+ Welcome: '👋',
+ Giveaway: '🎉',
+ ServerStats: '🔢',
+ Tools: '🛠️',
+ Search: '🔍',
+ Reaction_roles: '🎭',
+ Community: '👥',
+ Birthday: '🎂',
+ Verification: '✅',
+ Voice: '🔊',
+ Logging: '📝',
+ JoinToCreate: '🎤',
+};
+
+const ALIASES_BY_COMMAND = Object.entries(COMMAND_ALIASES_BY_COMMAND).reduce((acc, [cmd, aliases]) => {
+ acc[cmd] = aliases;
+ return acc;
+}, {});
+
+function brandFooter(suffix = '') {
+ const base = BRAND_NAME;
+ return suffix ? `${base} | ${suffix}` : base;
+}
+
+function formatCategoryLabel(folderName) {
+ if (!folderName) return 'Other';
+ return folderName
+ .split(/[_-]/)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
+ .join(' ');
+}
+
+function formatCommandUsage(name) {
+ const aliases = ALIASES_BY_COMMAND[name];
+ const prefixParts = aliases?.length
+ ? aliases.slice(0, 3).map((a) => `\`${DEFAULT_PREFIX}${a}\``)
+ : [`\`${DEFAULT_PREFIX}${name}\``];
+ return `/${name} · ${prefixParts.join(' · ')}`;
+}
+
+/**
+ * Primary commands only (no duplicate alias keys).
+ * @param {import('discord.js').Client} client
+ */
+export function collectPrimaryCommands(client) {
+ if (!client?.commands?.size) {
+ return [];
+ }
+
+ const seen = new Set();
+ const list = [];
+
+ for (const command of client.commands.values()) {
+ const name = command?.data?.name;
+ if (!name || seen.has(name)) {
+ continue;
+ }
+ seen.add(name);
+
+ list.push({
+ name,
+ description: command.data.description || 'No description available',
+ category: command.category || 'Other',
+ categoryLabel: formatCategoryLabel(command.category),
+ icon: CATEGORY_ICONS[command.category] || '🔹',
+ });
+ }
+
+ return list.sort((a, b) => a.name.localeCompare(b.name));
+}
+
+/**
+ * @param {import('discord.js').Client} client
+ */
+export async function getCategoryFolders() {
+ const commandsPath = path.join(__dirname, '../commands');
+ return (await fs.readdir(commandsPath, { withFileTypes: true }))
+ .filter((dirent) => dirent.isDirectory() && dirent.name !== 'modules')
+ .map((dirent) => dirent.name)
+ .sort();
+}
+
+/**
+ * Category select menu row (kept visible while paging).
+ * @param {import('discord.js').Client} client
+ */
+export async function createHelpSelectRow(client) {
+ const categoryDirs = await getCategoryFolders();
+ const commandCount = collectPrimaryCommands(client).length;
+
+ const options = [
+ {
+ label: '📋 All Commands',
+ description: `View all ${commandCount} commands`,
+ value: ALL_COMMANDS_ID,
+ },
+ ...categoryDirs.map((category) => {
+ const label = formatCategoryLabel(category);
+ const icon = CATEGORY_ICONS[category] || '🔹';
+ return {
+ label: `${icon} ${label}`,
+ description: `Commands in ${label}`,
+ value: category,
+ };
+ }),
+ ];
+
+ return createSelectMenu(CATEGORY_SELECT_ID, 'Select a category', options);
+}
+
+/**
+ * @param {import('discord.js').Client} client
+ * @param {number} currentPage
+ * @param {number} totalPages
+ * @param {string} category
+ */
+export async function buildHelpViewComponents(client, currentPage, totalPages, category = '') {
+ const selectRow = await createHelpSelectRow(client);
+ const pageRow = createHelpPaginationButtons(currentPage, totalPages, category);
+ return [selectRow, pageRow];
+}
+
+/**
+ * @param {import('discord.js').Client} client
+ */
+export async function createInitialHelpMenu(client) {
+ const commandCount = collectPrimaryCommands(client).length;
+
+ const embed = createEmbed({
+ title: `🤖 ${BRAND_NAME}`,
+ description:
+ `Welcome to **${BRAND_NAME}** — ${BotConfig.brand?.tagline || 'your server security bot'}.\n\n` +
+ `**Prefix:** \`${DEFAULT_PREFIX}\` · **Slash:** \`/\`\n` +
+ `Use the menu below to browse every command.\n\n` +
+ `**Moderation tip:** \`ban\` / \`${DEFAULT_PREFIX}b\` = **1 user** per use · ` +
+ `\`massban\` / \`${DEFAULT_PREFIX}mban\` = **many users** in one command.`,
+ color: 'primary',
+ });
+
+ embed.addFields(
+ { name: '🛡️ Moderation', value: 'ban, kick, timeout, purge, lock…', inline: true },
+ { name: '🎮 Fun', value: 'roll, flip, ship, fight…', inline: true },
+ { name: '📊 Leveling', value: 'rank, leaderboard, XP setup', inline: true },
+ { name: '🎫 Tickets', value: 'Support ticket system', inline: true },
+ { name: '🎉 Giveaways', value: 'gcreate, gend, greroll', inline: true },
+ { name: '✅ Verification', value: 'verify, autoverify, roles', inline: true },
+ );
+ embed.setFooter({ text: brandFooter(`${commandCount} commands`) });
+ embed.setTimestamp();
+
+ const bugReportButton = new ButtonBuilder()
+ .setLabel('Contact Developer')
+ .setStyle(ButtonStyle.Link)
+ .setURL('https://discord.com/users/1198136184526864475');
+
+ const selectRow = await createHelpSelectRow(client);
+ const buttonRow = new ActionRowBuilder().addComponents(bugReportButton);
+
+ return { embeds: [embed], components: [buttonRow, selectRow] };
+}
+
+export async function getAllCategories() {
+ return getCategoryFolders();
+}
+
+/**
+ * @param {string} categoryFolder
+ * @param {number} page
+ * @param {import('discord.js').Client} client
+ */
+export async function getCategoryEmbedAndPageCount(categoryFolder, page = 1, client) {
+ const allCommands = collectPrimaryCommands(client).filter(
+ (cmd) => cmd.category === categoryFolder,
+ );
+
+ const categoryLabel = formatCategoryLabel(categoryFolder);
+ const icon = CATEGORY_ICONS[categoryFolder] || '🔹';
+ const pageSize = 5;
+ const totalPages = Math.ceil(allCommands.length / pageSize) || 1;
+ const validPage = Math.max(1, Math.min(page, totalPages));
+ const startIndex = (validPage - 1) * pageSize;
+ const pageCommands = allCommands.slice(startIndex, startIndex + pageSize);
+
+ const embed = createEmbed({
+ title: `${icon} ${categoryLabel} Commands`,
+ description:
+ allCommands.length === 0
+ ? 'No commands in this category.'
+ : `Page ${validPage} of ${totalPages} · **${allCommands.length}** command(s)`,
+ color: 'primary',
+ });
+
+ for (const cmd of pageCommands) {
+ embed.addFields({
+ name: `• ${cmd.name}`,
+ value: `${cmd.description}\n${formatCommandUsage(cmd.name)}`,
+ inline: false,
+ });
+ }
+
+ embed.setFooter({ text: brandFooter(`Page ${validPage}/${totalPages}`) });
+ embed.setTimestamp();
+
+ return { embed, totalPages, currentPage: validPage };
+}
+
+/**
+ * @param {number} page
+ * @param {import('discord.js').Client} client
+ */
+export async function getAllCommandsEmbedAndPageCount(page = 1, client) {
+ const allCommands = collectPrimaryCommands(client);
+ const pageSize = 8;
+ const totalPages = Math.ceil(allCommands.length / pageSize) || 1;
+ const validPage = Math.max(1, Math.min(page, totalPages));
+ const startIndex = (validPage - 1) * pageSize;
+ const pageCommands = allCommands.slice(startIndex, startIndex + pageSize);
+
+ const embed = createEmbed({
+ title: `📋 All Commands — ${BRAND_NAME}`,
+ description: `Page ${validPage} of ${totalPages} · **${allCommands.length}** commands · Prefix \`${DEFAULT_PREFIX}\``,
+ color: 'primary',
+ });
+
+ for (const cmd of pageCommands) {
+ embed.addFields({
+ name: `${cmd.icon} /${cmd.name}`,
+ value: `${cmd.description}\n${formatCommandUsage(cmd.name)}\n*${cmd.categoryLabel}*`,
+ inline: false,
+ });
+ }
+
+ embed.setFooter({ text: brandFooter(`Page ${validPage}/${totalPages}`) });
+ embed.setTimestamp();
+
+ return { embed, totalPages, currentPage: validPage };
+}
+
+export function createHelpPaginationButtons(currentPage, totalPages, category = '') {
+ const canGoBack = currentPage > 1;
+ const canGoNext = currentPage < totalPages;
+
+ const backButton = new ButtonBuilder()
+ .setCustomId(`help:back:${currentPage - 1}:${category}`)
+ .setLabel('← Back')
+ .setStyle(ButtonStyle.Primary)
+ .setDisabled(!canGoBack);
+
+ const nextButton = new ButtonBuilder()
+ .setCustomId(`help:next:${currentPage + 1}:${category}`)
+ .setLabel('Next →')
+ .setStyle(ButtonStyle.Primary)
+ .setDisabled(!canGoNext);
+
+ return new ActionRowBuilder().addComponents(backButton, nextButton);
+}
diff --git a/src/utils/moderation.js b/src/utils/moderation.js
index 24690cd1f..e12bd471a 100644
--- a/src/utils/moderation.js
+++ b/src/utils/moderation.js
@@ -4,22 +4,6 @@ import { logger } from './logger.js';
import { getFromDb, setInDb } from './database.js';
import { getColor } from '../config/bot.js';
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
export async function logEvent({ client, guild, guildId, event }) {
try {
if (!guild && guildId) {
@@ -48,7 +32,6 @@ export async function logEvent({ client, guild, guildId, event }) {
return;
}
-
const actionStyles = {
'Member Banned': { color: getColor('error'), icon: '🔨' },
'Member Kicked': { color: getColor('warning'), icon: '👢' },
@@ -125,12 +108,6 @@ export async function logEvent({ client, guild, guildId, event }) {
}
}
-
-
-
-
-
-
export async function generateCaseId(client, guildId) {
try {
const caseKey = `moderation_cases_${guildId}`;
@@ -140,7 +117,7 @@ export async function generateCaseId(client, guildId) {
return nextCase;
} catch (error) {
logger.error("Error generating case ID:", error);
-return Date.now();
+ return Date.now();
}
}
@@ -179,29 +156,32 @@ export async function storeModerationCase({ guildId, caseId, caseData }) {
}
}
-
-
-
-
-
-
+/**
+ * Get filtered moderation cases
+ * @param {string} guildId - The guild ID
+ * @param {Object} filters - Search filters
+ * @returns {Promise} Filtered cases
+ */
export async function getModerationCases(guildId, filters = {}) {
try {
const { userId, moderatorId, action, limit = 50, offset = 0 } = filters;
- const allCases = [];
-
const caseListKey = `moderation_cases_list_${guildId}`;
const caseList = await getFromDb(caseListKey, []);
let filteredCases = caseList;
+ // TỐI ƯU HÓA: Tìm kiếm thông minh qua cả ID trực tiếp lẫn quét chuỗi text hiển thị 'target'
if (userId) {
- filteredCases = filteredCases.filter(case_ => case_.targetUserId === userId);
+ filteredCases = filteredCases.filter(case_ => {
+ return case_.targetUserId === userId || (case_.target && case_.target.includes(userId));
+ });
}
if (moderatorId) {
- filteredCases = filteredCases.filter(case_ => case_.moderatorId === moderatorId);
+ filteredCases = filteredCases.filter(case_ => {
+ return case_.moderatorId === moderatorId || (case_.executor && case_.executor.includes(moderatorId));
+ });
}
if (action) {
@@ -252,5 +232,3 @@ export async function logModerationAction({ client, guild, event }) {
return caseId;
}
-
-
diff --git a/src/utils/prefixCommandAdapter.js b/src/utils/prefixCommandAdapter.js
new file mode 100644
index 000000000..842380b5b
--- /dev/null
+++ b/src/utils/prefixCommandAdapter.js
@@ -0,0 +1,203 @@
+import { ApplicationCommandOptionType } from 'discord.js';
+
+const MENTION_REGEX = /^<[@#][!&]?\d+>$/;
+const INTEGER_REGEX = /^-?\d+$/;
+const SNOWFLAKE_REGEX = /^\d{17,20}$/;
+
+function stripFlags(opts) {
+ if (!opts || typeof opts !== 'object') return opts;
+ const { flags, ephemeral, ...safe } = opts;
+ return safe;
+}
+
+function getTextOnlyArgs(args) {
+ return args.filter((a) => !MENTION_REGEX.test(a) && !INTEGER_REGEX.test(a));
+}
+
+function parseSubcommandTokens(command, textOnlyArgs) {
+ const json = command?.data?.toJSON?.() ?? { options: [] };
+ const groupNames = new Set(
+ (json.options ?? [])
+ .filter((o) => o.type === ApplicationCommandOptionType.SubcommandGroup)
+ .map((o) => o.name),
+ );
+ const topSubcommands = new Set(
+ (json.options ?? [])
+ .filter((o) => o.type === ApplicationCommandOptionType.Subcommand)
+ .map((o) => o.name),
+ );
+
+ let subcommandGroup = null;
+ let subcommand = null;
+ let stringArgsStart = 0;
+
+ if (textOnlyArgs.length > 0 && groupNames.has(textOnlyArgs[0])) {
+ subcommandGroup = textOnlyArgs[0];
+ subcommand = textOnlyArgs[1] ?? null;
+ stringArgsStart = 2;
+ } else if (textOnlyArgs.length > 0 && topSubcommands.has(textOnlyArgs[0])) {
+ subcommand = textOnlyArgs[0];
+ stringArgsStart = 1;
+ }
+
+ return {
+ subcommandGroup,
+ subcommand,
+ stringArgs: textOnlyArgs.slice(stringArgsStart),
+ };
+}
+
+function findSnowflake(args) {
+ return args.find((a) => SNOWFLAKE_REGEX.test(a)) ?? null;
+}
+
+/**
+ * Fake interaction for prefix commands (nh!ban @user reason).
+ * Uses a single bot reply message for defer/reply/editReply to avoid duplicate success messages.
+ */
+export function createPrefixInteraction(message, client, command, commandName, args = []) {
+ const parsedArgs = [...args];
+ const textOnlyArgs = getTextOnlyArgs(parsedArgs);
+ const { subcommandGroup, subcommand, stringArgs } = parseSubcommandTokens(command, textOnlyArgs);
+
+ let _deferred = false;
+ let _replied = false;
+ let _replyMessage = null;
+
+ const sendOrEditReply = async (content) => {
+ const opts = stripFlags(typeof content === 'string' ? { content } : content);
+ if (_replyMessage) {
+ _replyMessage = await _replyMessage.edit(opts);
+ } else {
+ _replyMessage = await message.reply(opts);
+ }
+ _replied = true;
+ return _replyMessage;
+ };
+
+ return {
+ _isPrefix: true,
+ id: `prefix-${message.id}`,
+ createdTimestamp: message.createdTimestamp,
+ guildId: message.guild.id,
+ channelId: message.channel.id,
+ commandName,
+ type: 0,
+ member: message.member,
+ memberPermissions: message.member?.permissions ?? null,
+ guild: message.guild,
+ channel: message.channel,
+ user: message.author,
+ client,
+
+ get deferred() {
+ return _deferred;
+ },
+ get replied() {
+ return _replied;
+ },
+
+ deferReply: async () => {
+ _deferred = true;
+ },
+
+ reply: sendOrEditReply,
+ editReply: sendOrEditReply,
+
+ followUp: async (content) => {
+ const opts = stripFlags(typeof content === 'string' ? { content } : content);
+ return message.channel.send(opts);
+ },
+
+ deleteReply: async () => {
+ if (_replyMessage) {
+ await _replyMessage.delete().catch(() => {});
+ _replyMessage = null;
+ _replied = false;
+ }
+ },
+
+ options: {
+ getSubcommandGroup: () => subcommandGroup,
+ getSubcommand: () => subcommand,
+
+ getInteger: () => {
+ const found = parsedArgs.find((a) => INTEGER_REGEX.test(a));
+ return found !== undefined ? parseInt(found, 10) : null;
+ },
+
+ getNumber: () => {
+ const found = parsedArgs.find((a) => /^-?[\d.]+$/.test(a) && !Number.isNaN(parseFloat(a)));
+ return found !== undefined ? parseFloat(found) : null;
+ },
+
+ getString: (name) => {
+ const mentionUsers = [...message.mentions.users.values()].map((u) => `<@${u.id}>`);
+ const snowflakeUsers = parsedArgs.filter(
+ (a) => SNOWFLAKE_REGEX.test(a) && !message.mentions.users.has(a),
+ );
+
+ // massban / masskick: users = all @mentions (or IDs), reason = remaining text
+ if (name === 'users') {
+ const combined = [...mentionUsers, ...snowflakeUsers];
+ if (combined.length > 0) {
+ return combined.join(' ');
+ }
+ return stringArgs.join(' ') || null;
+ }
+
+ if (name === 'reason') {
+ return stringArgs.join(' ') || null;
+ }
+
+ return stringArgs.join(' ') || null;
+ },
+
+ getUser: () => message.mentions.users.first() ?? null,
+
+ // Sync only — moderation commands use getMember() without await
+ getMember: (_name) => {
+ const mentioned = message.mentions.members.first();
+ if (mentioned) return mentioned;
+
+ const mentionedUser = message.mentions.users.first();
+ if (mentionedUser) {
+ return message.guild.members.cache.get(mentionedUser.id) ?? null;
+ }
+
+ const id = findSnowflake(parsedArgs);
+ return id ? message.guild.members.cache.get(id) ?? null : null;
+ },
+
+ getChannel: () =>
+ message.mentions.channels.first() ?? message.channel,
+
+ getRole: () => {
+ const mentioned = message.mentions.roles.first();
+ if (mentioned) return mentioned;
+ const id = findSnowflake(parsedArgs);
+ return id ? message.guild.roles.cache.get(id) ?? null : null;
+ },
+
+ getBoolean: () => {
+ const lowered = parsedArgs.map((a) => a.toLowerCase());
+ if (lowered.some((a) => ['true', 'yes', 'on', '1'].includes(a))) return true;
+ if (lowered.some((a) => ['false', 'no', 'off', '0'].includes(a))) return false;
+ return null;
+ },
+
+ getAttachment: () => message.attachments.first() ?? null,
+ },
+ };
+}
+
+export function parsePrefixContent(content, prefix) {
+ if (!content.toLowerCase().startsWith(prefix.toLowerCase())) return null;
+
+ const trimmed = content.slice(prefix.length).trim();
+ if (!trimmed) return null;
+
+ const parts = trimmed.split(/ +/);
+ const commandName = parts.shift().toLowerCase();
+ return { commandName, args: parts };
+}