diff --git a/README.md b/README.md index 700ac20739..3d0d6a0b5e 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,24 @@ -# TitanBot - Ultimate Discord Bot +# Milano Roleplay 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. +This repository contains the Milano Roleplay Discord bot, built with Node.js, discord.js, and PostgreSQL. -[![Support Server](https://img.shields.io/badge/-Support%20Server-%235865F2?logo=discord&logoColor=white&style=flat-square&logoWidth=20)](https://discord.gg/8kJBYhTGW9) -[![Discord.js](https://img.shields.io/npm/v/discord.js?style=flat-square&labelColor=%23202225&color=%23202225&logo=npm&logoColor=white&logoWidth=20)](https://www.npmjs.com/package/discord.js) -![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-%23336791?logo=postgresql&logoColor=white&style=flat-square&logoWidth=20) +## Features -## Table of Contents +The bot supports moderation, tickets, applications, leveling, giveaways, birthdays, server statistics, reaction roles, search and utility tools, verification, welcome messages, and logging. Economy commands, fun/game commands, Join-to-Create voice-channel automation, and music playback have been removed. -- [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.md) +## Setup - -## Features Overview +1. Install Node.js 20.10 or later and PostgreSQL. +2. Copy `.env.example` to `.env` and configure `DISCORD_TOKEN`, `CLIENT_ID`, and your PostgreSQL connection. +3. Install dependencies with `npm install`. +4. Start the bot with `npm start`. -TitanBot offers a complete suite of tools for Discord server management and community engagement: +For Docker deployment, configure `.env` and run `docker compose up -d --build`. - - - - - -
+## Commands -### Moderation & Administration -- **Mass Actions** - Bulk ban/kick capabilities -- **User Notes** - Keep detailed moderation records -- **Case Management** - View and track all mod actions +Use `/help` in Discord to browse available commands. Slash commands are registered from the remaining command modules at startup. -### Economy System -- **Shop & Inventory** - Buy and manage items -- **Gambling** - Risk it for rewards -- **Pay System** - Transfer money between users +## Support -### 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 - -### Music -- **24/7 Mode** - Play music 24/7 -- **Interative Button System** - Manage music through buttons -- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music - -
- - -## 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:** - ```bash - cp .env.example .env - ``` - Set at minimum `DISCORD_TOKEN`, `CLIENT_ID`, and `GUILD_ID`. Docker Compose also reads `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` from `.env` (defaults: `titanbot` / `password` / `titanbot`). - -3. **Build and start the containers:** - ```bash - docker compose up -d --build - ``` - -4. **Check status:** - ```bash - docker compose ps - curl http://localhost:3000/health - ``` - -This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. Music uses public Lavalink v4 nodes from `lavalink/nodes.json` by default. - -### Music - -Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy](https://github.com/riffy-rb/riffy), similar to [Musicify](https://github.com/codebymitch/Musicify). - -1. By default, the bot loads multiple public v4 SSL nodes from [`lavalink/nodes.json`](lavalink/nodes.json) (sourced from [lavalink.darrennathanael.com](https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/)). Edit that file to add or remove nodes. -2. To self-host Lavalink instead, run `docker compose --profile local-lavalink up -d` and set single-node env vars in `.env`: - ```env - LAVALINK_HOST=lavalink - LAVALINK_PORT=2333 - LAVALINK_PASSWORD=youshallnotpass - LAVALINK_SECURE=false - ``` - Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. -3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. -4. Use `/play ` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. - -### 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 20.10.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`). - -### Multiple servers - -Slash commands are registered **globally** on startup (via `CLIENT_ID`), so the bot works in every server it is invited to. `GUILD_ID` stays in the tutorial `.env` for setup steps but is not used for command registration. - -Notes: -- Global slash commands may take up to about an hour to propagate on first deploy -- Each server has **isolated** data: config, economy, tickets, leveling, dashboards, warnings, etc. (all keys are scoped as `guild:{guildId}:...`) -- In the [Discord Developer Portal](https://discord.com/developers/applications), ensure your bot is not restricted to a single guild if you plan to invite it elsewhere -- Generate an OAuth2 invite URL from the [Discord Developer Portal](https://discord.com/developers/applications) (OAuth2 → URL Generator, scopes: `bot` and `applications.commands`) - -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. **Verify Database Setup** - ```bash - npm run migrate:check - ``` - -6. **Start the Bot** - ```bash - npm start - ``` - -> **Note on database migrations:** Schema tables and legacy key migrations run -> **automatically on startup**, so` managed hosts like **Railway** need no manual -> migration step — just deploy/restart. To disable auto-migration set -> `AUTO_MIGRATE=false`. You can still run a manual key migration locally with -> `node scripts/migrate-keys.js --dry-run` (preview) or `node scripts/migrate-keys.js`. - - -## 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** - -## 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* +Discord: https://discord.gg/mlroleplay diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..157c1c47db 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -1,181 +1,41 @@ -import { - SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, -} from "discord.js"; +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.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"; +import { createEmbed } from '../../utils/embeds.js'; +import { createSelectMenu } from '../../utils/components.js'; -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: "💰", - Music: "🎵", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - "Reaction Roles": "🎭", - Community: "👥", - Birthday: "🎂", - "Join To Create": "🔌", - Verification: "✅", -}; - -function formatCategoryName(rawCategory) { - return rawCategory - .replace(/_/g, '') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} +const ALL_COMMANDS_ID = 'help-all-commands'; +const EXCLUDED_CATEGORIES = new Set(['economy', 'fun', 'music', 'jointocreate']); 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: "Browse every available command in a single list", - value: ALL_COMMANDS_ID, - }, - ...categoryDirs.map((category) => { - const categoryName = formatCategoryName(category); - 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`, - description: 'Set up your server, pick what to enable, then browse commands below.', - color: 'primary', - thumbnail: client.user?.displayAvatarURL?.({ size: 1024 }), - fields: [ - { - name: '🚀 Getting Started', - value: [ - '**1. Launch setup** — Run `/configwizard` to configure prefix, mod role, and logs.', - '**2. Enable systems** — Use `/commands dashboard` to turn categories on or off.', '**3. Browse commands** — Use the menu below to view categories and commands.', - ].join('\n'), - inline: false, - }, - { - name: 'ℹ️ How It Works', - value: [ - '• Dashboard commands manage each feature visually', - '• Settings are saved per server', - '• Slash commands and prefixes both work once enabled', - ].join('\n'), - inline: false, - }, - { - name: '\u200B', - value: `-# ${botName} is [open source](https://youtu.be/1jCZX8s3bJE?si=NPOYx-vxVE1I5vJK)`, - inline: false, - }, - ], - }); - - 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 selectRow = createSelectMenu( - CATEGORY_SELECT_ID, - "Select to view the commands", - options, - ); - - const buttonRow = new ActionRowBuilder().addComponents([ - bugReportButton, - supportButton, - ]); - - return { - embeds: [embed], - components: [buttonRow, selectRow], - }; + const categories = [...new Set([...(client?.commands?.values?.() ?? [])] + .map((command) => command.category) + .filter((category) => category && !EXCLUDED_CATEGORIES.has(String(category).toLowerCase())))].sort(); + const options = [ + { label: '📋 Alle Befehle', description: 'Alle verfügbaren Befehle anzeigen', value: ALL_COMMANDS_ID }, + ...categories.slice(0, 24).map((category) => ({ + label: category.replace(/_/g, ' '), + description: `Befehle der Kategorie ${category}`, + value: category, + })), + ]; + const embed = createEmbed({ + title: `📖 ${client?.user?.username || 'Bot'} Hilfe`, + description: 'Wähle eine Kategorie aus, um die verfügbaren Befehle zu sehen.', + color: 'primary', + fields: [{ name: 'Hinweis', value: 'Nur aktiv geladene Befehle werden hier angezeigt.' }], + }); + const menu = createSelectMenu('help-category-select', 'Kategorie auswählen', options); + const bug = new ButtonBuilder().setCustomId('help-bug-report').setLabel('Report Bug').setStyle(ButtonStyle.Danger); + const support = new ButtonBuilder().setLabel('Support Server').setURL('https://discord.gg/QnWNz2dKCE').setStyle(ButtonStyle.Link); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(bug, support), menu] }; } export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName("help") - .setDescription("Displays the help menu with all available commands"), - - async execute(interaction, guildConfig, client) { - - const { MessageFlags } = await import('discord.js'); - await InteractionHelper.safeDefer(interaction); - - const { embeds, components } = await createInitialHelpMenu(client); - - await InteractionHelper.safeEditReply(interaction, { - embeds, - components, - }); - - setTimeout(async () => { - try { - if (!InteractionHelper.isInteractionValid(interaction)) { - return; - } - - 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) { - logger.debug('Help menu close edit failed (interaction may have expired):', error?.message); - } - }, HELP_MENU_TIMEOUT_MS); - }, -}; \ No newline at end of file + slashOnly: true, + data: new SlashCommandBuilder().setName('help').setDescription('Displays the help menu with all available commands'), + async execute(interaction, guildConfig, client) { + await InteractionHelper.safeDefer(interaction); + const view = await createInitialHelpMenu(client); + await InteractionHelper.safeEditReply(interaction, view); + }, +}; diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js index 95528ecd9c..ff8b4c5632 100644 --- a/src/commands/Economy/balance.js +++ b/src/commands/Economy/balance.js @@ -1,87 +1 @@ -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 userOption = interaction.options.getUser("user"); - const targetUser = userOption || interaction.user; - const guildId = interaction.guildId; - - logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!interaction._commandStartTime}`); - - 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); - - logger.info(`[ECONOMY] Economy data retrieved - userData:`, userData); - - 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js index 9b2fc717f9..ff8b4c5632 100644 --- a/src/commands/Economy/beg.js +++ b/src/commands/Economy/beg.js @@ -1,99 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, 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 { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = Number(botConfig?.economy?.begMin) || 50; -const MAX_WIN = Number(botConfig?.economy?.begMax) || 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 = successEmbed( - 'Begging Successful', - 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 = warningEmbed( - 'Insufficient Funds', - 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 1a90387315..ff8b4c5632 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -1,160 +1 @@ -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/config/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.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" || item.type === "tool") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - if (item.type === "tool") { - successDescription += `\n\n**🛠️ ${item.name} added to your inventory!**`; - } - } - - 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index 101dc5a575..ff8b4c5632 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -1,119 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const JAIL_TIME = 2 * 60 * 60 * 1000; -const FINE_RATE = 0.2; - -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 { - // Fine is based on the potential haul of the attempted crime - const potentialHaul = Math.floor((crime.min + crime.max) / 2); - const fine = Math.min(Math.floor(potentialHaul * FINE_RATE), userData.wallet || 0); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = warningEmbed( - "🚔 Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine.toLocaleString()} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index 4045d23ed0..ff8b4c5632 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -1,104 +1 @@ -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/config/guildConfig.js'; -import { formatDuration } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botConfig } from '../../config/bot.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js index 56015b5fb4..ff8b4c5632 100644 --- a/src/commands/Economy/deposit.js +++ b/src/commands/Economy/deposit.js @@ -1,138 +1 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.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: [ - buildUserErrorEmbed( - 'validation', - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: MessageFlags.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: [ - buildUserErrorEmbed( - 'validation', - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: MessageFlags.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 = successEmbed( - 'Deposit Successful', - `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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.js index d702eb6157..ff8b4c5632 100644 --- a/src/commands/Economy/economy.js +++ b/src/commands/Economy/economy.js @@ -1,32 +1 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import economyDashboard from './modules/economy_dashboard.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('economy') - .setDescription('Economy management commands') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the economy management dashboard') - ), - category: 'Economy', - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - await economyDashboard.execute(interaction, config, client); - } - } -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js index e4603797a7..ff8b4c5632 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.js @@ -1,89 +1 @@ -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'; -import { getEconomyPrefix } from '../../utils/database.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 = getEconomyPrefix(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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 5288f048bb..ff8b4c5632 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -1,132 +1 @@ -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 { 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js index b4acf1745d..ff8b4c5632 100644 --- a/src/commands/Economy/gamble.js +++ b/src/commands/Economy/gamble.js @@ -1,131 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.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); - // Net change: the bet is replaced by the payout (bet was at stake, not pre-deducted) - cashChange = amountWon - betAmount; - - resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = warningEmbed( - "💔 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index b7efcfec80..ff8b4c5632 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -1,70 +1 @@ -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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js index fda8ba1dda..ff8b4c5632 100644 --- a/src/commands/Economy/mine.js +++ b/src/commands/Economy/mine.js @@ -1,93 +1 @@ -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 { 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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js index 5e8e904862..ff8b4c5632 100644 --- a/src/commands/Economy/pay.js +++ b/src/commands/Economy/pay.js @@ -1,149 +1 @@ -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 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 = successEmbed( - 'Payment Successful', - `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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js index 68f74fbfee..ff8b4c5632 100644 --- a/src/commands/Economy/rob.js +++ b/src/commands/Economy/rob.js @@ -1,154 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { BotConfig } from '../../config/bot.js'; - -const ROB_COOLDOWN = BotConfig.economy?.cooldowns?.rob ?? 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = BotConfig.economy?.robSuccessRate ?? 0.4; -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: [ - warningEmbed( - 'Robbery Blocked', - `${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 = successEmbed( - 'Robbery Successful', - `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 = buildUserErrorEmbed( - 'unknown', - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`, - { titleOverride: 'Robbery Failed' } - ); - } - - 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 ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.js index fb3be41dc3..ff8b4c5632 100644 --- a/src/commands/Economy/shop-config.js +++ b/src/commands/Economy/shop-config.js @@ -1,28 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop-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) { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'setrole') { - return shopConfigSetrole.execute(interaction, config, client); - } - }, -}; +export default {}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index 16b2b62d96..ff8b4c5632 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -1,13 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopBrowse from './modules/shop_browse.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Browse the economy shop.'), - - async execute(interaction, config, client) { - return shopBrowse.execute(interaction, config, client); - }, -}; +export default {}; diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js index 98b83e369e..ff8b4c5632 100644 --- a/src/commands/Economy/slut.js +++ b/src/commands/Economy/slut.js @@ -1,188 +1 @@ -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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js index bf45af02ad..ff8b4c5632 100644 --- a/src/commands/Economy/withdraw.js +++ b/src/commands/Economy/withdraw.js @@ -1,85 +1 @@ -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 { 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 = successEmbed( - 'Withdrawal Successful', - `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 +export default {}; diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js index 775be897ad..ff8b4c5632 100644 --- a/src/commands/Economy/work.js +++ b/src/commands/Economy/work.js @@ -1,123 +1 @@ -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'; -import { botConfig } from '../../config/bot.js'; - -const WORK_COOLDOWN = botConfig.economy?.cooldowns?.work ?? 30 * 60 * 1000; -const MIN_WORK_AMOUNT = botConfig.economy?.workMin ?? 10; -const MAX_WORK_AMOUNT = botConfig.economy?.workMax ?? 100; -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' }) -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Fun/count.js b/src/commands/Fun/count.js index 1dbd6014b1..ff8b4c5632 100644 --- a/src/commands/Fun/count.js +++ b/src/commands/Fun/count.js @@ -1,176 +1 @@ -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - getCountingGameConfig, - activateCountingGame, - disableCountingGame, - resetCountingGame, - buildCountingLeaderboard, - getCountingSystemChoices, - getCountingSystemLabel, - getExpectedCountValue, -} from '../../services/countingGameService.js'; -import { logger } from '../../utils/logger.js'; - -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName('count') - .setDescription('Manage the server counting game') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName('setup') - .setDescription('Start a counting game in a text channel') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('The channel where counting will take place') - .setRequired(true) - .addChannelTypes(ChannelType.GuildText), - ) - .addStringOption((option) => - option - .setName('system') - .setDescription('The counting system to use') - .setRequired(true) - .addChoices(...getCountingSystemChoices()), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName('disable').setDescription('Disable the counting game for this server'), - ) - .addSubcommand((subcommand) => - subcommand.setName('status').setDescription('View current counting game status'), - ) - .addSubcommand((subcommand) => - subcommand - .setName('reset') - .setDescription('Reset the current counting sequence') - .addIntegerOption((option) => - option - .setName('start') - .setDescription('The number to start at after reset') - .setMinValue(1), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName('leaderboard').setDescription('Show the counting game leaderboard'), - ), - category: 'Fun', - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) { - logger.warn('Count command defer failed', { userId: interaction.user.id, guildId: interaction.guildId }); - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); - } - - const guildId = interaction.guildId; - const subcommand = interaction.options.getSubcommand(); - const config = await getCountingGameConfig(interaction.client, guildId); - - if (subcommand === 'setup') { - const channel = interaction.options.getChannel('channel'); - const system = interaction.options.getString('system'); - if (!channel || channel.type !== ChannelType.GuildText) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a text channel for the counting game.' }); - } - - if (config.enabled && config.channelId && config.channelId !== channel.id) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has an active counting channel configured: <#${config.channelId}>. Disable the current counting game first, or use that existing channel.` }); - } - - await activateCountingGame(interaction.client, guildId, channel.id, system); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Counting Game Enabled', - `The counting game is now active in ${channel} using the **${getCountingSystemLabel(system)}** system. Players must count up from **1** and may not post two numbers in a row.`, - ), - ], - }); - } - - if (subcommand === 'disable') { - if (!config.enabled) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed('Counting Game Disabled', 'The counting game is already disabled for this server.')], - }); - } - - await disableCountingGame(interaction.client, guildId); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Counting Game Disabled', 'The counting game has been disabled.')], - }); - } - - if (subcommand === 'status') { - const fields = [ - { name: 'Enabled', value: config.enabled ? 'Yes' : 'No', inline: true }, - { name: 'Channel', value: config.channelId ? `<#${config.channelId}>` : 'Not configured', inline: true }, - { name: 'System', value: getCountingSystemLabel(config.system), inline: true }, - { name: 'Next count', value: getExpectedCountValue(config), inline: true }, - { name: 'Current streak', value: `${config.currentStreak}`, inline: true }, - { name: 'Best streak', value: `${config.bestStreak || 0}`, inline: true }, - { name: 'Last counter', value: config.lastUserId ? `<@${config.lastUserId}>` : 'None', inline: true }, - ]; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'Counting Game Status', - description: 'Overview of the currently configured counting game.', - fields, - color: 'primary', - }), - ], - }); - } - - if (subcommand === 'reset') { - if (!config.enabled) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Enable the counting game first with `/count setup`.' }); - } - - const startNumber = interaction.options.getInteger('start') || 1; - await resetCountingGame(interaction.client, guildId, startNumber); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Counting Game Reset', - `The counting sequence has been reset. Start again with **${startNumber}** in <#${config.channelId}>.`, - ), - ], - }); - } - - if (subcommand === 'leaderboard') { - const leaderboard = buildCountingLeaderboard(config, interaction.guild); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'Counting Game Leaderboard', - description: leaderboard.length > 0 ? leaderboard.join('\n') : 'No counts have been recorded yet.', - color: 'primary', - }), - ], - }); - } - - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a valid counting game action.' }); - } catch (error) { - logger.error('Count command error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Something went wrong while managing the counting game.' }); - } - }, -}; +export default {}; diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js index 3093af2419..ff8b4c5632 100644 --- a/src/commands/Fun/fight.js +++ b/src/commands/Fun/fight.js @@ -1,83 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const EMBED_DESCRIPTION_LIMIT = 4096; - -export default { - data: new SlashCommandBuilder() - .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") - .addUserOption((option) => - option - .setName("opponent") - .setDescription("The user to fight.") - .setRequired(true), - ), - category: 'Fun', - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); - - if (challenger.id === opponent.id) { - const embed = warningEmbed( - "⚔️ Invalid Challenge", - `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.` - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - if (opponent.bot) { - const embed = warningEmbed( - "⚔️ Invalid Opponent", - "You can't fight bots! Challenge a real person instead." - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const winner = rand(0, 1) === 0 ? challenger : opponent; - const loser = winner.id === challenger.id ? opponent : challenger; - const rounds = rand(3, 7); - const damage = rand(10, 50); - - const log = []; - log.push( - `💥 **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, - ); - - for (let i = 1; i <= rounds; i++) { - const attacker = rand(0, 1) === 0 ? challenger : opponent; - const target = attacker.id === challenger.id ? opponent : challenger; - const action = [ - "throws a wild punch", - "lands a critical hit", - "uses a weak spell", - "parries and counterattacks", - ][rand(0, 3)]; - log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, - ); - } - - const outcomeText = log.join("\n"); - const winnerText = `👑 **${winner.username}** has defeated ${loser.username} and claims the victory!`; - const fullDescription = `${outcomeText}\n\n${winnerText}`; - - const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT - ? fullDescription - : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; - - const embed = successEmbed( - "🏆 Duel Complete!", - description - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js index 2eeef5cfa9..ff8b4c5632 100644 --- a/src/commands/Fun/flip.js +++ b/src/commands/Fun/flip.js @@ -1,25 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), - category: 'Fun', - - async execute(interaction, config, client) { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "🪙" : "🔮"; - - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file +export default {}; diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js index 988fe1b663..ff8b4c5632 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/roll.js @@ -1,81 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") - .addStringOption((option) => - option - .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") - .setRequired(true) - .setMaxLength(50), - ), - category: 'Fun', - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); - - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); - - if (!match) { - throw new TitanBotError( - `Invalid dice notation: ${notation}`, - ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' - ); - } - - const numDice = parseInt(match[1] || "1", 10); - const numSides = parseInt(match[2], 10); - const modifier = parseInt(match[3] || "0", 10); - - if (numDice < 1 || numDice > 20) { - throw new TitanBotError( - `Too many dice requested: ${numDice}`, - ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' - ); - } - - if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( - `Invalid number of sides: ${numSides}`, - ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' - ); - } - - let rolls = []; - let totalRoll = 0; - - for (let i = 0; i < numDice; i++) { - const roll = Math.floor(Math.random() * numSides) + 1; - rolls.push(roll); - totalRoll += roll; - } - - const finalTotal = totalRoll + modifier; - - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? `+ (${modifier})` : ""; - - const embed = successEmbed( - `🎲 Rolling ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, - `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file +export default {}; diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index 593212f856..ff8b4c5632 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -1,684 +1 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, LabelBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - initializeJoinToCreate, - getChannelConfiguration, - updateChannelConfig, - removeTriggerChannel, - hasManageGuildPermission, - logConfigurationChange, - getConfiguration -} from '../../services/joinToCreateService.js'; - -export default { - data: new SlashCommandBuilder() - .setName("jointocreate") - .setDescription("Manage Join to Create voice channels system.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new Join to Create voice channel.") - .addChannelOption((option) => - option - .setName("category") - .setDescription("Category to create the channel in.") - .addChannelTypes(ChannelType.GuildCategory) - ) - .addStringOption((option) => - option - .setName("channel_name") - .setDescription("Select a template for naming temporary voice channels.") - .addChoices( - { name: "{username}'s Room (Default)", value: "{username}'s Room" }, - { name: "{username}'s Channel", value: "{username}'s Channel" }, - { name: "{username}'s Lounge", value: "{username}'s Lounge" }, - { name: "{username}'s Space", value: "{username}'s Space" }, - { name: "{displayName}'s Room", value: "{displayName}'s Room" }, - { name: "{username}'s VC", value: "{username}'s VC" }, - { name: "{username}'s Music Room", value: "{username}'s Music Room" }, - { name: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { name: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { name: "{username}'s Private Room", value: "{username}'s Private Room" } - ) - ) - .addIntegerOption((option) => - option - .setName("user_limit") - .setDescription("Maximum number of users in temporary channels. (0 = unlimited)") - ) - .addIntegerOption((option) => - option - .setName("bitrate") - .setDescription("Bitrate for temporary channels in kbps (8-96).") - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Configure an existing Join to Create system.") - .addChannelOption((option) => - option - .setName("trigger_channel") - .setDescription("The Join to Create trigger channel to configure.") - .setRequired(true) - .addChannelTypes(ChannelType.GuildVoice) - ) - ), - category: "utility", - - async execute(interaction, config, client) { - try { - - if (!hasManageGuildPermission(interaction.member)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - 'You need **Manage Server** permission to use this command.' - ); - } - - const subcommand = interaction.options.getSubcommand(); - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - let responseEmbed; - - if (subcommand === "setup") { - await handleSetupSubcommand(interaction, client); - return; - } else if (subcommand === "dashboard") { - await handleConfigSubcommand(interaction, client); - return; - } - - } catch (error) { - try { - let errorMessage = 'An error occurred while executing this command.'; - - if (error instanceof TitanBotError) { - errorMessage = error.userMessage || 'An error occurred. Please try again.'; - logger.debug(`TitanBotError [${error.type}]: ${error.message}`, error.context || {}); - } else { - logger.error('Unexpected error in jointocreate command:', error); - errorMessage = 'An unexpected error occurred. Please try again or contact support.'; - } - - return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: errorMessage }); - } catch (replyError) { - logger.error('Failed to send error message:', replyError); - } - } - } -}; - -async function handleSetupSubcommand(interaction, client) { - try { - const category = interaction.options.getChannel('category'); - const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; - const userLimit = interaction.options.getInteger('user_limit') || 0; - const bitrate = interaction.options.getInteger('bitrate') || 64; - const guildId = interaction.guild.id; - - logger.debug(`Setting up Join to Create in guild ${guildId} with template: ${nameTemplate}`); - - const existingConfig = await getConfiguration(client, guildId); - - if (Array.isArray(existingConfig.triggerChannels) && existingConfig.triggerChannels.length > 0) { - const activeTriggerChannels = []; - const staleTriggerChannelIds = []; - - for (const existingChannelId of existingConfig.triggerChannels) { - const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); - if (existingChannel) { - activeTriggerChannels.push(existingChannel); - } else { - staleTriggerChannelIds.push(existingChannelId); - } - } - - if (staleTriggerChannelIds.length > 0) { - for (const staleChannelId of staleTriggerChannelIds) { - logger.info(`Cleaning up stale JTC trigger ${staleChannelId} from guild ${guildId}`); - await removeTriggerChannel(client, guildId, staleChannelId); - } - } - - if (activeTriggerChannels.length > 0) { - const primaryTrigger = activeTriggerChannels[0]; - const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; - - throw new TitanBotError( - 'Guild already has a Join to Create channel', - ErrorTypes.VALIDATION, - errorMessage, - { - guildId, - activeTriggerCount: activeTriggerChannels.length, - expected: true, - suppressErrorLog: true - } - ); - } - } - - logger.debug('Creating Join to Create trigger channel...'); - let triggerChannel = await interaction.guild.channels.create({ - name: 'Join to Create', - type: ChannelType.GuildVoice, - parent: category?.id, - userLimit: 0, - bitrate: 64000, - permissionOverwrites: [ - { - id: interaction.guild.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - ], - }); - - logger.debug(`Created trigger channel ${triggerChannel.id}, initializing config...`); - - const config = await initializeJoinToCreate(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); - - await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { - channelId: triggerChannel.id, - nameTemplate, - userLimit, - bitrate - }); - - logger.info(`Successfully created Join to Create system in guild ${guildId}`); - - const responseEmbed = successEmbed( - '✅ Setup Complete', - `Created Join to Create channel: ${triggerChannel}\n\n` + - `**Settings:**\n` + - `• Template: \`${nameTemplate}\`\n` + - `• User Limit: ${userLimit === 0 ? 'Unlimited' : userLimit + ' users'}\n` + - `• Bitrate: ${bitrate} kbps\n` + - `${category ?`• Category: ${category.name}`: '• Category: Root level'}` - ); - - return await InteractionHelper.safeEditReply(interaction, { embeds: [responseEmbed] }); - - } catch (error) { - logger.error('Error in handleSetupSubcommand:', error); - if (error instanceof TitanBotError) { - throw error; - } - throw new TitanBotError( - `Setup failed: ${error.message}`, - ErrorTypes.DISCORD_API, - 'Failed to set up Join to Create system. Please check bot permissions.' - ); - } -} - -async function handleConfigSubcommand(interaction, client) { - try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; - - const currentConfig = await getChannelConfiguration(client, guildId, triggerChannel.id); - const channelConfig = currentConfig.channelConfig || {}; - - const configEmbed = new EmbedBuilder() - .setTitle('Join to Create Configuration') - .setDescription(`Configuration for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: 'Channel Name Template', - value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate || "{username}'s Room"}\``, - inline: false - }, - { - name: 'User Limit', - value: `${(channelConfig.userLimit ?? currentConfig.userLimit ?? 0) === 0 ? 'Unlimited' : (channelConfig.userLimit ?? currentConfig.userLimit ?? 0) + ' users'}`, - inline: true - }, - { - name: 'Bitrate', - value: `${(channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000} kbps`, - inline: true - } - ) - .setFooter({ text: 'Use the buttons below to modify settings • Only one trigger channel is supported per guild' }) - .setTimestamp(); - - const nameButton = new ButtonBuilder() - .setCustomId(`jtc_config_name_${triggerChannel.id}`) - .setLabel('📝 Name Template') - .setStyle(ButtonStyle.Primary); - - const limitButton = new ButtonBuilder() - .setCustomId(`jtc_config_limit_${triggerChannel.id}`) - .setLabel('👥 User Limit') - .setStyle(ButtonStyle.Primary); - - const bitrateButton = new ButtonBuilder() - .setCustomId(`jtc_config_bitrate_${triggerChannel.id}`) - .setLabel('🎵 Bitrate') - .setStyle(ButtonStyle.Primary); - - const deleteButton = new ButtonBuilder() - .setCustomId(`jtc_config_delete_${triggerChannel.id}`) - .setLabel('🗑️ Remove Channel') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [configEmbed], - components: [row] - }); - - const message = await interaction.fetchReply(); - - if (!message || typeof message.createMessageComponentCollector !== 'function') { - throw new TitanBotError( - 'Failed to fetch interaction reply for collector setup', - ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' - ); - } - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000 - }); - - collector.on('collect', async (buttonInteraction) => { - try { - - if (!hasManageGuildPermission(buttonInteraction.member)) { - await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to use these controls.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const customId = buttonInteraction.customId; - - if (customId.includes('jtc_config_name_')) { - await handleNameTemplateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_limit_')) { - await handleUserLimitModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_bitrate_')) { - await handleBitrateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_delete_')) { - await handleChannelDeletion(buttonInteraction, triggerChannel, currentConfig, client); - } - } catch (error) { - const userMessage = error instanceof TitanBotError - ? error.userMessage || 'An error occurred.' - : 'An error occurred while processing your request.'; - - if (error instanceof TitanBotError) { - logger.debug(`Button interaction validation error: ${error.message}`, error.context || {}); - } else { - logger.error('Unexpected error in config button interaction:', error); - } - - await buttonInteraction.reply({ - content: `❌ ${userMessage}`, - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); - - collector.on('end', () => { - const disabledRow = new ActionRowBuilder().addComponents( - nameButton.setDisabled(true), - limitButton.setDisabled(true), - bitrateButton.setDisabled(true), - deleteButton.setDisabled(true) - ); - - message.edit({ - components: [disabledRow], - embeds: [configEmbed.setFooter({ text: 'Configuration session expired. Run the command again to make changes.' })] - }).catch(() => {}); - }); - - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - throw new TitanBotError( - `Config failed: ${error.message}`, - ErrorTypes.DATABASE, - 'Failed to load configuration.' - ); - } -} - -async function handleNameTemplateModal(interaction, triggerChannel, currentConfig, client) { - try { - const TEMPLATE_OPTIONS = [ - { label: "{username}'s Room (Default)", value: "{username}'s Room" }, - { label: "{username}'s Channel", value: "{username}'s Channel" }, - { label: "{username}'s Lounge", value: "{username}'s Lounge" }, - { label: "{username}'s Space", value: "{username}'s Space" }, - { label: "{displayName}'s Room", value: "{displayName}'s Room" }, - { label: "{username}'s VC", value: "{username}'s VC" }, - { label: "{username}'s Music Room", value: "{username}'s Music Room" }, - { label: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { label: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { label: "{username}'s Private Room", value: "{username}'s Private Room" }, - ]; - - const currentTemplate = currentConfig.channelConfig?.nameTemplate - || currentConfig.channelNameTemplate - || "{username}'s Room"; - - const templateSelect = new StringSelectMenuBuilder() - .setCustomId('template') - .setPlaceholder('Pick a name template...') - .setOptions( - TEMPLATE_OPTIONS.map(o => ({ - label: o.label, - value: o.value, - default: o.value === currentTemplate, - })), - ); - - const templateLabel = new LabelBuilder() - .setLabel('Channel name template') - .setStringSelectMenuComponent(templateSelect); - - const modal = new ModalBuilder() - .setCustomId(`jtc_name_modal_${triggerChannel.id}`) - .setTitle('Channel Name Template') - .addLabelComponents(templateLabel); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_name_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const [newTemplate] = modalSubmission.fields.getStringSelectValues('template'); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - nameTemplate: newTemplate - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated channel name template', { - channelId: triggerChannel.id, - newTemplate - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Channel name template changed to \`${newTemplate}\``)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in name template modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the template.' - ); - } -} - -async function handleUserLimitModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentLimit = currentConfig.channelConfig?.userLimit ?? currentConfig.userLimit ?? 0; - - const modal = new ModalBuilder() - .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) - .setTitle('Configure User Limit') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('user_limit') - .setLabel('Enter user limit (0-99, 0 = unlimited)') - .setPlaceholder('Enter a number between 0 and 99') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(2) - .setValue(currentLimit.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_limit_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const userInput = modalSubmission.fields.getTextInputValue('user_limit').trim(); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - userLimit: parseInt(userInput) - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated user limit', { - channelId: triggerChannel.id, - userLimit: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `User limit changed to ${parseInt(userInput) === 0 ? 'Unlimited' : parseInt(userInput) + ' users'}`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in user limit modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the user limit.' - ); - } -} - -async function handleBitrateModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentBitrate = ((currentConfig.channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000); - - const modal = new ModalBuilder() - .setCustomId(`jtc_bitrate_modal_${triggerChannel.id}`) - .setTitle('Configure Bitrate') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('bitrate') - .setLabel('Enter bitrate in kbps (8-384)') - .setPlaceholder('Enter a number between 8 and 384') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(3) - .setValue(currentBitrate.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_bitrate_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - if (!hasManageGuildPermission(modalSubmission.member)) { - await modalSubmission.reply({ - content: '❌ You need **Manage Server** permission to modify these settings.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const userInput = modalSubmission.fields.getTextInputValue('bitrate').trim(); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - bitrate: parseInt(userInput) * 1000 - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated bitrate', { - channelId: triggerChannel.id, - bitrate: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Bitrate changed to ${parseInt(userInput)} kbps`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in bitrate modal:', error); - throw new TitanBotError( - `Modal error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while updating the bitrate.' - ); - } -} - -async function handleChannelDeletion(interaction, triggerChannel, currentConfig, client) { - try { - const confirmRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`jtc_delete_confirm_${triggerChannel.id}`) - .setLabel('🗑️ Yes, Delete') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`jtc_delete_cancel_${triggerChannel.id}`) - .setLabel('❌ Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [warningEmbed('Confirm Deletion', `Are you sure you want to remove **${triggerChannel.name}** from the Join to Create system?\n\nThis action cannot be undone.`)], - components: [confirmRow], - flags: MessageFlags.Ephemeral - }); - - const message = await interaction.fetchReply(); - const deleteCollector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: (i) => i.user.id === interaction.user.id && - (i.customId === `jtc_delete_confirm_${triggerChannel.id}` || - i.customId === `jtc_delete_cancel_${triggerChannel.id}`), - time: 600_000, - max: 1 - }); - - deleteCollector.on('collect', async (buttonInteraction) => { - try { - - if (!hasManageGuildPermission(buttonInteraction.member)) { - await buttonInteraction.reply({ - content: '❌ You need **Manage Server** permission to remove channels.', - flags: MessageFlags.Ephemeral - }); - return; - } - - if (buttonInteraction.customId === `jtc_delete_confirm_${triggerChannel.id}`) { - - await removeTriggerChannel(client, interaction.guild.id, triggerChannel.id); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Removed Join to Create trigger', { - channelId: triggerChannel.id, - channelName: triggerChannel.name - }); - - try { - if (triggerChannel.members.size === 0) { - await triggerChannel.delete('Join to Create trigger removed by administrator'); - } - } catch (deleteError) { - logger.warn(`Could not delete channel ${triggerChannel.id}: ${deleteError.message}`); - - } - - await buttonInteraction.update({ - embeds: [successEmbed('Removed', `**${triggerChannel.name}** has been removed from the Join to Create system.`)], - components: [] - }); - - } else { - await buttonInteraction.update({ - embeds: [successEmbed('Cancelled', 'Channel removal has been cancelled.')], - components: [] - }); - } - } catch (collectError) { - logger.error('Error handling delete confirmation:', collectError); - await buttonInteraction.reply({ - content: '❌ An error occurred while processing your request.', - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); - - deleteCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - message.edit({ components: [] }).catch(() => {}); - } - }); - - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - logger.error('Unexpected error in handleChannelDeletion:', error); - throw new TitanBotError( - `Deletion error: ${error.message}`, - ErrorTypes.UNKNOWN, - 'An error occurred while removing the channel.' - ); - } -} \ No newline at end of file +export default {}; diff --git a/src/commands/Music/join.js b/src/commands/Music/join.js index 10e6174103..ff8b4c5632 100644 --- a/src/commands/Music/join.js +++ b/src/commands/Music/join.js @@ -1,21 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Join your voice channel without starting playback'), - - async execute(interaction, config, client) { - const deferred = await deferMusicCommand(interaction); - if (!deferred) { - return; - } - - const embed = await joinVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - }, -}; +export default {}; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js index fa8d7cfef6..ff8b4c5632 100644 --- a/src/commands/Music/music.js +++ b/src/commands/Music/music.js @@ -1,188 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - skipTrack, - stopPlayback, - pausePlayback, - resumePlayback, - shuffleQueue, - setLoopMode, - setVolume, - seekTrack, - removeFromQueue, - moveInQueue, - clearQueue, - setTwentyFourSeven, - leaveVoiceChannel, - replyMusicSuccess, -} from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('music') - .setDescription('Manage playback, queue, and voice session settings') - .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), - ) - .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), - ) - .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), - ) - .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), - ) - .addSubcommand((sub) => - sub - .setName('loop') - .setDescription('Set loop mode') - .addStringOption((opt) => - opt - .setName('mode') - .setDescription('Loop mode') - .setRequired(true) - .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, - ), - ), - ) - .addSubcommand((sub) => - sub - .setName('volume') - .setDescription('Set playback volume') - .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), - ), - ) - .addSubcommand((sub) => - sub - .setName('seek') - .setDescription('Seek to a position in the current track') - .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), - ), - ) - .addSubcommand((sub) => - sub - .setName('remove') - .setDescription('Remove a track from the queue') - .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub - .setName('move') - .setDescription('Move a track in the queue') - .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), - ) - .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), - ) - .addSubcommand((sub) => - sub - .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') - .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), - ), - ), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'pause': { - const embed = await pausePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'resume': { - const embed = await resumePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'skip': { - const embed = await skipTrack(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'stop': { - const embed = await stopPlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'shuffle': { - const embed = await shuffleQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'seek': { - const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'remove': { - const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'move': { - const embed = await moveInQueue( - client, - interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), - ); - await replyMusicSuccess(interaction, embed); - break; - } - case 'clear': { - const embed = await clearQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'leave': { - const embed = await leaveVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); - await replyMusicSuccess(interaction, embed); - break; - } - default: - await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', - }); - } - }, -}; +export default {}; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.js index e50c685122..ff8b4c5632 100644 --- a/src/commands/Music/nowplaying.js +++ b/src/commands/Music/nowplaying.js @@ -1,17 +1 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildNowPlayingReply } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('nowplaying') - .setDescription('Show the currently playing track'), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); - await InteractionHelper.safeEditReply(interaction, payload); - }, -}; +export default {}; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index 1d07665382..ff8b4c5632 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -1,24 +1 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song or add it to the queue') - .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), - ), - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const result = await playQuery(client, interaction, interaction.options.getString('query')); - await replyMusicSuccess(interaction, result.embed); - }, -}; +export default {}; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js index 66faa4eb9d..ff8b4c5632 100644 --- a/src/commands/Music/queue.js +++ b/src/commands/Music/queue.js @@ -1,24 +1 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildQueueReply } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('queue') - .setDescription('Show the current music queue') - .addIntegerOption((opt) => - opt.setName('page').setDescription('Page number').setMinValue(1), - ), - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); - await InteractionHelper.safeEditReply(interaction, { - embeds: payload.embeds, - components: payload.components, - }); - }, -}; +export default {}; diff --git a/src/config/application.js b/src/config/application.js index 159b9eb295..1a8ffc4506 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -1,87 +1,16 @@ -import { fileURLToPath } from "url"; -import path from "path"; -import botConfig, { validateConfig } from "./bot.js"; -import { shopConfig as shop } from "./shop/index.js"; -import { pgConfig } from "./database/postgres.js"; +import { fileURLToPath } from 'url'; +import path from 'path'; +import botConfig from './bot.js'; +import { pgConfig } from './database/postgres.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const appConfig = { - paths: { - root: path.join(__dirname, "../.."), - commands: path.join(__dirname, "../commands"), - events: path.join(__dirname, "../events"), - config: __dirname, - utils: path.join(__dirname, "../utils"), - services: path.join(__dirname, "../services"), - handlers: path.join(__dirname, "../handlers"), - interactions: path.join(__dirname, "../interactions"), - }, - - bot: { - ...botConfig, - token: process.env.DISCORD_TOKEN || process.env.TOKEN, - clientId: process.env.CLIENT_ID, - // Retained for tutorial/setup compatibility; not used for command registration. - guildId: process.env.GUILD_ID, - - shop: { - ...botConfig.shop, - ...shop, - }, - }, - - // PostgreSQL configuration - Primary production database - postgresql: { - ...pgConfig, - }, - - logging: { - level: process.env.LOG_LEVEL || "info", - file: { - enabled: process.env.LOG_TO_FILE === "true", - path: path.join(__dirname, "../../logs"), - maxSize: "20m", - maxFiles: "14d", - zippedArchive: true, - }, - console: { - enabled: true, - colorize: true, - timestamp: true, - }, - sentry: { - enabled: process.env.SENTRY_DSN ? true : false, - dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV || "development", - }, - }, - - api: { - port: process.env.PORT || 3000, - cors: { - origin: process.env.CORS_ORIGIN?.split(",") || "*", - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], - }, - rateLimit: { - windowMs: 15 * 60 * 1000, - max: 100, - }, - }, - - shop, - - features: { - ...botConfig.features, - music: botConfig.features?.music ?? true, - }, - - env: process.env.NODE_ENV || "development", - isProduction: process.env.NODE_ENV === "production", - isDevelopment: process.env.NODE_ENV !== "production", + paths: { root: path.join(__dirname, '../..'), commands: path.join(__dirname, '../commands'), events: path.join(__dirname, '../events'), config: __dirname, utils: path.join(__dirname, '../utils'), services: path.join(__dirname, '../services'), handlers: path.join(__dirname, '../handlers'), interactions: path.join(__dirname, '../interactions') }, + bot: { ...botConfig, token: process.env.DISCORD_TOKEN || process.env.TOKEN, clientId: process.env.CLIENT_ID, guildId: process.env.GUILD_ID }, + postgresql: { ...pgConfig }, + logging: { level: process.env.LOG_LEVEL || 'info', file: { enabled: process.env.LOG_TO_FILE === 'true', path: path.join(__dirname, '../../logs'), maxSize: '20m', maxFiles: '14d', zippedArchive: true }, console: { enabled: true, colorize: true, timestamp: true }, sentry: { enabled: !!process.env.SENTRY_DSN, dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || 'development' } }, + api: { port: process.env.PORT || 3000, cors: { origin: process.env.CORS_ORIGIN?.split(',') || '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] }, rateLimit: { windowMs: 15 * 60 * 1000, max: 100 } }, + features: { ...botConfig.features }, env: process.env.NODE_ENV || 'development', isProduction: process.env.NODE_ENV === 'production', isDevelopment: process.env.NODE_ENV !== 'production', }; - Object.freeze(appConfig); - -export default appConfig; \ No newline at end of file +export default appConfig; diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..ed251f0b8d 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -1,485 +1,57 @@ import { logger } from '../utils/logger.js'; export const botConfig = { - // ========================= - // BOT PRESENCE (what users see under the bot name) - // ========================= - // `status` options: - // - "online" = green dot - // - "idle" = yellow moon - // - "dnd" = red do-not-disturb - // - "invisible" = appears offline - presence: { - // Current online state shown on Discord. - status: "online", - - // Activity lines shown under the bot name. - // `type` number mapping from Discord: - // 0 = Playing - // 1 = Streaming - // 2 = Listening - // 3 = Watching - // 4 = Custom - // 5 = Competing - activities: [ - { - name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see - type: 4, // Custom - }, - ], - }, - - // ========================= - // COMMAND BEHAVIOR - // ========================= + presence: { status: 'online', activities: [{ name: 'Custom Status', state: 'Milano Roleplay', type: 4 }] }, commands: { - // Bot owner user IDs (comma-separated in OWNER_IDS env var). - // Owners can access owner/admin-level bot commands. - owners: process.env.OWNER_IDS?.split(",").map((id) => id.trim()).filter(Boolean) || [], - - // Default wait time between command uses (in seconds). - defaultCooldown: 3, - - // If true, old commands are removed before re-registering. - deleteCommands: false, - - // Optional server ID retained for tutorial compatibility; not used for command registration. - testGuildId: process.env.TEST_GUILD_ID, - - // When true (or MAINTENANCE_MODE=true), only bot owners can run commands. - maintenanceMode: process.env.MAINTENANCE_MODE === "true", - - // Command prefix for text-based commands (e.g., "!" for "!ping"). - // Supports both slash commands and prefix commands. - prefix: process.env.PREFIX || "!", + owners: process.env.OWNER_IDS?.split(',').map((id) => id.trim()).filter(Boolean) || [], + defaultCooldown: 3, deleteCommands: false, testGuildId: process.env.TEST_GUILD_ID, + maintenanceMode: process.env.MAINTENANCE_MODE === 'true', prefix: process.env.PREFIX || '!', }, - - // ========================= - // APPLICATIONS SYSTEM - // ========================= applications: { - // Default questions shown when someone fills out an application. - defaultQuestions: [ - { question: "What is your name?", required: true }, - { question: "How old are you?", required: true }, - { question: "Why do you want to join?", required: true }, - ], - - // Embed colors by application status. - statusColors: { - pending: "#FFA500", - approved: "#00FF00", - denied: "#FF0000", - }, - - // How long users must wait before submitting another application (hours). - applicationCooldown: 24, - - // Auto-delete denied applications after this many days. - deleteDeniedAfter: 7, - - // Auto-delete approved applications after this many days. - deleteApprovedAfter: 30, - - // Role IDs allowed to manage applications. - managerRoles: [], // Will be populated from environment or database + defaultQuestions: [{ question: 'What is your name?', required: true }, { question: 'How old are you?', required: true }, { question: 'Why do you want to join?', required: true }], + statusColors: { pending: '#FFA500', approved: '#00FF00', denied: '#FF0000' }, applicationCooldown: 24, + deleteDeniedAfter: 7, deleteApprovedAfter: 30, managerRoles: [], }, - - // ========================= - // EMBED COLORS & BRANDING - // ========================= - // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors embeds: { colors: { - // Main brand colors. - primary: "#336699", - secondary: "#2F3136", - - // Standard status colors for success/error/warning/info messages. - success: "#57F287", - error: "#ED4245", - warning: "#FEE75C", - info: "#3498DB", - - // Neutral utility colors. - light: "#FFFFFF", - dark: "#202225", - gray: "#99AAB5", - - // Discord-style palette shortcuts. - blurple: "#5865F2", - green: "#57F287", - yellow: "#FEE75C", - fuchsia: "#EB459E", - red: "#ED4245", - black: "#000000", - - // Feature-specific colors. - giveaway: { - active: "#57F287", - ended: "#ED4245", - }, - ticket: { - open: "#57F287", - claimed: "#FAA61A", - closed: "#ED4245", - pending: "#99AAB5", - }, - economy: "#F1C40F", - birthday: "#E91E63", - moderation: "#9B59B6", - - // Ticket priority color mapping. - priority: { - none: "#95A5A6", - low: "#3498db", - medium: "#2ecc71", - high: "#f1c40f", - urgent: "#e74c3c", - }, - }, - footer: { - // Default footer text used in bot embeds. - text: "Titan Bot", - // Footer icon URL (null = no icon). - icon: null, - }, - // Default thumbnail URL for embeds (null = no thumbnail). - thumbnail: null, - author: { - // Optional default embed author block. - name: null, - icon: null, - url: null, + primary: '#336699', secondary: '#2F3136', success: '#57F287', error: '#ED4245', warning: '#FEE75C', info: '#3498DB', + light: '#FFFFFF', dark: '#202225', gray: '#99AAB5', blurple: '#5865F2', green: '#57F287', yellow: '#FEE75C', + fuchsia: '#EB459E', red: '#ED4245', black: '#000000', giveaway: { active: '#57F287', ended: '#ED4245' }, + ticket: { open: '#57F287', claimed: '#FAA61A', closed: '#ED4245', pending: '#99AAB5' }, birthday: '#E91E63', moderation: '#9B59B6', + priority: { none: '#95A5A6', low: '#3498db', medium: '#2ecc71', high: '#f1c40f', urgent: '#e74c3c' }, }, + footer: { text: 'Titan Bot', icon: null }, thumbnail: null, author: { name: null, icon: null, url: null }, }, - - // ========================= - // ECONOMY SETTINGS - // ========================= - economy: { - currency: { - // Currency display name. - name: "coins", - // Plural display name. - namePlural: "coins", - // Currency symbol shown in balances. - symbol: "$", - }, - - // Starting balance for new users. - startingBalance: 0, - - // Maximum bank amount before upgrades (if upgrades are used). - baseBankCapacity: 100000, - - // Daily reward amount. - dailyAmount: 100, - - // Work command random payout range. - workMin: 10, - workMax: 100, - - // Beg command random payout range. - begMin: 5, - begMax: 50, - - // Command cooldowns (milliseconds). - cooldowns: { - daily: 24 * 60 * 60 * 1000, - work: 60 * 60 * 1000, - crime: 2 * 60 * 60 * 1000, - rob: 4 * 60 * 60 * 1000, - }, - - // Chance to succeed when robbing (0.4 = 40%). - robSuccessRate: 0.4, - - // Jail time after failed rob (milliseconds). - // 3600000 = 1 hour. - robFailJailTime: 3600000, - }, - - // ========================= - // SHOP SETTINGS - // ========================= - // Add shop defaults here when needed. - shop: { - - }, - - // ========================= - // TICKET SYSTEM - // ========================= tickets: { - // Category ID where new tickets are created (null = no forced category). - defaultCategory: null, - - // Role IDs allowed to manage/support tickets. - supportRoles: [], - - // Priority options users/staff can assign. - priorities: { - none: { - emoji: "⚪", - color: "#95A5A6", - label: "None", - }, - low: { - emoji: "🟢", - color: "#2ECC71", - label: "Low", - }, - medium: { - emoji: "🟡", - color: "#F1C40F", - label: "Medium", - }, - high: { - emoji: "🔴", - color: "#E74C3C", - label: "High", - }, - urgent: { - emoji: "🚨", - color: "#E91E63", - label: "Urgent", - }, - }, - - // Default priority for new tickets. - defaultPriority: "none", - - // Category ID where closed tickets are archived. - archiveCategory: null, - - // Channel ID where ticket logs are sent. - logChannel: null, - }, - - // ========================= - // GIVEAWAY SETTINGS - // ========================= - giveaways: { - // Default giveaway duration in milliseconds. - // 86400000 = 24 hours. - defaultDuration: 86400000, - - // Allowed winner count range. - minimumWinners: 1, - maximumWinners: 10, - - // Allowed giveaway duration range in milliseconds. - // 300000 = 5 minutes. - minimumDuration: 300000, - // 2592000000 = 30 days. - maximumDuration: 2592000000, - - // Role IDs allowed to host giveaways. - allowedRoles: [], - - // Role IDs that bypass giveaway restrictions. - bypassRoles: [], + defaultCategory: null, supportRoles: [], + priorities: { none: { emoji: '⚪', color: '#95A5A6', label: 'None' }, low: { emoji: '🟢', color: '#2ECC71', label: 'Low' }, medium: { emoji: '🟡', color: '#F1C40F', label: 'Medium' }, high: { emoji: '🔴', color: '#E74C3C', label: 'High' }, urgent: { emoji: '🚨', color: '#E91E63', label: 'Urgent' } }, + defaultPriority: 'none', archiveCategory: null, logChannel: null, }, - - // ========================= - // BIRTHDAY SETTINGS - // ========================= - birthday: { - // Role ID given to users on their birthday. - defaultRole: null, - - // Channel ID where birthday announcements are posted. - announcementChannel: null, - - // Timezone used to calculate birthday dates. - timezone: "UTC", - }, - - // ========================= - // VERIFICATION SETTINGS - // ========================= + giveaways: { defaultDuration: 86400000, minimumWinners: 1, maximumWinners: 10, minimumDuration: 300000, maximumDuration: 2592000000, allowedRoles: [], bypassRoles: [] }, + birthday: { defaultRole: null, announcementChannel: null, timezone: 'UTC' }, verification: { - // Message shown when posting the verification panel. - defaultMessage: "Click the button below to verify yourself and gain access to the server!", - - // Text on the verification button. - defaultButtonText: "Verify", - - // Automatic verification behavior. - autoVerify: { - // How automatic verification decides who is auto-approved: - // - "none" = everyone is auto-verified immediately - // - "account_age" = account must be older than set days - // - "server_size" = auto-verify everyone only in smaller servers - defaultCriteria: "none", - - // Days used when `defaultCriteria` is `account_age`. - defaultAccountAgeDays: 7, - - // Member count threshold used when `defaultCriteria` is `server_size`. - // Example: 1000 means auto-verify if server has fewer than 1000 members. - serverSizeThreshold: 1000, - - // Allowed safety limits for account-age requirements. - // 1 = minimum day, 365 = maximum days. - minAccountAge: 1, - maxAccountAge: 365, - - // If true, user receives a DM after verification. - sendDMNotification: true, - - // Human-readable descriptions for each criteria mode. - criteria: { - account_age: "Account must be older than specified days", - server_size: "All users if server has less than 1000 members", - none: "All users immediately" - } - }, - - // Minimum time between verification attempts (milliseconds). - // 5000 = 5 seconds. - verificationCooldown: 5000, - - // Maximum failed attempts allowed inside the time window below. - maxVerificationAttempts: 3, - - // Time window for counting attempts (milliseconds). - // 60000 = 1 minute. - attemptWindow: 60000, - - // In-memory safety limits (helps avoid unbounded memory growth). - maxCooldownEntries: 10000, - maxAttemptEntries: 10000, - // Cleanup frequency for cooldown/attempt maps (milliseconds). - // 300000 = 5 minutes. - cooldownCleanupInterval: 300000, - // Maximum metadata payload size for audit entries (bytes). - maxAuditMetadataBytes: 4096, - // Maximum number of audit entries kept in memory. - maxInMemoryAuditEntries: 1000, - // If true, log every verification action. - logAllVerifications: true, - // If true, preserve verification audit history. - keepAuditTrail: true, - }, - - // ========================= - // WELCOME / GOODBYE MESSAGES - // ========================= - welcome: { - // Welcome template posted when a user joins. - // Placeholders: {user}, {server}, {memberCount} - defaultWelcomeMessage: - "Welcome {user} to {server}! We now have {memberCount} members!", - // Goodbye template posted when a user leaves. - // Placeholders: {user}, {memberCount} - defaultGoodbyeMessage: - "{user} has left the server. We now have {memberCount} members.", - // Channel ID for welcome messages. - defaultWelcomeChannel: null, - // Channel ID for goodbye messages. - defaultGoodbyeChannel: null, + defaultMessage: 'Click the button below to verify yourself and gain access to the server!', defaultButtonText: 'Verify', + autoVerify: { defaultCriteria: 'none', defaultAccountAgeDays: 7, serverSizeThreshold: 1000, minAccountAge: 1, maxAccountAge: 365, sendDMNotification: true, criteria: { account_age: 'Account must be older than specified days', server_size: 'All users if server has less than 1000 members', none: 'All users immediately' } }, + verificationCooldown: 5000, maxVerificationAttempts: 3, attemptWindow: 60000, maxCooldownEntries: 10000, maxAttemptEntries: 10000, + cooldownCleanupInterval: 300000, maxAuditMetadataBytes: 4096, maxInMemoryAuditEntries: 1000, logAllVerifications: true, keepAuditTrail: true, }, - - // ========================= - // COUNTER CHANNELS - // ========================= + welcome: { defaultWelcomeMessage: 'Welcome {user} to {server}! We now have {memberCount} members!', defaultGoodbyeMessage: '{user} has left the server. We now have {memberCount} members.', defaultWelcomeChannel: null, defaultGoodbyeChannel: null }, counters: { - defaults: { - // Default naming/description templates for counter entries. - name: "{name} Counter", - description: "Server {name} counter", - // Channel type used for counters (typically "voice"). - type: "voice", - // Channel name format. `{count}` is replaced automatically. - channelName: "{name}-{count}", - }, - permissions: { - // Default denied permissions for the counter channel. - deny: ["VIEW_CHANNEL"], - // Default allowed permissions for the counter channel. - allow: ["VIEW_CHANNEL", "CONNECT", "SPEAK"], - }, - messages: { - // Default response messages for counter actions. - created: "✅ Created counter **{name}**", - deleted: "🗑️ Deleted counter **{name}**", - updated: "🔄 Updated counter **{name}**", - }, + defaults: { name: '{name} Counter', description: 'Server {name} counter', type: 'voice', channelName: '{name}-{count}' }, + permissions: { deny: ['VIEW_CHANNEL'], allow: ['VIEW_CHANNEL', 'CONNECT', 'SPEAK'] }, + messages: { created: '✅ Created counter **{name}**', deleted: '🗑️ Deleted counter **{name}**', updated: '🔄 Updated counter **{name}**' }, types: { - // Built-in counter types and how each count is calculated. - members: { - name: "👥 Members", - description: "Total members in the server", - getCount: (guild) => guild.memberCount.toString(), - }, - bots: { - name: "🤖 Bots", - description: "Total bot accounts in the server", - getCount: (guild) => - guild.members.cache.filter((m) => m.user.bot).size.toString(), - }, - members_only: { - name: "👤 Humans", - description: "Total human members (non-bots)", - getCount: (guild) => - guild.members.cache.filter((m) => !m.user.bot).size.toString(), - }, + members: { name: '👥 Members', description: 'Total members in the server', getCount: (guild) => guild.memberCount.toString() }, + bots: { name: '🤖 Bots', description: 'Total bot accounts in the server', getCount: (guild) => guild.members.cache.filter((m) => m.user.bot).size.toString() }, + members_only: { name: '👤 Humans', description: 'Total human members (non-bots)', getCount: (guild) => guild.members.cache.filter((m) => !m.user.bot).size.toString() }, }, }, - - // ========================= - // GENERIC BOT MESSAGES - // ========================= - messages: { - noPermission: "You do not have permission to use this command.", - cooldownActive: "Please wait {time} before using this command again.", - errorOccurred: "An error occurred while executing this command.", - missingPermissions: - "I am missing required permissions to perform this action.", - commandDisabled: "This command has been disabled.", - maintenanceMode: "The bot is currently in maintenance mode.", - }, - - // ========================= - // FEATURE TOGGLES - // ========================= - // Set any feature to `false` to disable it globally. - features: { - // Core systems. - economy: true, - leveling: true, - moderation: true, - logging: true, - welcome: true, - - // Community engagement systems. - tickets: true, - giveaways: true, - birthday: true, - counter: true, - - // Security and self-service systems. - verification: true, - reactionRoles: true, - joinToCreate: true, - - // Utility/quality-of-life modules. - voice: true, - search: true, - tools: true, - utility: true, - community: true, - fun: true, - music: true, - }, + messages: { noPermission: 'You do not have permission to use this command.', cooldownActive: 'Please wait {time} before using this command again.', errorOccurred: 'An error occurred while executing this command.', missingPermissions: 'I am missing required permissions to perform this action.', commandDisabled: 'This command has been disabled.', maintenanceMode: 'The bot is currently in maintenance mode.' }, + features: { leveling: true, moderation: true, logging: true, welcome: true, tickets: true, giveaways: true, birthday: true, counter: true, verification: true, reactionRoles: true, search: true, tools: true, utility: true, community: true }, }; -export function validateConfig(config) { +export function validateConfig() { const errors = []; - if (process.env.NODE_ENV !== 'production') { logger.debug('Environment variables check:'); logger.debug('DISCORD_TOKEN exists:', !!process.env.DISCORD_TOKEN); @@ -489,163 +61,29 @@ export function validateConfig(config) { logger.debug('POSTGRES_HOST exists:', !!process.env.POSTGRES_HOST); logger.debug('NODE_ENV:', process.env.NODE_ENV); } - - if (!process.env.DISCORD_TOKEN && !process.env.TOKEN) { - errors.push("Bot token is required (DISCORD_TOKEN or TOKEN environment variable)"); - } - - if (!process.env.CLIENT_ID) { - errors.push("Client ID is required (CLIENT_ID environment variable)"); + if (!process.env.DISCORD_TOKEN && !process.env.TOKEN) errors.push('Bot token is required (DISCORD_TOKEN or TOKEN environment variable)'); + if (!process.env.CLIENT_ID) errors.push('Client ID is required (CLIENT_ID environment variable)'); + if (process.env.NODE_ENV === 'production' && !process.env.POSTGRES_URL && !process.env.DATABASE_URL) { + if (!process.env.POSTGRES_HOST) errors.push('PostgreSQL connection is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_HOST)'); + if (!process.env.POSTGRES_USER) errors.push('PostgreSQL user is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_USER)'); + if (!process.env.POSTGRES_PASSWORD) errors.push('PostgreSQL password is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_PASSWORD)'); } - - if (process.env.NODE_ENV === 'production') { - // A full connection URL (DATABASE_URL / POSTGRES_URL) satisfies all Postgres - // requirements, matching how src/config/database/postgres.js resolves the pool config. - const hasConnectionUrl = Boolean(process.env.POSTGRES_URL || process.env.DATABASE_URL); - - if (!hasConnectionUrl) { - if (!process.env.POSTGRES_HOST) { - errors.push("PostgreSQL connection is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_HOST)"); - } - if (!process.env.POSTGRES_USER) { - errors.push("PostgreSQL user is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_USER)"); - } - if (!process.env.POSTGRES_PASSWORD) { - errors.push("PostgreSQL password is required in production (set DATABASE_URL/POSTGRES_URL, or POSTGRES_PASSWORD)"); - } - } - } - return errors; } - const configErrors = validateConfig(botConfig); -if (configErrors.length > 0) { - logger.error("Bot configuration errors:", configErrors.join("\n")); - if (process.env.NODE_ENV === "production") { - process.exit(1); - } -} - +if (configErrors.length && process.env.NODE_ENV === 'production') { logger.error('Bot configuration errors:', errors.join('\n')); process.exit(1); } export const BotConfig = botConfig; - -const COMMAND_CATEGORY_FEATURE_MAP = { - birthday: "birthday", - community: "community", - economy: "economy", - fun: "fun", - giveaway: "giveaways", - jointocreate: "joinToCreate", - leveling: "leveling", - logging: "logging", - moderation: "moderation", - music: "music", - reaction_roles: "reactionRoles", - search: "search", - serverstats: "counter", - ticket: "tickets", - tools: "tools", - utility: "utility", - verification: "verification", - welcome: "welcome", -}; - -function normalizeCategoryKey(category) { - return String(category || "").trim().toLowerCase().replace(/\s+/g, "_"); -} - -export function getCommandPrefix() { - return botConfig.commands?.prefix ?? "!"; -} - -export function getBotOwners() { - return (botConfig.commands?.owners ?? []) - .map((id) => String(id).trim()) - .filter(Boolean); -} - -export function isBotOwner(userId) { - if (!userId) { - return false; - } - - return getBotOwners().includes(String(userId)); -} - -export function isMaintenanceMode() { - return botConfig.commands?.maintenanceMode === true; -} - -export function getBotMessage(key, replacements = {}) { - let message = botConfig.messages?.[key] || key; - - for (const [placeholder, value] of Object.entries(replacements)) { - message = message.replace(new RegExp(`\\{${placeholder}\\}`, "g"), String(value)); - } - - return message; -} - -export function isFeatureEnabled(featureKey) { - if (!featureKey) { - return true; - } - - return botConfig.features?.[featureKey] !== false; -} - -export function isCommandCategoryEnabled(category) { - const normalized = normalizeCategoryKey(category); - - if (!normalized || normalized === "core") { - return true; - } - - const featureKey = COMMAND_CATEGORY_FEATURE_MAP[normalized]; - if (!featureKey) { - return true; - } - - return isFeatureEnabled(featureKey); -} - -export function getApplicationStatusColor(status) { - const colors = botConfig.applications?.statusColors || {}; - const hex = colors[status]; - return hex ? getColor(hex) : getColor(status === "approved" ? "success" : status === "denied" ? "error" : "warning"); -} - -export function getDefaultApplicationQuestions() { - return (botConfig.applications?.defaultQuestions || []).map((entry) => - typeof entry === "string" ? entry : entry.question, - ).filter(Boolean); -} - -export function getColor(path, fallback = "#99AAB5") { - - if (typeof path === "number") return path; - if (typeof path === "string" && path.startsWith("#")) { - - return parseInt(path.replace("#", ""), 16); - } - const result = path - .split(".") - .reduce( - (obj, key) => (obj && obj[key] !== undefined ? obj[key] : fallback), - botConfig.embeds.colors, - ); - - if (typeof result === "string" && result.startsWith("#")) { - return parseInt(result.replace("#", ""), 16); - } - return result; -} - -export function getRandomColor() { - const colors = Object.values(botConfig.embeds.colors).flatMap((color) => - typeof color === "string" ? color : Object.values(color), - ); - return colors[Math.floor(Math.random() * colors.length)]; -} - -export default botConfig; \ No newline at end of file +const COMMAND_CATEGORY_FEATURE_MAP = { birthday: 'birthday', community: 'community', giveaway: 'giveaways', leveling: 'leveling', logging: 'logging', moderation: 'moderation', reaction_roles: 'reactionRoles', search: 'search', serverstats: 'counter', ticket: 'tickets', tools: 'tools', utility: 'utility', verification: 'verification', welcome: 'welcome' }; +function normalizeCategoryKey(category) { return String(category || '').trim().toLowerCase().replace(/\s+/g, '_'); } +export function getCommandPrefix() { return botConfig.commands?.prefix ?? '!'; } +export function getBotOwners() { return (botConfig.commands?.owners ?? []).map((id) => String(id).trim()).filter(Boolean); } +export function isBotOwner(userId) { return !!userId && getBotOwners().includes(String(userId)); } +export function isMaintenanceMode() { return botConfig.commands?.maintenanceMode === true; } +export function getBotMessage(key, replacements = {}) { let message = botConfig.messages?.[key] || key; for (const [keyName, value] of Object.entries(replacements)) message = message.replace(new RegExp(`\\{${keyName}\\}`, 'g'), String(value)); return message; } +export function isFeatureEnabled(key) { return !key || botConfig.features?.[key] !== false; } +export function isCommandCategoryEnabled(category) { const normalized = normalizeCategoryKey(category); if (!normalized || normalized === 'core') return true; const feature = COMMAND_CATEGORY_FEATURE_MAP[normalized]; return !feature || isFeatureEnabled(feature); } +export function getApplicationStatusColor(status) { const hex = botConfig.applications?.statusColors?.[status]; return hex ? getColor(hex) : getColor(status === 'approved' ? 'success' : status === 'denied' ? 'error' : 'warning'); } +export function getDefaultApplicationQuestions() { return (botConfig.applications?.defaultQuestions || []).map((item) => typeof item === 'string' ? item : item.question).filter(Boolean); } +export function getColor(path, fallback = '#99AAB5') { if (typeof path === 'number') return path; if (typeof path === 'string' && path.startsWith('#')) return parseInt(path.slice(1), 16); const result = String(path).split('.').reduce((value, key) => value?.[key] ?? fallback, botConfig.embeds.colors); return typeof result === 'string' && result.startsWith('#') ? parseInt(result.slice(1), 16) : result; } +export function getRandomColor() { const colors = Object.values(botConfig.embeds.colors).flatMap((color) => typeof color === 'string' ? color : Object.values(color)); return colors[Math.floor(Math.random() * colors.length)]; } +export default botConfig; diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.js index b23023a143..171b384d81 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.js @@ -1,152 +1,21 @@ -/** - * Command Aliases Configuration - * Maps shortened command names to their full command names - */ - export const commandAliases = { - 'bal': 'balance', - 'money': 'balance', - 'cash': 'balance', - - 'dep': 'deposit', - 'with': 'withdraw', - 'work': 'work', - 'daily': 'daily', - 'gamble': 'gamble', - 'bet': 'gamble', - 'rob': 'rob', - 'crime': 'crime', - 'pay': 'pay', - 'give': 'pay', - 'send': 'pay', - - 'ping': 'ping', - 'help': 'help', - 'h': 'help', - 'info': 'help', - - 'ban': 'ban', - 'kick': 'kick', - 'mute': 'timeout', - 'warn': 'warn', - 'clear': 'purge', - 'purge': 'purge', - 'untimeout': 'untimeout', - 'unmute': 'untimeout', - - 'rank': 'rank', - 'lvl': 'rank', - 'xp': 'rank', - 'leaderboard': 'leaderboard', - 'lb': 'leaderboard', - 'top': 'leaderboard', - - 'shop': 'shop', - 'buy': 'buy', - 'inventory': 'inventory', - 'inv': 'inventory', - 'items': 'inventory', - - 'user': 'userinfo', - 'avatar': 'avatar', - 'pfp': 'avatar', - 'icon': 'avatar', - - 'bd': 'birthday', - 'bday': 'birthday', - 'b': 'birthday', - - 'flip': 'flip', - 'coin': 'flip', - 'roll': 'roll', - 'dice': 'roll', - 'fight': 'fight', - - 'gcreate': 'gcreate', - 'gstart': 'gcreate', - 'gend': 'gend', - 'gstop': 'gend', - 'gdelete': 'gdelete', - 'greroll': 'greroll', - 'groll': 'greroll', - - 'ticket': 'ticket', - 't': 'ticket', - 'new': 'ticket', - - 'ver': 'verify', - 'vadmin': 'verification', - 'av': 'autoverify', - - 'welcome': 'welcome', - 'greet': 'greet', - 'goodbye': 'goodbye', - 'autorole': 'autorole', - - 'calc': 'calculate', - 'math': 'calculate', - 'weather': 'weather', - 'todo': 'todo', - 'report': 'report', - 'userinfo': 'userinfo', - 'whois': 'userinfo', - 'ui': 'userinfo', - - 'serverstats': 'serverstats', - 'ss': 'serverstats', - 'sstats': 'serverstats', - - 'rr': 'reactroles', - 'reactionroles': 'reactroles', - - 'jtc': 'jointocreate', - 'jointocreate': 'jointocreate', - - 'np': 'nowplaying', - 'now': 'nowplaying', + ping: 'ping', help: 'help', h: 'help', info: 'help', + ban: 'ban', kick: 'kick', mute: 'timeout', warn: 'warn', clear: 'purge', purge: 'purge', + untimeout: 'untimeout', unmute: 'untimeout', rank: 'rank', lvl: 'rank', xp: 'rank', + leaderboard: 'leaderboard', lb: 'leaderboard', top: 'leaderboard', + user: 'userinfo', avatar: 'avatar', pfp: 'avatar', icon: 'avatar', + bd: 'birthday', bday: 'birthday', b: 'birthday', + gcreate: 'gcreate', gstart: 'gcreate', gend: 'gend', gstop: 'gend', gdelete: 'gdelete', greroll: 'greroll', groll: 'greroll', + ticket: 'ticket', t: 'ticket', new: 'ticket', ver: 'verify', vadmin: 'verification', av: 'autoverify', + welcome: 'welcome', greet: 'greet', goodbye: 'goodbye', autorole: 'autorole', + calc: 'calculate', math: 'calculate', weather: 'weather', todo: 'todo', report: 'report', + userinfo: 'userinfo', whois: 'userinfo', ui: 'userinfo', serverstats: 'serverstats', ss: 'serverstats', sstats: 'serverstats', + rr: 'reactroles', reactionroles: 'reactroles', }; - export const subcommandAliases = { - 'l': 'list', - 'ls': 'list', - 's': 'set', - 'i': 'info', - 'r': 'remove', - 'rm': 'remove', - 'del': 'remove', - 'n': 'next', - 'sc': 'setchannel', - - 'a': 'add', - 'c': 'complete', - 'done': 'complete', - 'd': 'complete', - - 'start': 'create', - 'stop': 'end', - 'roll': 'reroll', - - 'add': 'add', - 'remove': 'remove', - 'list': 'list', + l: 'list', ls: 'list', s: 'set', i: 'info', r: 'remove', rm: 'remove', del: 'remove', n: 'next', sc: 'setchannel', + a: 'add', c: 'complete', done: 'complete', d: 'complete', start: 'create', stop: 'end', roll: 'reroll', + add: 'add', remove: 'remove', list: 'list', }; - -/** - * Resolve a command alias to its full command name - * @param {string} commandName - The command name (could be an alias) - * @returns {string} - The full command name, or the original if not an alias - */ -export function resolveCommandAlias(commandName) { - const normalized = commandName.toLowerCase(); - return commandAliases[normalized] || commandName; -} - -/** - * Resolve a subcommand alias to its full subcommand name - * @param {string} subcommandName - The subcommand name (could be an alias) - * @returns {string} - The full subcommand name, or the original if not an alias - */ -export function resolveSubcommandAlias(subcommandName) { - const normalized = subcommandName.toLowerCase(); - return subcommandAliases[normalized] || subcommandName; -} +export function resolveCommandAlias(name) { return commandAliases[name.toLowerCase()] || name; } +export function resolveSubcommandAlias(name) { return subcommandAliases[name.toLowerCase()] || name; } diff --git a/src/config/commands/commandCategories.js b/src/config/commands/commandCategories.js index 3d3ec1b5ee..0f18753060 100644 --- a/src/config/commands/commandCategories.js +++ b/src/config/commands/commandCategories.js @@ -1,46 +1,8 @@ -/** - * Command category metadata for the command access manager. - */ - export const CATEGORY_ICONS = { - Birthday: '🎂', - Community: '👥', - Core: 'ℹ️', - Economy: '💰', - Fun: '🎮', - Giveaway: '🎉', - JoinToCreate: '🔌', - Leveling: '📊', - Logging: '📝', - Moderation: '🛡️', - Music: '🎵', - Reaction_roles: '🎭', - Search: '🔍', - ServerStats: '📈', - Ticket: '🎫', - Tools: '🛠️', - Utility: '🔧', - Verification: '✅', - Welcome: '👋', + Birthday: '🎂', Community: '👥', Core: 'ℹ️', Giveaway: '🎉', Leveling: '📊', Logging: '📝', + Moderation: '🛡️', Reaction_roles: '🎭', Search: '🔍', ServerStats: '📈', Ticket: '🎫', Tools: '🛠️', Utility: '🔧', Verification: '✅', Welcome: '👋', }; - -/** Commands that always stay available so admins can recover access. */ export const PROTECTED_COMMANDS = new Set(['commands', 'configwizard']); - -export function normalizeCategoryKey(category) { - return String(category || '') - .trim() - .toLowerCase() - .replace(/\s+/g, '_'); -} - -export function formatCategoryName(rawCategory) { - return String(rawCategory || '') - .replace(/_/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} - -export function getCategoryIcon(category) { - return CATEGORY_ICONS[category] || CATEGORY_ICONS[formatCategoryName(category)] || '📁'; -} +export function normalizeCategoryKey(category) { return String(category || '').trim().toLowerCase().replace(/\s+/g, '_'); } +export function formatCategoryName(category) { return String(category || '').replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\b\w/g, (char) => char.toUpperCase()); } +export function getCategoryIcon(category) { return CATEGORY_ICONS[category] || CATEGORY_ICONS[formatCategoryName(category)] || '📁'; } diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js index 2b57f7bd60..bf34bd69ad 100644 --- a/src/config/commands/prefixRestrictions.js +++ b/src/config/commands/prefixRestrictions.js @@ -1,131 +1,19 @@ -/** - * Prefix command restrictions — dashboard and advanced setup flows stay slash-only. - */ - -/** Top-level commands that cannot be invoked via prefix at all. */ -export const SLASH_ONLY_COMMANDS = new Set([ - 'configwizard', - 'help', - 'embedbuilder', - 'wipedata', - 'apply', -]); - -/** Subcommands blocked for every command when invoked via prefix. */ -export const GLOBAL_BLOCKED_SUBCOMMANDS = new Set([ - 'dashboard', - 'setup', -]); - -/** Subcommand groups blocked for every command when invoked via prefix. */ -export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set([ - 'config', -]); - -/** Per-command subcommands that stay slash-only (beyond the global block list). */ -export const COMMAND_BLOCKED_SUBCOMMANDS = { - music: new Set([ - 'shuffle', - 'loop', - 'seek', - 'remove', - 'move', - 'clear', - '247', - ]), - birthday: new Set(['setchannel']), - report: new Set(['setchannel']), -}; - -function collectSubcommandNames(commandJson) { - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); - - if (subcommandGroup) { - const names = []; - for (const group of subcommandGroup.options || []) { - names.push(...(group.options?.map((opt) => opt.name) || [])); - } - return names; - } - - return (commandJson.options?.filter((opt) => opt.type === 1) || []).map((sub) => sub.name); -} - -function isSubcommandBlocked(commandName, subcommandName) { - if (!subcommandName) { - return false; - } - - if (GLOBAL_BLOCKED_SUBCOMMANDS.has(subcommandName)) { - return true; - } - - const commandBlocked = COMMAND_BLOCKED_SUBCOMMANDS[commandName]; - return commandBlocked?.has(subcommandName) ?? false; -} - -/** - * Returns whether a prefix invocation should be rejected. - * @param {object} command - Loaded command module - * @param {string[]} args - Parsed prefix arguments (after command name) - * @param {(name: string) => string} resolveSubcommandAlias - * @returns {{ blocked: boolean, reason?: string }} - */ -export function getPrefixRestriction(command, args, resolveSubcommandAlias) { - if (!command?.data?.toJSON) { - return { blocked: false }; - } - - const commandJson = command.data.toJSON(); - const commandName = commandJson.name?.toLowerCase(); - - if (command.prefixOnly === false || command.slashOnly === true) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - if (SLASH_ONLY_COMMANDS.has(commandName)) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - const [firstArg, secondArg] = args.map((arg) => arg?.toLowerCase?.() || null); - const resolvedFirstArg = firstArg ? resolveSubcommandAlias(firstArg) : null; - const resolvedSecondArg = secondArg ? resolveSubcommandAlias(secondArg) : null; - - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); - - const allSubcommandNames = collectSubcommandNames(commandJson); - const allSubcommandsBlocked = - allSubcommandNames.length > 0 && - allSubcommandNames.every((name) => isSubcommandBlocked(commandName, name)); - - if (allSubcommandsBlocked) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - if (firstArg && GLOBAL_BLOCKED_SUBCOMMAND_GROUPS.has(firstArg)) { - return { - blocked: true, - reason: 'This configuration flow is only available as a slash command.', - }; - } - - if (resolvedFirstArg && isSubcommandBlocked(commandName, resolvedFirstArg)) { - return { - blocked: true, - reason: 'This subcommand is only available as a slash command.', - }; - } - - if (subcommandGroup && resolvedSecondArg && isSubcommandBlocked(commandName, resolvedSecondArg)) { - return { - blocked: true, - reason: 'This subcommand is only available as a slash command.', - }; - } - +export const SLASH_ONLY_COMMANDS = new Set(['configwizard', 'help', 'embedbuilder', 'wipedata', 'apply']); +export const GLOBAL_BLOCKED_SUBCOMMANDS = new Set(['dashboard', 'setup']); +export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set(['config']); +export const COMMAND_BLOCKED_SUBCOMMANDS = { birthday: new Set(['setchannel']), report: new Set(['setchannel']) }; +function collectSubcommandNames(data) { const group = data.options?.find((option) => option.type === 2); if (group) return (group.options || []).flatMap((item) => item.options?.map((option) => option.name) || []); return (data.options?.filter((option) => option.type === 1) || []).map((option) => option.name); } +function isSubcommandBlocked(command, subcommand) { return !!subcommand && (GLOBAL_BLOCKED_SUBCOMMANDS.has(subcommand) || COMMAND_BLOCKED_SUBCOMMANDS[command]?.has(subcommand)); } +export function getPrefixRestriction(command, args, resolveAlias) { + if (!command?.data?.toJSON) return { blocked: false }; + const data = command.data.toJSON(); const name = data.name?.toLowerCase(); + if (command.prefixOnly === false || command.slashOnly === true || SLASH_ONLY_COMMANDS.has(name)) return { blocked: true, reason: 'This command is only available as a slash command.' }; + const [first, second] = args.map((arg) => arg?.toLowerCase?.() || null); const resolvedFirst = first ? resolveAlias(first) : null; const resolvedSecond = second ? resolveAlias(second) : null; + const group = data.options?.find((option) => option.type === 2); const all = collectSubcommandNames(data); + if (all.length && all.every((sub) => isSubcommandBlocked(name, sub))) return { blocked: true, reason: 'This command is only available as a slash command.' }; + if (first && GLOBAL_BLOCKED_SUBCOMMAND_GROUPS.has(first)) return { blocked: true, reason: 'This configuration flow is only available as a slash command.' }; + if (resolvedFirst && isSubcommandBlocked(name, resolvedFirst)) return { blocked: true, reason: 'This subcommand is only available as a slash command.' }; + if (group && resolvedSecond && isSubcommandBlocked(name, resolvedSecond)) return { blocked: true, reason: 'This subcommand is only available as a slash command.' }; return { blocked: false }; } - -export function isPrefixRestrictedCommand(command, args, resolveSubcommandAlias) { - return getPrefixRestriction(command, args, resolveSubcommandAlias).blocked; -} +export function isPrefixRestrictedCommand(command, args, resolveAlias) { return getPrefixRestriction(command, args, resolveAlias).blocked; } diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fb1a165f31..f5a86831f2 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -12,12 +12,6 @@ import { getCommandPrefix, getBotMessage, isBotOwner, isCommandCategoryEnabled, import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; import { createEmbed } from '../utils/embeds.js'; import { isCommandEnabled } from '../services/commandAccessService.js'; -import { - getCountingGameConfig, - saveCountingGameConfig, - isValidCountingMessage, - recordCorrectCount, -} from '../services/countingGameService.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -27,21 +21,13 @@ export default { async execute(message, client) { try { if (message.author.bot || !message.guild) return; - logger.debug(`Message received from ${message.author.tag}: ${message.content}`); - - const countingProcessed = await handleCountingGame(message, client); - if (countingProcessed) { - return; - } - await handlePrefixCommand(message, client); - await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); } - } + }, }; async function handlePrefixCommand(message, client) { @@ -49,205 +35,68 @@ async function handlePrefixCommand(message, client) { const guildConfig = await getGuildConfig(client, message.guild.id); const prefix = guildConfig?.prefix || getCommandPrefix(); const parsed = parsePrefixCommand(message.content, prefix); - - if (!parsed) { - return; - } - - let { commandName, args } = parsed; - const musicPrefixShortcut = commandName.toLowerCase(); - const MUSIC_PREFIX_SHORTCUTS = new Set(['leave', 'pause', 'resume', 'skip', 'stop', 'volume']); - if (MUSIC_PREFIX_SHORTCUTS.has(musicPrefixShortcut)) { - commandName = 'music'; - args = [musicPrefixShortcut, ...args]; - } - - logger.info(`Prefix command detected: ${commandName}, args: ${args.join(', ')}`); + if (!parsed) return; + const { commandName, args } = parsed; const resolvedCommandName = resolveCommandAlias(commandName); - logger.info(`Resolved command name: ${resolvedCommandName}`); const command = client.commands.get(resolvedCommandName); - - if (!command) { - logger.warn(`Command not found: ${resolvedCommandName}`); - return; - } + if (!command) return; if (isMaintenanceMode() && !isBotOwner(message.author.id)) { - await message.channel.send({ - embeds: [createEmbed({ - title: 'Maintenance Mode', - description: getBotMessage('maintenanceMode'), - color: 'warning', - })], - }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Maintenance Mode', description: getBotMessage('maintenanceMode'), color: 'warning' })] }).catch(() => {}); return; } - if (!isCommandCategoryEnabled(command.category)) { - await message.channel.send({ - embeds: [createEmbed({ - title: 'Feature Disabled', - description: getBotMessage('commandDisabled'), - color: 'error', - })], - }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Feature Disabled', description: getBotMessage('commandDisabled'), color: 'error' })] }).catch(() => {}); return; } const restriction = getPrefixRestriction(command, args, resolveSubcommandAlias); if (!supportsPrefixExecution(command) || restriction.blocked) { if (restriction.blocked && restriction.reason) { - const embed = createEmbed({ - title: 'Slash Command Only', - description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, - color: 'info', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Slash Command Only', description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, color: 'info' })] }).catch(() => {}); } return; } if (!(await isCommandEnabled(client, message.guild.id, resolvePrefixAccessKey(command.data, args), command.category))) { - const embed = createEmbed({ - title: 'Command Disabled', - description: 'This command has been disabled for this server.', - color: 'error', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); + await message.channel.send({ embeds: [createEmbed({ title: 'Command Disabled', description: 'This command has been disabled for this server.', color: 'error' })] }).catch(() => {}); return; } - const mockInteractionForProtection = { - guildId: message.guild.id, - user: message.author, - }; - const abuseProtection = await enforceAbuseProtection( - mockInteractionForProtection, - command, - resolvedCommandName, - ); - if (!abuseProtection.allowed) { - const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); - const embed = createEmbed({ - title: 'Command Cooldown', - description: `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, - color: 'error', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); + const protection = await enforceAbuseProtection({ guildId: message.guild.id, user: message.author }, command, resolvedCommandName); + if (!protection.allowed) { + const cooldown = formatCooldownDuration(protection.remainingMs); + await message.channel.send({ embeds: [createEmbed({ title: 'Command Cooldown', description: `This command is on cooldown. Please wait ${cooldown} before trying again.`, color: 'error' })] }).catch(() => {}); return; } - logger.info(`Executing prefix command: ${prefix}${commandName} (resolved to ${resolvedCommandName}) by ${message.author.tag}`); - await executePrefixCommand(command, message, args, client, prefix, guildConfig); } catch (error) { logger.error('Error handling prefix command:', error); } } -async function handleCountingGame(message, client) { - try { - const config = await getCountingGameConfig(client, message.guild.id); - if (!config.enabled || !config.channelId || message.channel.id !== config.channelId) { - return false; - } - - const content = message.content.trim(); - const validCount = isValidCountingMessage(content, config); - const invalidAttempt = !validCount || message.author.id === config.lastUserId; - - if (invalidAttempt) { - await message.delete().catch(() => {}); - await saveCountingGameConfig(client, message.guild.id, { - ...config, - nextNumber: 1, - lastUserId: null, - currentStreak: 0, - }); - - const failureMessage = await message.channel.send(`❌ Count broken by <@${message.author.id}>. The sequence has been reset to **1**.`); - setTimeout(() => { - failureMessage.delete().catch(() => {}); - }, 10000); - - return true; - } - - await recordCorrectCount(client, message.guild.id, message.author.id); - return true; - } catch (error) { - logger.error('Error handling counting game:', error); - return false; - } -} - 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; - } - + if (!(await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS))) return; + const config = await getLevelingConfig(client, message.guild.id); + if (!config?.enabled || config.ignoredChannels?.includes(message.channel.id)) return; + if (config.ignoredRoles?.length) { + const member = await message.guild.members.fetch(message.author.id).catch(() => null); + if (member && member.roles.cache.some((role) => config.ignoredRoles.includes(role.id))) return; + } + if (config.blacklistedUsers?.includes(message.author.id) || !message.content?.trim()) 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?.leveledUp) { - logger.info( - `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` - ); - } + if (Date.now() - (userData.lastMessage || 0) < (config.xpCooldown || 60) * 1000) return; + const minXP = Math.max(1, config.xpRange?.min || config.xpPerMessage?.min || 15); + const maxXP = Math.max(minXP, config.xpRange?.max || config.xpPerMessage?.max || 25); + let xp = Math.floor(Math.random() * (maxXP - minXP + 1)) + minXP; + if (config.xpMultiplier > 1) xp = Math.floor(xp * config.xpMultiplier); + const result = await addXp(client, message.guild, message.member, xp); + if (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); } -} \ No newline at end of file +} diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index c76c1e123f..b43c3a565c 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -1,333 +1,7 @@ -import { ChannelType, PermissionFlagsBits } from 'discord.js'; -import { - getJoinToCreateConfig, - registerTemporaryChannel, - unregisterTemporaryChannel, - getTemporaryChannelInfo, - formatChannelName -} from '../utils/database.js'; -import { sanitizeInput } from '../utils/validation.js'; -import { logger } from '../utils/logger.js'; -import { handleMusicVoiceState } from '../services/music/musicVoiceState.js'; - -const channelCreationCooldown = new Map(); -const VOICE_CREATE_COOLDOWN_MS = 2000; -const DEFAULT_VOICE_BITRATE = 64000; -const MAX_VOICE_BITRATE = 384000; -const MIN_VOICE_BITRATE = 8000; -const MAX_CHANNEL_NAME_LENGTH = 100; -const FALLBACK_CHANNEL_NAME = 'Voice Room'; -const MAX_TRACKED_COOLDOWNS = 10000; +import { Events } from 'discord.js'; +// Join-to-create voice automation and music voice handling were removed. export default { - name: 'voiceStateUpdate', - async execute(oldState, newState, client) { - if (newState.member.user.bot) return; - - const guildId = newState.guild.id; - const userId = newState.member.id; - const cooldownKey = `${guildId}-${userId}`; - cleanupCooldownEntries(); - - try { - const config = await getJoinToCreateConfig(client, guildId); - - if (!config.enabled || config.triggerChannels.length === 0) { - return; - } - - if (!oldState.channel && newState.channel) { - await handleVoiceJoin(client, newState, config); - } - - if (oldState.channel && !newState.channel) { - await handleVoiceLeave(client, oldState, config); - } - - if (oldState.channel && newState.channel && oldState.channel.id !== newState.channel.id) { - await handleVoiceMove(client, oldState, newState, config); - } - - } catch (error) { - logger.error(`Error in voiceStateUpdate for guild ${guildId}:`, error); - } - - async function handleVoiceJoin(client, state, config) { - const { channel, member } = state; - - if (!config.triggerChannels.includes(channel.id)) { - return; - } - - const now = Date.now(); - if (channelCreationCooldown.has(cooldownKey)) { - const lastCreation = channelCreationCooldown.get(cooldownKey); -if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { - logger.warn(`User ${member.id} is on cooldown for channel creation`); - return; - } - } - - const existingTempChannel = Object.keys(config.temporaryChannels || {}).find( - tempChannelId => { - const tempInfo = config.temporaryChannels[tempChannelId]; - return tempInfo && tempInfo.ownerId === member.id; - } - ); - - if (existingTempChannel) { - const tempChannel = state.guild.channels.cache.get(existingTempChannel); - if (tempChannel) { - try { - await member.voice.setChannel(tempChannel); - return; - } catch (error) { - logger.warn(`Failed to move user ${member.id} to existing channel ${existingTempChannel}:`, error); - } - } - } - - if (member.voice.channel?.id !== channel.id) { - return; - } - - channelCreationCooldown.set(cooldownKey, now); - trimCooldownMapIfNeeded(); - - await createTemporaryChannel(client, state, config); - } - - async function handleVoiceLeave(client, state, config) { - const { channel, member } = state; - - const tempChannelInfo = await getTemporaryChannelInfo(client, state.guild.id, channel.id); - - if (!tempChannelInfo) { - return; - } - - if (channel.members.size === 0) { - await deleteTemporaryChannel(client, channel, state.guild.id); - } else if (tempChannelInfo.ownerId === member.id) { - const nextMember = channel.members.first(); - if (nextMember) { - await transferChannelOwnership(client, channel, state.guild.id, nextMember.id); - } - } - } - - async function handleVoiceMove(client, oldState, newState, config) { - if (oldState.channel) { - const tempChannelInfo = await getTemporaryChannelInfo(client, oldState.guild.id, oldState.channel.id); - - if (tempChannelInfo) { - if (oldState.channel.members.size === 0) { - await deleteTemporaryChannel(client, oldState.channel, oldState.guild.id); - } else if (tempChannelInfo.ownerId === oldState.member.id) { - const nextMember = oldState.channel.members.first(); - if (nextMember) { - await transferChannelOwnership(client, oldState.channel, oldState.guild.id, nextMember.id); - } - } - } - } - - if (config.triggerChannels.includes(newState.channel.id) && - !config.triggerChannels.includes(oldState.channel?.id)) { - await handleVoiceJoin(client, newState, config); - } - } - - async function createTemporaryChannel(client, state, config) { - const { channel: triggerChannel, member, guild } = state; - - try { - const me = guild.members.me; - if (!me) { - logger.warn(`Bot member cache unavailable while creating temporary channel in guild ${guild.id}`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const triggerPermissions = triggerChannel.permissionsFor(me); - if (!triggerPermissions?.has([PermissionFlagsBits.ManageChannels, PermissionFlagsBits.MoveMembers, PermissionFlagsBits.Connect])) { - logger.warn(`Missing required permissions for temporary channel creation in guild ${guild.id} (trigger channel ${triggerChannel.id})`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const channelOptions = config.channelOptions?.[triggerChannel.id] || {}; - const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate || "{username}'s Room"; - - let userLimit = channelOptions.userLimit ?? config.userLimit ?? 0; - const bitrate = clampVoiceBitrate(channelOptions.bitrate ?? config.bitrate ?? DEFAULT_VOICE_BITRATE); - - userLimit = Math.max(0, Math.min(99, userLimit || 0)); - - logger.info(`Creating temporary channel for user ${member.id} with user limit: ${userLimit}`); - - const existingChannels = guild.channels.cache.filter(c => - c.parentId === triggerChannel.parentId && - c.name.startsWith(triggerChannel.name) - ).size; - - let finalName; - - if ( - nameTemplate.includes('{username}') || - nameTemplate.includes('{displayName}') - ) { - finalName = formatChannelName(nameTemplate, { - username: member.user.username, - userTag: member.user.tag, - displayName: member.displayName, - guildName: guild.name, - channelName: triggerChannel.name - }); - } else { - finalName = `${triggerChannel.name} ${existingChannels + 1}`; - } - - const channelName = sanitizeVoiceChannelName(finalName); - - if (!member.voice?.channel || member.voice.channel.id !== triggerChannel.id) { - logger.debug(`Member ${member.id} no longer in trigger channel ${triggerChannel.id}, aborting temporary channel creation`); - channelCreationCooldown.delete(cooldownKey); - return; - } - - const tempChannel = await guild.channels.create({ - name: channelName, -type: ChannelType.GuildVoice, - parent: triggerChannel.parentId, -userLimit: userLimit === 0 ? undefined : userLimit, - bitrate: bitrate, - permissionOverwrites: [ - { - id: member.id, - allow: ['Connect', 'Speak', 'PrioritySpeaker', 'MoveMembers'] - }, - { - id: guild.id, - allow: ['Connect', 'Speak'] - } - ] - }); - - await registerTemporaryChannel(client, guild.id, tempChannel.id, member.id, triggerChannel.id); - - if (member.voice?.channel?.id === triggerChannel.id) { - await member.voice.setChannel(tempChannel); - } else { - logger.debug(`Skipped moving ${member.id} to temporary channel ${tempChannel.id} because voice state changed`); - } - - logger.info(`Created temporary voice channel ${tempChannel.name} (${tempChannel.id}) for user ${member.user.tag} in guild ${guild.name} with user limit ${userLimit}`); - - } catch (error) { - logger.error(`Failed to create temporary channel for user ${member.user.tag} in guild ${guild.name}:`, error); - - channelCreationCooldown.delete(cooldownKey); - - try { - await member.send({ - content: `❌ Failed to create your temporary voice channel. Please contact a server administrator.` - }); - } catch (dmError) { - logger.debug(`Unable to send temporary channel failure DM to user ${member.id}:`, dmError); - } - } - } - - async function deleteTemporaryChannel(client, channel, guildId) { - try { - await unregisterTemporaryChannel(client, guildId, channel.id); - - await channel.delete('Temporary voice channel - empty'); - - logger.info(`Deleted temporary voice channel ${channel.name} (${channel.id}) in guild ${channel.guild.name}`); - - } catch (error) { - logger.error(`Failed to delete temporary channel ${channel.id}:`, error); - } - } - - async function transferChannelOwnership(client, channel, guildId, newOwnerId) { - try { - const config = await getJoinToCreateConfig(client, guildId); - const tempChannelInfo = config.temporaryChannels[channel.id]; - - if (!tempChannelInfo) return; - - config.temporaryChannels[channel.id].ownerId = newOwnerId; - await client.db.set(`guild:${guildId}:jointocreate`, config); - - const newOwner = await channel.guild.members.fetch(newOwnerId); - if (newOwner) { - const channelOptions = config.channelOptions?.[tempChannelInfo.triggerChannelId] || {}; - const nameTemplate = channelOptions.nameTemplate || config.channelNameTemplate; - - const newChannelName = sanitizeVoiceChannelName(formatChannelName(nameTemplate, { - username: newOwner.user.username, - userTag: newOwner.user.tag, - displayName: newOwner.displayName, - guildName: channel.guild.name, - channelName: channel.guild.channels.cache.get(tempChannelInfo.triggerChannelId)?.name || 'Voice Channel' - })); - - await channel.setName(newChannelName); - } - - logger.info(`Transferred ownership of temporary channel ${channel.id} to user ${newOwnerId}`); - - } catch (error) { - logger.error(`Failed to transfer ownership of channel ${channel.id}:`, error); - } - } - - if (client.config?.features?.music) { - handleMusicVoiceState(client, oldState, newState).catch((error) => { - logger.error('Music voice state handler error:', error); - }); - } - } + name: Events.VoiceStateUpdate, + async execute() {}, }; - -function sanitizeVoiceChannelName(inputName) { - const safeName = sanitizeInput(String(inputName || ''), MAX_CHANNEL_NAME_LENGTH) - .replace(/[\r\n\t]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - return safeName || FALLBACK_CHANNEL_NAME; -} - -function clampVoiceBitrate(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return DEFAULT_VOICE_BITRATE; - } - - return Math.max(MIN_VOICE_BITRATE, Math.min(MAX_VOICE_BITRATE, Math.floor(parsed))); -} - -function cleanupCooldownEntries() { - const now = Date.now(); - for (const [key, timestamp] of channelCreationCooldown.entries()) { - if (now - timestamp >= VOICE_CREATE_COOLDOWN_MS) { - channelCreationCooldown.delete(key); - } - } -} - -function trimCooldownMapIfNeeded() { - if (channelCreationCooldown.size <= MAX_TRACKED_COOLDOWNS) { - return; - } - - const entries = [...channelCreationCooldown.entries()].sort((a, b) => a[1] - b[1]); - const removeCount = channelCreationCooldown.size - MAX_TRACKED_COOLDOWNS; - for (let index = 0; index < removeCount; index += 1) { - channelCreationCooldown.delete(entries[index][0]); - } -} \ No newline at end of file diff --git a/src/handlers/help/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.js index 960826e7de..ec0965a3b9 100644 --- a/src/handlers/help/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.js @@ -1,415 +1,71 @@ 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 { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.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 EXCLUDED_CATEGORIES = new Set(['economy', 'fun', 'music', 'jointocreate']); -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: "💰", - Music: "🎵", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - "Reaction Roles": "🎭", - Community: "👥", - Birthday: "🎂", - "Join To Create": "🔌", - Verification: "✅", - Config: "⚙️", -}; - -function formatCategoryName(rawCategory) { - return rawCategory - .replace(/_/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); +function commandData(command) { + const raw = command?.data; + return raw?.toJSON ? raw.toJSON() : raw; } -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, - }); - } - } +function displayEntries(client, category = null) { + const result = []; + for (const command of client?.commands?.values?.() ?? []) { + const data = commandData(command); + const name = data?.name; + if (!name || EXCLUDED_CATEGORIES.has(String(command.category || '').toLowerCase())) continue; + if (category && String(command.category).toLowerCase() !== String(category).toLowerCase()) continue; + const options = data.options || []; + const subcommands = options.filter((option) => option.type === 1); + if (subcommands.length) { + for (const sub of subcommands) result.push({ name: `${name} ${sub.name}`, description: sub.description || data.description || '', category: command.category }); + } else { + result.push({ name, description: data.description || '', category: command.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, - ) - : [], - }; + } + return result.sort((a, b) => a.name.localeCompare(b.name)); } -async function createCategoryCommandsMenu(category, client) { - const categoryName = formatCategoryName(category); - 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.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], - }; +function buildMenu(title, entries, client, includeCategories = false) { + const embed = createEmbed({ + title, + description: entries.length ? entries.map((entry) => `**/${entry.name}** — ${includeCategories ? `${entry.category}: ` : ''}${entry.description}`).join('\n').slice(0, 4000) : 'Keine Befehle in dieser Kategorie verfügbar.', + }); + embed.setFooter({ text: FOOTER_TEXT }); + embed.setTimestamp(); + const back = createButton(BACK_BUTTON_ID, 'Zurück', 'primary', '⬅️', false); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(back)] }; } 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 = formatCategoryName(category); - - 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)); + const entries = displayEntries(client); + return buildMenu('📋 Alle Befehle', entries.slice((page - 1) * 45, page * 45), client, true); +} - let registeredCommands = new Collection(); +export const helpCategorySelectMenu = { + name: CATEGORY_SELECT_ID, + async execute(interaction, client) { try { - if (client?.application?.commands?.fetch) { - const commands = await client.application.commands.fetch(); - for (const cmd of commands.values()) { - registeredCommands.set(cmd.name, cmd); - } - } + if (!interaction.deferred && !interaction.replied) await interaction.deferUpdate(); + const category = interaction.values[0]; + const result = category === ALL_COMMANDS_ID + ? await createAllCommandsMenu(1, client) + : buildMenu(`${category} – Befehle`, displayEntries(client, category), client); + await interaction.editReply(result); } catch (error) { - logger.error('Error fetching registered commands:', error); + if (error?.code === 40060 || error?.code === 10062) { + logger.warn('Help category select interaction already acknowledged or expired.'); + return; + } + await handleInteractionError(interaction, error, { type: 'select_menu', customId: interaction.customId, handler: 'help_category' }); } - - 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: `Browse every available command in one list. Use the page buttons below to move through the full set.` - }); - - 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.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, - }; -} - -export const helpCategorySelectMenu = { - name: CATEGORY_SELECT_ID, - async execute(interaction, client) { - try { - if (!interaction.deferred && !interaction.replied) { - await interaction.deferUpdate(); - } - - const selectedCategory = interaction.values[0]; - - if (selectedCategory === ALL_COMMANDS_ID) { - const { embeds, components } = await createAllCommandsMenu(1, client); - await interaction.editReply({ - embeds, - components, - }); - } else { - const { embeds, components } = await createCategoryCommandsMenu(selectedCategory, client); - await interaction.editReply({ - embeds, - components, - }); - } - } catch (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; - } - - await handleInteractionError(interaction, error, { - type: 'select_menu', - customId: interaction.customId, - handler: 'help_category', - }); - } - }, -}; \ No newline at end of file + }, +}; diff --git a/src/handlers/loaders/commandLoader.js b/src/handlers/loaders/commandLoader.js index ecb7ad96d4..87c5b093bc 100644 --- a/src/handlers/loaders/commandLoader.js +++ b/src/handlers/loaders/commandLoader.js @@ -3,298 +3,121 @@ import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { Collection } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import botConfig from '../../config/bot.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MAX_COMMANDS = 100; const COMMAND_COUNT_WARN_THRESHOLD = 90; +const REMOVED_CATEGORIES = new Set(['economy', 'fun', 'music', 'jointocreate']); +const REMOVED_COMMANDS = new Set(['jointocreate']); function getSubcommandInfo(commandData) { - const subcommands = []; - - if (commandData.options) { - for (const option of commandData.options) { -if (option.type === 1) { - subcommands.push(option.name); -} else if (option.type === 2) { - if (option.options) { - for (const subOption of option.options) { -if (subOption.type === 1) { - subcommands.push(`${option.name}/${subOption.name}`); - } - } - } - } - } + const result = []; + for (const option of commandData.options || []) { + if (option.type === 1) result.push(option.name); + else if (option.type === 2) { + for (const nested of option.options || []) if (nested.type === 1) result.push(`${option.name}/${nested.name}`); } - - return subcommands; + } + return result; } -async function getAllFiles(directory, fileList = []) { - const files = await fs.readdir(directory, { withFileTypes: true }); - - for (const file of files) { - const filePath = path.join(directory, file.name); - - if (file.isDirectory()) { - if (file.name === 'modules') { - continue; - } - await getAllFiles(filePath, fileList); - } else if (file.name.endsWith('.js')) { - fileList.push(filePath); - } - } - - return fileList; +async function getAllFiles(directory, files = []) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'modules') await getAllFiles(fullPath, files); + } else if (entry.name.endsWith('.js')) files.push(fullPath); + } + return files; } export async function loadCommands(client) { - client.commands = new Collection(); - const commandsPath = path.join(__dirname, '../../commands'); - const commandFiles = await getAllFiles(commandsPath); - - logger.info(`Found ${commandFiles.length} command files to load`); - - const uniqueCommandNames = new Set(); - - for (const filePath of commandFiles) { - try { - const normalizedPath = filePath.replace(/\\/g, '/'); - - const commandName = path.basename(filePath, '.js'); - const commandDir = path.dirname(filePath); - const category = path.basename(commandDir); - - const commandModule = await import(`file://${filePath}`); - const command = commandModule.default || commandModule; - - if (!command.data || !command.execute) { - logger.warn(`Command at ${filePath} is missing required "data" or "execute" property.`); - continue; - } - - command.category = category; - command.filePath = normalizedPath; - - const primaryCommandName = command.data.name; - - if (!uniqueCommandNames.has(primaryCommandName)) { - uniqueCommandNames.add(primaryCommandName); - - client.commands.set(primaryCommandName, command); - } - - const subcommands = getSubcommandInfo(command.data.toJSON()); - - logger.info(`Loaded command: ${primaryCommandName} from ${normalizedPath} (category: ${category})`); - - if (subcommands.length > 0) { - logger.info(` - Subcommands: ${subcommands.join(', ')}`); - } - - } catch (error) { - logger.error(`Error loading command from ${filePath}:`, error); - } - } - - const commandsWithSubcommands = Array.from(client.commands.values()).filter(cmd => { - const subcommands = getSubcommandInfo(cmd.data.toJSON()); - return subcommands.length > 0; - }); - - const totalSubcommands = commandsWithSubcommands.reduce((total, cmd) => { - return total + getSubcommandInfo(cmd.data.toJSON()).length; - }, 0); - - const uniqueCommands = new Set(); - for (const [name, command] of client.commands.entries()) { - if (command.data && command.data.name) { - uniqueCommands.add(command.data.name); - } - } - - logger.info(`Loaded ${uniqueCommands.size} commands`); - return client.commands; -} - -function collectCommandPayloads(client) { - const commands = []; - let totalSubcommands = 0; - const registeredNames = new Set(); + client.commands = new Collection(); + const commandsPath = path.join(__dirname, '../../commands'); + const files = await getAllFiles(commandsPath); + const names = new Set(); - for (const command of client.commands.values()) { - if (!command.data || typeof command.data.toJSON !== 'function') { - logger.warn(`Command missing data or toJSON method: ${command}`); - continue; - } + for (const filePath of files) { + const category = path.basename(path.dirname(filePath)); + const fileName = path.basename(filePath, '.js').toLowerCase(); + if (REMOVED_CATEGORIES.has(category.toLowerCase()) || REMOVED_COMMANDS.has(fileName)) continue; - const commandName = command.data.name; - logger.debug(`Processing command for registration: ${commandName}`); - - if (registeredNames.has(commandName)) { - logger.debug(`Skipping duplicate command: ${commandName}`); - continue; - } - - registeredNames.add(commandName); - const commandJson = command.data.toJSON(); - commands.push(commandJson); - totalSubcommands += getSubcommandInfo(commandJson).length; - - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Registering command: ${commandName}`); - } + try { + const module = await import(pathToFileURL(filePath).href); + const command = module.default || module; + if (!command.data || !command.execute) { + logger.warn(`Command at ${filePath} is missing required "data" or "execute" property.`); + continue; + } + command.category = category; + command.filePath = filePath.replace(/\\/g, '/'); + const name = command.data.name; + if (names.has(name)) continue; + names.add(name); + client.commands.set(name, command); + const subcommands = getSubcommandInfo(command.data.toJSON()); + logger.info(`Loaded command: ${name} (category: ${category})`); + if (subcommands.length) logger.info(` - Subcommands: ${subcommands.join(', ')}`); + } catch (error) { + logger.error(`Error loading command from ${filePath}:`, error); } + } - return { commands, totalSubcommands }; + logger.info(`Loaded ${client.commands.size} commands`); + return client.commands; } -function validateCommands(commands) { - const validationErrors = []; - - for (const cmd of commands) { - if (cmd.name && cmd.name.length > 32) { - validationErrors.push(`Command ${cmd.name} has name longer than 32 chars: "${cmd.name}" (${cmd.name.length} chars)`); - } - if (cmd.description && cmd.description.length > 110) { - validationErrors.push(`Command ${cmd.name} has description longer than 110 chars: "${cmd.description}" (${cmd.description.length} chars)`); - } - - if (!cmd.options) { - continue; - } - - for (const option of cmd.options) { - if (option.name && option.name.length > 32) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has name longer than 32 chars: "${option.name}" (${option.name.length} chars)`); - } - if (option.description && option.description.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has description longer than 110 chars: "${option.description}" (${option.description.length} chars)`); - } - - if (option.choices) { - for (const choice of option.choices) { - if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); - } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); - } - } - } - - if (!option.options) { - continue; - } - - for (const subOption of option.options) { - if (subOption.name && subOption.name.length > 32) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has name longer than 32 chars: "${subOption.name}" (${subOption.name.length} chars)`); - } - if (subOption.description && subOption.description.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has description longer than 110 chars: "${subOption.description}" (${subOption.description.length} chars)`); - } - - if (!subOption.choices) { - continue; - } - - for (const choice of subOption.choices) { - if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); - } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); - } - } - } - } - } +function collectCommandPayloads(client) { + const commands = []; + let totalSubcommands = 0; + for (const command of client.commands.values()) { + if (!command.data || typeof command.data.toJSON !== 'function') continue; + const data = command.data.toJSON(); + commands.push(data); + totalSubcommands += getSubcommandInfo(data).length; + } + return { commands, totalSubcommands }; +} - if (validationErrors.length > 0) { - logger.error('Command validation failed. Errors:'); - validationErrors.forEach((error) => logger.error(` - ${error}`)); - throw new Error(`Command validation failed with ${validationErrors.length} errors`); - } +function validateCommands(commands) { + for (const command of commands) { + if (command.name?.length > 32) throw new Error(`Command ${command.name} has name longer than 32 characters`); + if (command.description?.length > 110) throw new Error(`Command ${command.name} has description longer than 110 characters`); + } } function prepareCommandsForRegistration(commands) { - if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) { - logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); - } - - if (commands.length <= MAX_COMMANDS) { - return commands; - } - - logger.warn(`Command count (${commands.length}) exceeds Discord limit (${MAX_COMMANDS}), truncating...`); - const truncated = commands.slice(0, MAX_COMMANDS); - logger.info(`Truncated to ${truncated.length} commands for registration`); - return truncated; + if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); + return commands.slice(0, MAX_COMMANDS); } async function registerGlobalCommands(client, clientId, commands, totalSubcommands) { - if (!clientId) { - throw new Error('CLIENT_ID is required for slash command registration'); - } - - if (!client.rest) { - throw new Error('Discord REST client is not available for slash command registration'); - } - - logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally`); - logger.info('Validating commands before registration...'); - validateCommands(commands); - logger.info('Command validation passed'); - - const commandsToRegister = prepareCommandsForRegistration(commands); - - if (botConfig.commands?.deleteCommands) { - logger.info('Clearing existing global commands before registration...'); - await client.rest.put(`/applications/${clientId}/commands`, { body: [] }); - } - - logger.info(`Registering ${commandsToRegister.length} global commands...`); - await client.rest.put(`/applications/${clientId}/commands`, { body: commandsToRegister }); - logger.info(`Successfully registered ${commandsToRegister.length} global commands`); - logger.info('Global commands may take up to an hour to appear in all servers on first deploy'); + if (!clientId) throw new Error('CLIENT_ID is required for slash command registration'); + if (!client.rest) throw new Error('Discord REST client is not available for slash command registration'); + logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally`); + validateCommands(commands); + const body = prepareCommandsForRegistration(commands); + // Discord bulk-overwrite removes stale slash commands that are no longer loaded. + await client.rest.put(`/applications/${clientId}/commands`, { body }); + logger.info(`Successfully registered ${body.length} global commands`); } export async function registerCommands(client, options = {}) { - const { clientId = null } = options; - - try { - const { commands, totalSubcommands } = collectCommandPayloads(client); - await registerGlobalCommands(client, clientId, commands, totalSubcommands); - } catch (error) { - logger.error('Error registering commands:', error); - throw error; - } + const { commands, totalSubcommands } = collectCommandPayloads(client); + await registerGlobalCommands(client, options.clientId, commands, totalSubcommands); } export async function reloadCommand(client, commandName) { - const command = client.commands.get(commandName); - - if (!command) { - return { success: false, message: `Command "${commandName}" not found` }; - } - - try { - const commandPath = path.resolve(command.filePath); - const moduleUrl = pathToFileURL(commandPath); - moduleUrl.searchParams.set('t', Date.now().toString()); - - const newCommand = (await import(moduleUrl.href)).default; - - client.commands.set(commandName, newCommand); - - logger.info(`Reloaded command: ${commandName}`); - return { success: true, message: `Successfully reloaded command "${commandName}"` }; - } catch (error) { - logger.error(`Error reloading command "${commandName}":`, error); - return { success: false, message: `Error reloading command: ${error.message}` }; - } -} \ No newline at end of file + const command = client.commands.get(commandName); + if (!command) return { success: false, message: `Command "${commandName}" not found` }; + try { + const url = pathToFileURL(path.resolve(command.filePath)); + url.searchParams.set('t', Date.now().toString()); + client.commands.set(commandName, (await import(url.href)).default); + return { success: true, message: `Successfully reloaded command "${commandName}"` }; + } catch (error) { + logger.error(`Error reloading command "${commandName}":`, error); + return { success: false, message: `Error reloading command: ${error.message}` }; + } +} diff --git a/src/handlers/loaders/interactions.js b/src/handlers/loaders/interactions.js index 12a276268a..5023fed683 100644 --- a/src/handlers/loaders/interactions.js +++ b/src/handlers/loaders/interactions.js @@ -6,70 +6,50 @@ import { logger } from '../../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); - const interactionTypes = ['buttons', 'selectMenus', 'modals']; async function getAllInteractionFiles(directory, fileList = []) { const entries = await readdir(directory, { withFileTypes: true }); - for (const entry of entries) { const entryPath = join(directory, entry.name); - if (entry.isDirectory()) { await getAllInteractionFiles(entryPath, fileList); } else if (entry.name.endsWith('.js')) { fileList.push(entryPath); } } - return fileList; } export default async (client) => { - try { - const interactionsPath = join(__dirname, '../../interactions'); - - for (const type of interactionTypes) { - const typePath = join(interactionsPath, type); - - try { - const interactionFiles = await getAllInteractionFiles(typePath); - let loadedCount = 0; - - for (const filePath of interactionFiles) { - const relativePath = filePath.slice(interactionsPath.length + 1).replace(/\\/g, '/'); - const fileName = relativePath.split('/').pop(); - - try { - const module = await import(pathToFileURL(filePath).href); - const moduleExport = module.default; - const interactions = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; - - for (const interaction of interactions) { - if (!interaction?.name || !interaction?.execute) { - logger.warn(`Interaction ${relativePath} in ${type} is missing required properties.`); - continue; - } - - client[type].set(interaction.name, interaction); - loadedCount += 1; - logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name} (${fileName})`); + const interactionsPath = join(__dirname, '../../interactions'); + for (const type of interactionTypes) { + try { + const files = await getAllInteractionFiles(join(interactionsPath, type)); + let loadedCount = 0; + for (const filePath of files) { + const relativePath = filePath.slice(interactionsPath.length + 1).replace(/\\/g, '/'); + if (relativePath.startsWith('buttons/music/')) continue; + try { + const module = await import(pathToFileURL(filePath).href); + const exports = Array.isArray(module.default) ? module.default : [module.default]; + for (const interaction of exports) { + if (!interaction?.name || !interaction?.execute) { + logger.warn(`Interaction ${relativePath} in ${type} is missing required properties.`); + continue; } - } catch (error) { - logger.error(`Error loading interaction ${relativePath} in ${type}:`, error); + client[type].set(interaction.name, interaction); + loadedCount += 1; + logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name} (${relativePath.split('/').pop()})`); } - } - - logger.info(`Loaded ${loadedCount} ${type}`); - } catch (error) { - if (error.code !== 'ENOENT') { - logger.error(`Error loading ${type}:`, error); - } else { - logger.debug(`No ${type} directory found, skipping...`); + } catch (error) { + logger.error(`Error loading interaction ${relativePath} in ${type}:`, error); } } + logger.info(`Loaded ${loadedCount} ${type}`); + } catch (error) { + if (error.code !== 'ENOENT') logger.error(`Error loading ${type}:`, error); + else logger.debug(`No ${type} directory found, skipping...`); } - } catch (error) { - logger.error('Error loading interactions:', error); } }; diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js index b16290fbc3..876a2dc43b 100644 --- a/src/services/music/playerHandler.js +++ b/src/services/music/playerHandler.js @@ -1,254 +1,2 @@ -// Player event handlers for Riffy. Adapted from Musicify playerHandler (Apache-2.0). - -import { logger } from '../../utils/logger.js'; -import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; -import { - buildNowPlayingEmbed, - buildPlayerButtonRows, -} from './musicEmbeds.js'; - -const UPDATE_INTERVAL_MS = 15 * 1000; -const IDLE_DISCONNECT_MS = 30 * 1000; - -async function editOrSendPlayerMessage(client, guildData, channelId, embed, components) { - const channel = client.channels.cache.get(channelId); - if (!channel) { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - return; - } - - const payload = { embeds: [embed], components }; - - if (guildData.playerMessageId) { - try { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.edit(payload); - return; - } catch { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - clearUpdateInterval(guildData); - } - } - - try { - const newMsg = await channel.send(payload); - guildData.playerMessageId = newMsg.id; - guildData.playerChannelId = channel.id; - } catch (error) { - logger.error('Failed to send music player message:', error); - } -} - -export async function refreshPlayerMessage(client, guildId) { - try { - const player = client.riffy?.players?.get(guildId); - if (!player?.current) { - return; - } - - const guildData = getGuildMusicData(guildId); - const embed = buildNowPlayingEmbed(player.current, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - } catch (error) { - logger.error('Failed to refresh music player message:', error); - } -} - -function startUpdateInterval(client, guildId) { - const guildData = getGuildMusicData(guildId); - clearUpdateInterval(guildData); - guildData.updateInterval = setInterval(() => { - refreshPlayerMessage(client, guildId); - }, UPDATE_INTERVAL_MS); -} - -export function setupPlayerHandler(client) { - if (!client.riffy) { - logger.warn('Riffy not initialized; music player handlers not attached.'); - return; - } - - // Lavalink nodes often flap (reconnect -> error -> reconnect). Throttle all - // per-node messages to one line per interval, log the first connect only, - // and skip reconnect noise entirely since it is meaningless during flapping. - const nodeLogState = new Map(); - const NODE_LOG_INTERVAL_MS = 5 * 60 * 1000; - - const shouldLogNodeEvent = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - const now = Date.now(); - if (now - prev.lastLogAt < NODE_LOG_INTERVAL_MS) { - return false; - } - nodeLogState.set(nodeName, { ...prev, lastLogAt: now }); - return true; - }; - - const markNodeConnected = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - nodeLogState.set(nodeName, { ...prev, hasConnected: true }); - }; - - client.riffy.on('nodeConnect', (node) => { - const prev = nodeLogState.get(node.name) ?? { lastLogAt: 0, hasConnected: false }; - if (prev.hasConnected) { - return; - } - markNodeConnected(node.name); - logger.info(`Lavalink node "${node.name}" connected.`); - }); - - client.riffy.on('nodeReconnect', () => { - // Intentionally silent — reconnect spam is not actionable during flapping. - }); - - client.riffy.on('nodeError', (node, error) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" error: ${error?.message || error}`); - }); - - client.riffy.on('nodeDisconnect', (node) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" disconnected.`); - }); - - client.riffy.on('trackStart', async (player, track) => { - try { - const guildData = getGuildMusicData(player.guildId); - - // Keep the Lavalink player's loop mode aligned with the stored preference. - // Skip temporarily clears track-loop so it can advance; restore it here. - if (guildData.loop && player.loop !== guildData.loop) { - player.setLoop(guildData.loop); - } - - if (player.previous) { - guildData.previousTracks.push(player.previous); - if (guildData.previousTracks.length > 20) { - guildData.previousTracks.shift(); - } - } - - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - - const embed = buildNowPlayingEmbed(track, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - startUpdateInterval(client, player.guildId); - } catch (error) { - logger.error('Music trackStart error:', error); - } - }); - - client.riffy.on('queueEnd', async (player) => { - try { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.autoplay) { - player.autoplay(player); - return; - } - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } - - if (!guildData.twentyFourSeven) { - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - } - guildData.idleTimeout = setTimeout(() => { - try { - const currentPlayer = client.riffy.players.get(player.guildId); - if (currentPlayer && !currentPlayer.playing && !currentPlayer.paused && !currentPlayer.current) { - currentPlayer.destroy(); - } - } catch { - // player already destroyed - } - guildData.idleTimeout = null; - }, IDLE_DISCONNECT_MS); - } - } catch (error) { - logger.error('Music queueEnd error:', error); - } - }); - - client.riffy.on('playerDisconnect', async (player) => { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - guildData.previousTracks = []; - guildData.autoPaused = false; - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - }); - - client.riffy.on('trackError', async (player, track, payload) => { - logger.error(`Track error in ${player.guildId} for "${track?.info?.title}":`, payload?.error || payload); - const guildData = getGuildMusicData(player.guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send(`Failed to play **${track?.info?.title || 'track'}**. Skipping...`).catch(() => null); - } - } - }); - - client.riffy.on('trackStuck', async (player, track, payload) => { - logger.warn(`Track stuck in ${player.guildId} for "${track?.info?.title}" (${payload?.thresholdMs}ms)`); - }); -} - -export async function shutdownMusic(client) { - if (!client.riffy?.players) { - return; - } - - for (const player of client.riffy.players.values()) { - try { - player.destroy(); - } catch (error) { - logger.debug('Error destroying music player during shutdown:', error.message); - } - } -} +// Music playback is removed; there are no player sessions to shut down. +export async function shutdownMusic() {} diff --git a/src/services/music/riffySetup.js b/src/services/music/riffySetup.js index 70fde72d50..94adbbc31a 100644 --- a/src/services/music/riffySetup.js +++ b/src/services/music/riffySetup.js @@ -1,66 +1,5 @@ -import { createRequire } from 'module'; -import { GatewayDispatchEvents } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import lavalinkConfig from '../../config/music/lavalink.js'; -import { setupPlayerHandler } from './playerHandler.js'; -const require = createRequire(import.meta.url); -const { Riffy } = require('riffy'); - -export function initializeMusic(client) { - if (!lavalinkConfig.nodes?.length) { - logger.error('No Lavalink nodes configured. Add lavalink/nodes.json, set LAVALINK_NODES, or set LAVALINK_HOST in your environment.'); - return; - } - - client.riffy = new Riffy(client, lavalinkConfig.nodes, { - send: (payload) => { - const guildId = payload.d?.guild_id; - if (!guildId) { - return; - } - - const guild = client.guilds.cache.get(guildId); - if (guild?.shard) { - guild.shard.send(payload); - return; - } - - const shardCount = client.ws.shards.size || 1; - const shardId = Number((BigInt(guildId) >> 22n) % BigInt(shardCount)); - client.ws.shards.get(shardId)?.send(payload); - }, - defaultSearchPlatform: lavalinkConfig.defaultSearchPlatform, - restVersion: lavalinkConfig.restVersion, - bypassChecks: { - nodeFetchInfo: true, - }, - }); - - setupPlayerHandler(client); - - client.on('raw', (packet) => { - if ( - ![ - GatewayDispatchEvents.VoiceStateUpdate, - GatewayDispatchEvents.VoiceServerUpdate, - ].includes(packet.t) - ) { - return; - } - client.riffy.updateVoiceState(packet); - }); - - client.riffy.on('playerError', (player, error) => { - logger.error(`Music player error in guild ${player.guildId}:`, error); - }); - - logger.info(`Music initialized with ${lavalinkConfig.nodes.length} Lavalink node(s).`); -} - -export function initRiffyAfterReady(client) { - if (client.riffy && client.user?.id) { - client.riffy.init(client.user.id); - logger.info('Riffy voice connection manager initialized.'); - } -} +// Music playback is removed; no Lavalink client or voice listeners are started. +export function initializeMusic() { logger.info('Music playback is disabled.'); } +export function initRiffyAfterReady() {}