diff --git a/.replit b/.replit new file mode 100644 index 000000000..45d746fd8 --- /dev/null +++ b/.replit @@ -0,0 +1,51 @@ +modules = ["nodejs-18"] +[agent] +expertMode = true +stack = "BEST_EFFORT_FALLBACK" + +[nix] +channel = "stable-25_05" + +[userenv] + +[userenv.shared] +NODE_ENV = "development" +LOG_LEVEL = "info" +LOG_TO_FILE = "false" +PORT = "3000" +WEB_HOST = "0.0.0.0" +PORT_RETRY_ATTEMPTS = "5" +CORS_ORIGIN = "*" +AUTO_MIGRATE = "true" +POSTGRES_MIGRATION_TABLE = "schema_migrations" +SCHEMA_VERSION = "1" +SCHEMA_VERSION_LABEL = "baseline-v1" +BACKUP_DIR = "./backups" +BACKUP_RETENTION_DAYS = "14" + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "node src/app.js" + +[workflows.workflow.metadata] +outputType = "console" + +[[ports]] +localPort = 3000 +externalPort = 80 diff --git a/package-lock.json b/package-lock.json index 94e61bfc1..70023ff9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "titanbot-custom", - "version": "2.0.0", + "name": "starlight-security", + "version": "1.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "titanbot-custom", - "version": "2.0.0", + "name": "starlight-security", + "version": "1.1.1", "dependencies": { "@discordjs/rest": "^2.6.1", "axios": "^1.15.2", diff --git a/package.json b/package.json index a241fb08b..730a2143e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "titanbot-custom", + "name": "starlight-security", "version": "1.1.1", - "description": "Modular Ultimate Community Bot by Touchpoint Support", + "description": "Starlight Security - Advanced Discord Bot", "main": "src/app.js", "type": "module", "scripts": { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 4ad58be10..ed9b66fd1 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -1,234 +1,17 @@ -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"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const CATEGORY_SELECT_ID = "help-category-select"; -const ALL_COMMANDS_ID = "help-all-commands"; -const BUG_REPORT_BUTTON_ID = "help-bug-report"; -const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; - -const CATEGORY_ICONS = { - Core: "ℹ️", - Moderation: "🛡️", - Economy: "💰", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - Reaction_Roles: "🎭", - Community: "👥", - Birthday: "🎂", - Config: "⚙️", -}; - - - - - -export async function createInitialHelpMenu(client) { - const commandsPath = path.join(__dirname, "../../commands"); - const categoryDirs = ( - await fs.readdir(commandsPath, { withFileTypes: true }) - ) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .sort(); - - const options = [ - { - label: "📋 All Commands", - description: "View all available commands with pagination", - value: ALL_COMMANDS_ID, - }, - ...categoryDirs.map((category) => { - const categoryName = - category.charAt(0).toUpperCase() + - category.slice(1).toLowerCase(); - const icon = CATEGORY_ICONS[categoryName] || "🔍"; - return { - label: `${icon} ${categoryName}`, - description: `View commands in the ${categoryName} category`, - value: category, - }; - }), - ]; - - const botName = client?.user?.username || "Bot"; - const embed = createEmbed({ - title: `🤖 ${botName} Help Center`, - description: "Your all-in-one Discord companion for moderation, economy, fun, and server management.", - color: 'primary' - }); - - embed.addFields( - { - name: "🛡️ **Moderation**", - value: "Server moderation, user management, and enforcement tools", - inline: true - }, - { - name: "💰 **Economy**", - value: "Currency system, shops, and virtual economy", - inline: true - }, - { - name: "🎮 **Fun**", - value: "Games, entertainment, and interactive commands", - inline: true - }, - { - name: "📊 **Leveling**", - value: "User levels, XP system, and progression tracking", - inline: true - }, - { - name: "🎫 **Tickets**", - value: "Support ticket system for server management", - inline: true - }, - { - name: "🎉 **Giveaways**", - value: "Automated giveaway management and distribution", - inline: true - }, - { - name: "👋 **Welcome**", - value: "Member welcome messages and onboarding", - inline: true - }, - { - name: "🎂 **Birthdays**", - value: "Birthday tracking and celebration features", - inline: true - }, - { - name: "👥 **Community**", - value: "Community tools, applications, and member engagement", - inline: true - }, - { - name: "⚙️ **Config**", - value: "Server and bot configuration management commands", - inline: true - }, - { - name: "🔢 **Counter**", - value: "Live counter channel setup and counter controls", - inline: true - }, - { - name: "🎙️ **Join to Create**", - value: "Dynamic voice channel creation and management", - inline: true - }, - { - name: "🎭 **Reaction Roles**", - value: "Self-assignable roles using reaction-role systems", - inline: true - }, - { - name: "✅ **Verification**", - value: "Member verification workflows and access gating", - inline: true - }, - { - name: "🔧 **Utilities**", - value: "Useful tools and server utilities", - inline: true - } - ); - - embed.setFooter({ - text: "Made with ❤️" - }); - embed.setTimestamp(); - - const bugReportButton = new ButtonBuilder() - .setCustomId(BUG_REPORT_BUTTON_ID) - .setLabel("Report Bug") - .setStyle(ButtonStyle.Danger); - - const supportButton = new ButtonBuilder() - .setLabel("Support Server") - .setURL("https://discord.gg/QnWNz2dKCE") - .setStyle(ButtonStyle.Link); - - const touchpointButton = new ButtonBuilder() - .setLabel("Learn from Touchpoint") - .setURL("https://www.youtube.com/@TouchDisc") - .setStyle(ButtonStyle.Link); - - const selectRow = createSelectMenu( - CATEGORY_SELECT_ID, - "Select to view the commands", - options, - ); - - const buttonRow = new ActionRowBuilder().addComponents([ - bugReportButton, - supportButton, - touchpointButton, - ]); - - return { - embeds: [embed], - components: [buttonRow, selectRow], - }; -} +import { SlashCommandBuilder } from "discord.js"; +import { createInitialHelpMenu } from "../../utils/helpMenuHelper.js"; export default { data: new SlashCommandBuilder() .setName("help") - .setDescription("Displays the help menu with all available commands"), - + .setDescription("Displays the help menu"), + async execute(interaction, guildConfig, client) { + // Lấy client an toàn + const activeClient = client || interaction.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 { - const closedEmbed = createEmbed({ - title: "Help menu closed", - description: "Help menu has been closed, use /help again.", - color: "secondary", - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [closedEmbed], - components: [], - }); - } catch (error) { - - } - }, HELP_MENU_TIMEOUT_MS); + await interaction.deferReply({ ephemeral: true }); + const { embeds, components } = await createInitialHelpMenu(activeClient); + await interaction.editReply({ embeds, components }); }, }; - - diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 16cd240eb..000000000 --- a/src/commands/Economy/balance.js +++ /dev/null @@ -1,86 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('balance') - .setDescription("Check your or someone else's balance") - .addUserOption(option => - option - .setName('user') - .setDescription('User to check balance for') - .setRequired(false) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const targetUser = interaction.options.getUser("user") || interaction.user; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); - - if (targetUser.bot) { - throw createError( - "Bot user queried for balance", - ErrorTypes.VALIDATION, - "Bots don't have an economy balance." - ); - } - - const userData = await getEconomyData(client, guildId, targetUser.id); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { userId: targetUser.id, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - - const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0; - const bank = typeof userData.bank === 'number' ? userData.bank : 0; - - const embed = createEmbed({ - title: `💰 ${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, - }) - .addFields( - { - name: "💵 Cash", - value: `$${wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 Bank", - value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - { - name: "💎 Total", - value: `$${(wallet + bank).toLocaleString()}`, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'balance' }) -}; - - - - diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js deleted file mode 100644 index 260ebc656..000000000 --- a/src/commands/Economy/beg.js +++ /dev/null @@ -1,103 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = 50; -const MAX_WIN = 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = MessageTemplates.SUCCESS.DATA_UPDATED( - "begging", - successMessages[ - Math.floor(Math.random() * successMessages.length) - ] - ); - } else { - const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", - ]; - - replyEmbed = MessageTemplates.ERRORS.INSUFFICIENT_FUNDS( - "nothing", - "You failed to get any money from begging." - ); - replyEmbed.data.description = failMessages[Math.floor(Math.random() * failMessages.length)]; - } - - userData.wallet = newCash; -userData.lastBeg = Date.now(); - - await setEconomyData(client, guildId, userId, userData); - - await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); - }, { command: 'beg' }) -}; - - diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js deleted file mode 100644 index 72f4c62bb..000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,163 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**👑 The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; - - - - - diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js deleted file mode 100644 index 2403fe461..000000000 --- a/src/commands/Economy/crime.js +++ /dev/null @@ -1,122 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const MIN_CRIME_AMOUNT = 100; -const MAX_CRIME_AMOUNT = 2000; -const FAILURE_RATE = 0.4; -const JAIL_TIME = 2 * 60 * 60 * 1000; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName('crime') - .setDescription('Commit a crime to earn money (risky)') - .addStringOption(option => - option - .setName('type') - .setDescription('Type of crime to commit') - .setRequired(true) - .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, - ) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastCrime = userData.cooldowns?.crime || 0; - const isJailed = userData.jailedUntil && userData.jailedUntil > now; - - if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); - throw createError( - "User is in jail", - ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } - ); - } - - if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); - throw createError( - "Crime cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, - { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } - ); - } - - const crimeType = interaction.options.getString("type").toLowerCase(); - const crime = CRIME_TYPES.find( - c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType - ); - - if (!crime) { - throw createError( - "Invalid crime type", - ErrorTypes.VALIDATION, - "Please select a valid crime type.", - { crimeType } - ); - } - - const isSuccess = Math.random() > crime.risk; - const amountEarned = isSuccess - ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min - : 0; - - userData.cooldowns = userData.cooldowns || {}; - userData.cooldowns.crime = now; - - if (isSuccess) { - userData.wallet = (userData.wallet || 0) + amountEarned; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - const fine = Math.floor(amountEarned * 0.2); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = errorEmbed( - "Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; - - diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js deleted file mode 100644 index ba3d9bee3..000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,107 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { formatDuration } from '../../utils/helpers.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = 1000; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "✅ Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; - - - - diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js deleted file mode 100644 index 79cab9b42..000000000 --- a/src/commands/Economy/deposit.js +++ /dev/null @@ -1,144 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') - .addStringOption(option => - option - .setName('amount') - .setDescription('Amount to deposit (number or "all")') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - let depositAmount; - - if (amountInput.toLowerCase() === "all") { - depositAmount = userData.wallet; - } else { - depositAmount = parseInt(amountInput); - - if (isNaN(depositAmount) || depositAmount <= 0) { - throw createError( - "Invalid deposit amount", - ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, - { amountInput, userId } - ); - } - } - - if (depositAmount === 0) { - throw createError( - "Zero deposit amount", - ErrorTypes.VALIDATION, - "You have no cash to deposit.", - { userId, walletBalance: userData.wallet } - ); - } - - if (depositAmount > userData.wallet) { - depositAmount = userData.wallet; - await interaction.followUp({ - embeds: [ - MessageTemplates.ERRORS.INVALID_INPUT( - "deposit amount", - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: ["Ephemeral"], - }); - } - - const availableSpace = maxBank - userData.bank; - - if (availableSpace <= 0) { - throw createError( - "Bank is full", - ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, - { maxBank, currentBank: userData.bank, userId } - ); - } - - if (depositAmount > availableSpace) { - const originalDepositAmount = depositAmount; - depositAmount = availableSpace; - - if (amountInput.toLowerCase() !== "all") { - await interaction.followUp({ - embeds: [ - MessageTemplates.ERRORS.INVALID_INPUT( - "deposit amount", - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: ["Ephemeral"], - }); - } - } - - if (depositAmount === 0) { - throw createError( - "No space or cash for deposit", - ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", - { depositAmount, availableSpace, walletBalance: userData.wallet } - ); - } - - userData.wallet -= depositAmount; - userData.bank += depositAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "deposit", - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` - ) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 New Bank Balance", - value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'deposit' }) -}; - - - - - diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js deleted file mode 100644 index d96711690..000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,94 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") - .setDMPermission(false), - - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - - const prefix = `economy:${guildId}:`; - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["🥇", "🥈", "🥉"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - 🏦 ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - const description = leaderboardEntries.length > 0 - ? leaderboardEntries.join("\n") - : "No economy data is available for this server yet."; - - const embed = createEmbed({ - title: `Economy Leaderboard`, - description, - footer: `Your Rank: ${userRank > 0 ? `#${userRank}` : "No ranking data available"}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'eleaderboard' }) -}; - - - - - diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js deleted file mode 100644 index 367ad453e..000000000 --- a/src/commands/Economy/fish.js +++ /dev/null @@ -1,135 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; - -const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐟', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐟', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '🐙', rarity: 'rare' }, - { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, -]; - -const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", -]; - -export default { - data: new SlashCommandBuilder() - .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; - - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Fishing cooldown active", - ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, - { remaining, cooldownType: 'fish' } - ); - } - - - const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); - } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); - } - - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; - } - - const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; - - userData.wallet += finalEarned; - userData.lastFish = now; - - await setEconomyData(client, guildId, userId, userData); - - const rarityColors = { - common: '#95A5A6', - uncommon: '#2ECC71', - rare: '#3498DB', - epic: '#9B59B6', - legendary: '#F1C40F' - }; - - const embed = createEmbed({ - title: '🎣 Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - color: rarityColors[fishCaught.rarity] - }) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🐟 Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), - inline: true, - } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'fish' }) -}; diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js deleted file mode 100644 index d0bb7c3bd..000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,136 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const BASE_WIN_CHANCE = 0.4; -const CLOVER_WIN_BONUS = 0.1; -const CHARM_WIN_BONUS = 0.08; -const PAYOUT_MULTIPLIER = 2.0; -const GAMBLE_COOLDOWN = 5 * 60 * 1000; - -export default { - data: new SlashCommandBuilder() - .setName('gamble') - .setDescription('Gamble your money for a chance to win more') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount of cash to gamble') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastGamble = userData.lastGamble || 0; - let cloverCount = userData.inventory["lucky_clover"] || 0; - let charmCount = userData.inventory["lucky_charm"] || 0; - - if (now < lastGamble + GAMBLE_COOLDOWN) { - const remaining = lastGamble + GAMBLE_COOLDOWN - now; - const minutes = Math.floor(remaining / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); - - throw createError( - "Gamble cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, - { remaining, cooldownType: 'gamble' } - ); - } - - if (userData.wallet < betAmount) { - throw createError( - "Insufficient cash for gamble", - ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, - { required: betAmount, current: userData.wallet } - ); - } - - let winChance = BASE_WIN_CHANCE; - let cloverMessage = ""; - let usedClover = false; - let usedCharm = false; - - - if (cloverCount > 0) { - winChance += CLOVER_WIN_BONUS; - userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\n🍀 **Lucky Clover Consumed:** Your win chance was boosted!`; - usedClover = true; - } - - else if (charmCount > 0) { - winChance += CHARM_WIN_BONUS; - userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\n🍀 **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; - usedCharm = true; - } - - const win = Math.random() < winChance; - let cashChange = 0; - let resultEmbed; - - if (win) { - const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); -cashChange = amountWon; - - resultEmbed = successEmbed( - "🎉 You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = errorEmbed( - "💔 You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, - ); - } - - userData.wallet = (userData.wallet || 0) + cashChange; -userData.lastGamble = now; - - await setEconomyData(client, guildId, userId, userData); - - const newCash = userData.wallet; - - resultEmbed.addFields({ - name: "💵 New Cash Balance", - value: `$${newCash.toLocaleString()}`, - inline: true, - }); - - if (usedClover) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else if (usedCharm) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else { - resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, - }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'gamble' }) -}; - - - - diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js deleted file mode 100644 index 7369c8388..000000000 --- a/src/commands/Economy/inventory.js +++ /dev/null @@ -1,74 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('inventory') - .setDescription('View your economy inventory'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for inventory", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } - - logger.info(`[ECONOMY] Inventory retrieved`, { - userId, - guildId, - itemCount: Object.keys(inventory).length - }); - - const embed = createEmbed({ - title: `📦 ${interaction.user.username}'s Inventory`, - description: inventoryDescription, - }).setThumbnail(interaction.user.displayAvatarURL()); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'inventory' }) -}; - - - - diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js deleted file mode 100644 index f8dd54cba..000000000 --- a/src/commands/Economy/mine.js +++ /dev/null @@ -1,98 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const MINE_COOLDOWN = 60 * 60 * 1000; -const BASE_MIN_REWARD = 400; -const BASE_MAX_REWARD = 1200; -const PICKAXE_MULTIPLIER = 1.2; -const DIAMOND_PICKAXE_MULTIPLIER = 2.0; - -const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", -]; - -export default { - data: new SlashCommandBuilder() - .setName('mine') - .setDescription('Go mining to earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastMine = userData.lastMine || 0; - const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; - const hasPickaxe = userData.inventory["pickaxe"] || 0; - - if (now < lastMine + MINE_COOLDOWN) { - const remaining = lastMine + MINE_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Mining cooldown active", - ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, - { remaining, cooldownType: 'mine' } - ); - } - - const baseEarned = - Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1), - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasDiamondPickaxe > 0) { - finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\n💎 **Diamond Pickaxe Bonus: +100%**`; - } else if (hasPickaxe > 0) { - finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\n⛏️ **Pickaxe Bonus: +20%**`; - } - - const location = - MINE_LOCATIONS[ - Math.floor(Math.random() * MINE_LOCATIONS.length) - ]; - - userData.wallet += finalEarned; -userData.lastMine = now; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "💰 Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - ) - .addFields({ - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ text: `Next mine available in 1 hour.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'mine' }) -}; - - - - diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js deleted file mode 100644 index 53ae62d4e..000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,90 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { shopItems } from '../../../config/shop/items.js'; -import { getColor } from '../../../config/bot.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); - const embed = new EmbedBuilder() - .setTitle('🛒 Store') - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `🏷️ **Type:** ${item.type}\n💚 **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; - - const createShopComponents = (page) => { - if (totalPages <= 1) return []; - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('⬅️ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next ➡️') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages), - ), - ]; - }; - - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0, - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000, - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ content: '❌ You cannot use these buttons. Run `/shop browse` to get your own shop view.', flags: 64 }); - return; - } - const { customId } = buttonInteraction; - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) currentPage--; - else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); - await message.edit({ components: disabledComponents }); - } catch (_) {} - }); - } catch (error) { - logger.error('shop_browse error:', error); - await interaction.reply({ content: '❌ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); - } - }, -}; diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js deleted file mode 100644 index aaf52f33f..000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,36 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the premium role.')], - ephemeral: true, - }); - } - - const role = interaction.options.getRole('role'); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - currentConfig.premiumRoleId = role.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('✅ Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], - ephemeral: true, - }); - } catch (error) { - logger.error('shop_config_setrole error:', error); - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('System Error', 'Could not save the guild configuration.')], - ephemeral: true, - }); - } - }, -}; diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js deleted file mode 100644 index 40f6424be..000000000 --- a/src/commands/Economy/pay.js +++ /dev/null @@ -1,158 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import EconomyService from '../../services/economyService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('pay') - .setDescription('Pay another user some of your cash') - .addUserOption(option => - option - .setName('user') - .setDescription('User to pay') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to pay') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Pay command initiated`, { - senderId, - receiverId: receiver.id, - amount, - guildId - }); - - if (receiver.bot) { - throw createError( - "Cannot pay bot", - ErrorTypes.VALIDATION, - "You cannot pay a bot.", - { receiverId: receiver.id, isBot: true } - ); - } - - if (receiver.id === senderId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId: receiver.id } - ); - } - - if (amount <= 0) { - throw createError( - "Invalid payment amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiver.id) - ]); - - if (!senderData) { - throw createError( - "Failed to load sender economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId: senderId, guildId } - ); - } - - if (!receiverData) { - throw createError( - "Failed to load receiver economy data", - ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", - { userId: receiver.id, guildId } - ); - } - - - - const result = await EconomyService.transferMoney( - client, - guildId, - senderId, - receiver.id, - amount - ); - - - const updatedSenderData = await getEconomyData(client, guildId, senderId); - const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "payment", - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` - ) - .addFields( - { - name: "💳 Payment Amount", - value: `$${amount.toLocaleString()}`, - inline: true, - }, - { - name: "💵 Your New Balance", - value: `$${updatedSenderData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ - text: `Paid to ${receiver.tag}`, - iconURL: receiver.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info(`[ECONOMY] Payment sent successfully`, { - senderId, - receiverId: receiver.id, - amount, - senderBalance: updatedSenderData.wallet, - receiverBalance: updatedReceiverData.wallet - }); - - try { - const receiverEmbed = createEmbed({ - title: "💰 Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` - }).addFields({ - name: "Your New Cash", - value: `$${updatedReceiverData.wallet.toLocaleString()}`, - inline: true, - }); - await receiver.send({ embeds: [receiverEmbed] }); - } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); - } - }, { command: 'pay' }) -}; - - - - - diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js deleted file mode 100644 index d6ae21496..000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,156 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const ROB_COOLDOWN = 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = 0.25; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - MessageTemplates.ERRORS.CONFIGURATION_REQUIRED( - "robbery protection", - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` - ) - ], - }); - } - - const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE; - let resultEmbed; - - if (isSuccessful) { - const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE); - - robberData.wallet = (robberData.wallet || 0) + amountStolen; - victimData.wallet = (victimData.wallet || 0) - amountStolen; - - resultEmbed = MessageTemplates.SUCCESS.DATA_UPDATED( - "robbery", - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` - ); - } else { - const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); - - if ((robberData.wallet || 0) < fineAmount) { - robberData.wallet = 0; - } else { - robberData.wallet = (robberData.wallet || 0) - fineAmount; - } - - resultEmbed = MessageTemplates.ERRORS.INSUFFICIENT_PERMISSIONS( - "robbery failed", - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.` - ); - } - - robberData.lastRob = now; - - await setEconomyData(client, guildId, robberId, robberData); - await setEconomyData(client, guildId, victimUser.id, victimData); - - resultEmbed - .addFields( - { - name: `Your New Cash (${interaction.user.username})`, - value: `$${robberData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: `Victim's New Cash (${victimUser.username})`, - value: `$${victimData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ text: `Next robbery available in 4 hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; - - - diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js deleted file mode 100644 index 6ebdfe458..000000000 --- a/src/commands/Economy/shop.js +++ /dev/null @@ -1,60 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -import shopBrowse from './modules/shop_browse.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Economy shop commands.') - .addSubcommand(subcommand => - subcommand - .setName('browse') - .setDescription('Browse the economy shop.'), - ) - .addSubcommandGroup(group => - group - .setName('config') - .setDescription('Configure shop settings. (Manage Server required)') - .addSubcommand(subcommand => - subcommand - .setName('setrole') - .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') - .addRoleOption(option => - option - .setName('role') - .setDescription('The role to grant for Premium Role purchases.') - .setRequired(true), - ), - ), - ), - - async execute(interaction, config, client) { - try { - const subcommandGroup = interaction.options.getSubcommandGroup(false); - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'browse') { - return await shopBrowse.execute(interaction, config, client); - } - - if (subcommandGroup === 'config' && subcommand === 'setrole') { - return await shopConfigSetrole.execute(interaction, config, client); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Unknown subcommand.')], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error('shop command error:', error); - await InteractionHelper.safeReply(interaction, { - content: '❌ An error occurred while running the shop command.', - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js deleted file mode 100644 index 3d4791cf9..000000000 --- a/src/commands/Economy/slut.js +++ /dev/null @@ -1,193 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SLUT_COOLDOWN = 45 * 60 * 1000; - -const SLUT_ACTIVITIES = [ - { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, - { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, - { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, - { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, - { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, -]; - -const POSITIVE_OUTCOMES = [ - "Your stream blew up and tips poured in.", - "A VIP booking paid far above average.", - "Your after-hours shift was packed and profitable.", - "Premium requests came through and your payout jumped.", -]; - -const FINE_OUTCOMES = [ - "Venue security issued a compliance fine.", - "A moderation strike triggered a platform fee.", - "You were flagged and had to pay a penalty.", -]; - -const ROBBED_OUTCOMES = [ - "A fake buyer chargeback wiped part of your earnings.", - "A scam booking cleaned out a chunk of your cash.", - "You got baited by a fraud account and lost money.", -]; - -const LOSS_OUTCOMES = [ - "The set flopped and you had to cover operating costs.", - "You burned budget on prep and made no return.", - "The shift went sideways and left you in the red.", -]; - -function randomInt(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function randomChoice(items) { - return items[Math.floor(Math.random() * items.length)]; -} - -function resolveOutcome(activity, wallet) { - const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); - const fineChance = 0.22; - const robbedChance = 0.2; - const roll = Math.random(); - - if (roll < successChance) { - const amount = randomInt(activity.min, activity.max); - return { - type: 'payout', - delta: amount, - message: randomChoice(POSITIVE_OUTCOMES), - title: `💰 ${activity.name} - Payout` - }; - } - - const remainingAfterSuccess = roll - successChance; - - if (remainingAfterSuccess < fineChance) { - const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4))); - const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2))); - const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0; - return { - type: 'fine', - delta: -amount, - message: randomChoice(FINE_OUTCOMES), - title: `🚨 ${activity.name} - Fined` - }; - } - - if (remainingAfterSuccess < fineChance + robbedChance) { - const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35))); - const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1))); - const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0; - return { - type: 'robbed', - delta: -amount, - message: randomChoice(ROBBED_OUTCOMES), - title: `🕵️ ${activity.name} - Robbed` - }; - } - - const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3))); - const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15))); - const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0; - return { - type: 'loss', - delta: -amount, - message: randomChoice(LOSS_OUTCOMES), - title: `❌ ${activity.name} - Loss` - }; -} - -export default { - data: new SlashCommandBuilder() - .setName('slut') - .setDescription('Take a risky provocative job for random payout or loss'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for slut command", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastSlut = userData.lastSlut || 0; - - if (now - lastSlut < SLUT_COOLDOWN) { - const remainingTime = lastSlut + SLUT_COOLDOWN - now; - throw createError( - "Slut cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, - { timeRemaining: remainingTime, cooldownType: 'slut' } - ); - } - - const activity = randomChoice(SLUT_ACTIVITIES); - - const outcome = resolveOutcome(activity, userData.wallet || 0); - - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta); - userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta); - - if (outcome.type !== 'payout') { - userData.failedSluts = (userData.failedSluts || 0) + 1; - } - - userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta); - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, { - userId, - guildId, - activity: activity.name, - outcomeType: outcome.type, - amountDelta: outcome.delta, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; - const summaryLines = [ - `${outcome.message}`, - `💸 **Net Result:** ${amountLabel}`, - `💳 **Current Balance:** $${userData.wallet.toLocaleString()}`, - `📊 **Total Sessions:** ${userData.totalSluts}`, - `💵 **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, - `🧾 **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` - ]; - - const embed = createEmbed({ - title: outcome.title, - description: summaryLines.join('\n'), - color: outcome.delta >= 0 ? 'success' : 'error', - timestamp: true - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'slut' }) -}; - - - - - diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js deleted file mode 100644 index 2fc0b46a1..000000000 --- a/src/commands/Economy/withdraw.js +++ /dev/null @@ -1,86 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { MessageTemplates } from '../../utils/messageTemplates.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to withdraw') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - let withdrawAmount = amountInput; - - if (withdrawAmount <= 0) { - throw createError( - "Invalid withdrawal amount", - ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", - { amount: withdrawAmount, userId } - ); - } - - if (withdrawAmount > userData.bank) { - withdrawAmount = userData.bank; - } - - if (withdrawAmount === 0) { - throw createError( - "Empty bank account", - ErrorTypes.VALIDATION, - "Your bank account is empty.", - { userId, bankBalance: userData.bank } - ); - } - - userData.wallet += withdrawAmount; - userData.bank -= withdrawAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = MessageTemplates.SUCCESS.DATA_UPDATED( - "withdrawal", - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` - ) - .addFields( - { - name: "💵 New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "🏦 New Bank Balance", - value: `$${userData.bank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'withdraw' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js deleted file mode 100644 index ca39b2077..000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,127 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const WORK_COOLDOWN = 30 * 60 * 1000; -const MIN_WORK_AMOUNT = 50; -const MAX_WORK_AMOUNT = 300; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\n💻 **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "💼 Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "💰 New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "⏰ Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; - - - - diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js index 657359233..d62e9f062 100644 --- a/src/commands/Moderation/ban.js +++ b/src/commands/Moderation/ban.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { ModerationService } from '../../services/moderationService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { PunishmentService } from '../../services/punishmentService.js'; export default { data: new SlashCommandBuilder() .setName("ban") @@ -26,6 +27,14 @@ export default { const user = interaction.options.getUser("target"); const reason = interaction.options.getString("reason") || "No reason provided"; + // Missing args guard + if (!user) { + return handleInteractionError(interaction, + Object.assign(new Error("Please mention the user to ban.\nUsage: `nh!ban @user [reason]`"), { userMessage: "Please mention the user to ban.\nUsage: `nh!ban @user [reason]`" }), + { subtype: 'missing_target' } + ); + } + if (user.id === interaction.user.id) { throw new Error("You cannot ban yourself."); } @@ -41,6 +50,15 @@ export default { reason }); + PunishmentService.record({ + guildId: interaction.guildId, + userId: user.id, + moderatorId: interaction.user.id, + action: 'BAN', + reason, + caseId: result.caseId + }).catch(e => logger.warn('Failed to record ban punishment:', e.message)); + await InteractionHelper.universalReply(interaction, { embeds: [ successEmbed( diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js index ad1659249..beadbf126 100644 --- a/src/commands/Moderation/cases.js +++ b/src/commands/Moderation/cases.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getModerationCases } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; + export default { data: new SlashCommandBuilder() .setName('cases') @@ -42,9 +43,12 @@ export default { return; } + let targetUser = null; + let filterType = 'all'; + try { - const filterType = interaction.options.getString('filter') || 'all'; - const targetUser = interaction.options.getUser('user'); + filterType = interaction.options.getString('filter') || 'all'; + targetUser = interaction.options.getUser('user'); const limit = interaction.options.getInteger('limit') || 10; const filters = { @@ -55,11 +59,22 @@ export default { const cases = await getModerationCases(interaction.guild.id, filters); - if (cases.length === 0) { - throw new Error(targetUser - ? `No moderation cases found for ${targetUser.tag}` - : `No ${filterType === 'all' ? '' : filterType} cases found in this server.` - ); + if (!cases || cases.length === 0) { + const noCaseMessage = targetUser + ? `No cases found for user ${targetUser.tag} (ID: ${targetUser.id}).` + : `No cases found for filter "${filterType === 'all' ? 'All' : filterType}" in this server.`; + + // Dùng createEmbed thường để hiện màu xanh, không bị dính chữ ❌ Error ép buộc + const infoEmbed = createEmbed({ + title: '📋 No cases found', + description: noCaseMessage, + color: 0x3498db + }); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [infoEmbed], + flags: MessageFlags.Ephemeral + }); } const CASES_PER_PAGE = 5; @@ -126,7 +141,7 @@ export default { const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, -time: 120000 + time: 120000 }); collector.on('collect', async (buttonInteraction) => { @@ -163,16 +178,19 @@ time: 120000 components: [disabledRow] }); } catch (error) { + logger.debug('Could not disable cases buttons (message may have been deleted):', error.message); } }); } catch (error) { logger.error('Error in cases command:', error); + + // ĐÃ SỬA: In thẳng chi tiết lỗi cụ thể ra Discord để bạn biết chính xác lý do gãy lệnh return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( 'System Error', - 'An error occurred while retrieving moderation cases. Please try again later.' + `An error occurred: \`${error.message || error}\`\nPlease check this error in your database configuration.` ) ], flags: MessageFlags.Ephemeral @@ -180,7 +198,3 @@ time: 120000 } } }; - - - - diff --git a/src/commands/Moderation/history.js b/src/commands/Moderation/history.js new file mode 100644 index 000000000..54ef7eafb --- /dev/null +++ b/src/commands/Moderation/history.js @@ -0,0 +1,172 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { getColor } from '../../config/bot.js'; +import { PunishmentService } from '../../services/punishmentService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +const ACTION_EMOJI = { + BAN: '🔨', + KICK: '👢', + TIMEOUT: '⏳', + WARN: '⚠️', +}; + +const ACTION_COLOR = { + BAN: 0xe74c3c, + KICK: 0xe67e22, + TIMEOUT: 0xf39c12, + WARN: 0xf1c40f, +}; + +export default { + data: new SlashCommandBuilder() + .setName('history') + .setDescription('View full punishment history for a user') + .addUserOption(o => + o.setName('target').setRequired(true).setDescription('User to look up') + ) + .addStringOption(o => + o.setName('filter') + .setDescription('Filter by punishment type') + .addChoices( + { name: 'All', value: 'ALL' }, + { name: 'Bans', value: 'BAN' }, + { name: 'Kicks', value: 'KICK' }, + { name: 'Timeouts', value: 'TIMEOUT' }, + { name: 'Warns', value: 'WARN' }, + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + category: 'moderation', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + try { + const target = interaction.options.getUser('target'); + if (!target) { + throw new TitanBotError('Missing target', ErrorTypes.USER_INPUT, + 'Please mention the user to look up.\nUsage: `nh!history @user`'); + } + + const filter = interaction.options.getString('filter') || 'ALL'; + const guildId = interaction.guildId; + + let history = await PunishmentService.getUserHistory(guildId, target.id, 100); + + if (filter !== 'ALL') { + history = history.filter(p => p.action === filter); + } + + const counts = await PunishmentService.countByAction(guildId, target.id); + + if (history.length === 0) { + const embed = createEmbed({ + title: `📋 History: ${target.tag}`, + description: filter === 'ALL' + ? '✅ This user has no punishment records.' + : `✅ No **${filter}** records found for this user.` + }).setColor(getColor('success')) + .setThumbnail(target.displayAvatarURL({ dynamic: true })); + + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } + + const PER_PAGE = 5; + const totalPages = Math.ceil(history.length / PER_PAGE); + let page = 1; + + const buildEmbed = (p) => { + const start = (p - 1) * PER_PAGE; + const slice = history.slice(start, start + PER_PAGE); + + const embed = createEmbed({ + title: `📋 Punishment History: ${target.tag}`, + description: [ + `🔨 Bans: **${counts.BAN}** | 👢 Kicks: **${counts.KICK}** | ⏳ Timeouts: **${counts.TIMEOUT}** | ⚠️ Warns: **${counts.WARN}**`, + `Total records: **${history.length}**${filter !== 'ALL' ? ` (filtered: ${filter})` : ''}`, + ].join('\n') + }) + .setColor(0xe74c3c) + .setThumbnail(target.displayAvatarURL({ dynamic: true })) + .setFooter({ text: `Page ${p}/${totalPages} • ${target.id}` }); + + for (const record of slice) { + const ts = Math.floor( + new Date(record.created_at || record.createdAt || Date.now()).getTime() / 1000 + ); + const emoji = ACTION_EMOJI[record.action] ?? '📌'; + const modId = record.moderator_id || record.moderatorId; + const statusTag = record.active === false ? ' ~~revoked~~' : ''; + const durationStr = record.duration_minutes + ? ` | Duration: **${formatDuration(record.duration_minutes)}**` + : ''; + + embed.addFields({ + name: `${emoji} ${record.action}${statusTag} — `, + value: [ + `**Reason:** ${record.reason || 'No reason provided'}`, + `**Moderator:** <@${modId}> | ${durationStr}`, + record.case_id || record.caseId ? `**Case:** #${record.case_id || record.caseId}` : '' + ].filter(Boolean).join('\n'), + inline: false + }); + } + + return embed; + }; + + const buildRow = (p) => { + const row = new ActionRowBuilder(); + row.addComponents( + new ButtonBuilder().setCustomId('hist_prev').setLabel('⬅ Prev').setStyle(ButtonStyle.Secondary).setDisabled(p <= 1), + new ButtonBuilder().setCustomId('hist_page').setLabel(`${p}/${totalPages}`).setStyle(ButtonStyle.Primary).setDisabled(true), + new ButtonBuilder().setCustomId('hist_next').setLabel('Next ➡').setStyle(ButtonStyle.Secondary).setDisabled(p >= totalPages), + ); + return row; + }; + + const msg = await interaction.editReply({ + embeds: [buildEmbed(page)], + components: totalPages > 1 ? [buildRow(page)] : [] + }); + + if (totalPages <= 1) return; + + const collector = msg.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 120_000, + filter: btn => btn.user.id === interaction.user.id + }); + + collector.on('collect', async btn => { + await btn.deferUpdate(); + if (btn.customId === 'hist_prev' && page > 1) page--; + else if (btn.customId === 'hist_next' && page < totalPages) page++; + await btn.editReply({ embeds: [buildEmbed(page)], components: [buildRow(page)] }); + }); + + collector.on('end', async () => { + const disabledRow = buildRow(page); + disabledRow.components.forEach(b => b.setDisabled(true)); + msg.edit({ components: [disabledRow] }).catch(() => {}); + }); + + } catch (error) { + logger.error('History command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed(error.userMessage || 'Failed to retrieve punishment history.')] + }); + } + } +}; + +function formatDuration(minutes) { + if (minutes % 10080 === 0) return `${minutes / 10080}w`; + if (minutes % 1440 === 0) return `${minutes / 1440}d`; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js index d1902f906..c4116137c 100644 --- a/src/commands/Moderation/kick.js +++ b/src/commands/Moderation/kick.js @@ -4,6 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { PunishmentService } from '../../services/punishmentService.js'; export default { data: new SlashCommandBuilder() @@ -36,7 +37,15 @@ export default { const member = interaction.options.getMember("target"); const reason = interaction.options.getString("reason") || "No reason provided"; - + // Missing args guard + if (!targetUser) { + throw new TitanBotError( + "Missing target", + ErrorTypes.USER_INPUT, + "Please mention the user to kick.\nUsage: `nh!kick @user [reason]`" + ); + } + if (targetUser.id === interaction.user.id) { throw new TitanBotError( "Cannot kick self", @@ -102,6 +111,15 @@ export default { }); + PunishmentService.record({ + guildId: interaction.guildId, + userId: targetUser.id, + moderatorId: interaction.user.id, + action: 'KICK', + reason, + caseId + }).catch(e => logger.warn('Failed to record kick punishment:', e.message)); + await InteractionHelper.universalReply(interaction, { embeds: [ successEmbed( diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 7705db393..7cd0de864 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -1,111 +1,57 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("lock") - .setDescription( - "Locks the current channel (prevents @everyone from sending messages).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Lock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'lock' - }); - return; - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission to lock channels.", - ), - ], - }); - - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + .setName("lock") + .setDescription("Lock the channel for all roles") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Already Locked", - `${channel} is already locked.`, - ), - ], - }); - } + async execute(interaction, config, client) { + const isPrefix = interaction._isPrefix === true; - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: false }, -{ type: 0, reason: `Channel locked by ${interaction.user.tag}` }, - ); - - const lockEmbed = createEmbed( - "🔒 Channel Locked (Action Log)", - `${channel} has been locked down by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Locked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None', - moderatorId: interaction.user.id - } + if (!isPrefix) { + await InteractionHelper.safeDefer(interaction); } - }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔒 **Channel Locked**`, - `${channel} is now locked down. No one can speak here now.`, - ), - ], - }); - } catch (error) { - logger.error('Lock command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred while trying to lock the channel. Check my permissions (I need 'Manage Channels').", - ), - ], - }); + const channel = interaction.channel; + const guild = interaction.guild; + + try { + const overwrites = channel.permissionOverwrites.cache; + const roleIds = [...overwrites.keys(), guild.roles.everyone.id]; + + for (const id of roleIds) { + try { + await channel.permissionOverwrites.edit(id, { SendMessages: false }); + } catch (e) { + logger.debug(`Skipping role ${id}: ${e.message}`); + } + } + + // PHẢN HỒI DỰA VÀO LOẠI LỆNH + const embed = successEmbed("🔒 Channel locked successfully."); + + if (isPrefix) { + await channel.send({ embeds: [embed] }); + } else { + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); + } + + } catch (error) { + logger.error("Critical lock error:", error); + const errEmbed = errorEmbed("Error", "Failed to process lock command."); + + if (isPrefix) { + await channel.send({ embeds: [errEmbed] }); + } else { + await InteractionHelper.safeEditReply(interaction, { embeds: [errEmbed] }); + } + } } - } }; - - - diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js index 1420ea75f..07a2f3d28 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.js @@ -1,135 +1,96 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; -import { checkRateLimit } from '../../utils/rateLimiter.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +const MAX_AMOUNT = 1000; +const BATCH_SIZE = 100; // Discord bulk delete limit +const MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; // 14 days — Discord hard limit for bulkDelete + export default { data: new SlashCommandBuilder() - .setName("purge") - .setDescription("Delete a specific amount of messages") - .addIntegerOption((option) => - option - .setName("amount") - .setDescription("Number of messages (1-100)") - .setRequired(true), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - category: "moderation", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Purge interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'purge' - }); - return; - } + .setName("purge") + .setDescription(`Delete messages in bulk (up to ${MAX_AMOUNT})`) + .addIntegerOption(option => + option.setName("amount") + .setDescription(`Number of messages to delete (1–${MAX_AMOUNT})`) + .setRequired(true) + .setMinValue(1) + .setMaxValue(MAX_AMOUNT) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + async execute(interaction, config, client) { + const isPrefix = interaction._isPrefix === true; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Messages` permission to purge messages.", - ), - ], - }); - - const amount = interaction.options.getInteger("amount"); - const channel = interaction.channel; - - if (amount < 1 || amount > 100) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Amount", - "Please specify a number between 1 and 100.", - ), - ], - }); - - try { - - const rateLimitKey = `purge_${interaction.user.id}`; - const isAllowed = await checkRateLimit(rateLimitKey, 5, 60000); - if (!isAllowed) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - "You're purging messages too fast. Please wait a minute before trying again.", - "⏳ Rate Limited" - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const fetched = await channel.messages.fetch({ limit: amount }); - const deleted = await channel.bulkDelete(fetched, true); - const deletedCount = deleted.size; - - const purgeEmbed = createEmbed( - "🗑️ Messages Purged (Action Log)", - `${deletedCount} messages were deleted by ${interaction.user}.`, - ) -.setColor(getColor('moderation')) - .addFields( - { name: "Channel", value: channel.toString(), inline: true }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - { name: "Count", value: `${deletedCount} messages`, inline: false }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Messages Purged", - target: `${channel} (${deletedCount} messages)`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Deleted ${deletedCount} messages`, - metadata: { - channelId: channel.id, - messageCount: deletedCount, - requestedAmount: amount, - moderatorId: interaction.user.id - } + // Slash: defer ephemerally so the "thinking…" message doesn't count as a channel message + if (!isPrefix) { + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(`🗑️ Deleted ${deletedCount} messages in ${channel}.`), - ], -flags: MessageFlags.Ephemeral, - }); - - setTimeout(() => { - interaction.deleteReply().catch(err => - logger.debug('Failed to auto-delete purge response:', err) - ); - }, 3000); - } catch (error) { - logger.error('Purge command error:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } -}; + let amount = interaction.options.getInteger("amount") ?? 10; + amount = Math.max(1, Math.min(amount, MAX_AMOUNT)); + + const channel = interaction.channel; + + try { + let totalDeleted = 0; + let remaining = amount; + + while (remaining > 0) { + const batchLimit = Math.min(remaining, BATCH_SIZE); + const fetched = await channel.messages.fetch({ limit: batchLimit }); + + if (fetched.size === 0) break; + // bulkDelete only works on messages < 14 days old + const cutoff = Date.now() - MAX_AGE_MS; + const deletable = fetched.filter(msg => msg.createdTimestamp > cutoff); + if (deletable.size === 0) break; + + let deleted = 0; + if (deletable.size === 1) { + await deletable.first().delete(); + deleted = 1; + } else { + const result = await channel.bulkDelete(deletable, true); + deleted = result.size; + } + + totalDeleted += deleted; + remaining -= deleted; + + // Fewer messages fetched than requested → no more messages available + if (fetched.size < batchLimit) break; + + // Small delay between batches to avoid rate-limits on large purges + if (remaining > 0) await new Promise(r => setTimeout(r, 500)); + } + + if (totalDeleted === 0) { + const err = errorEmbed("No Messages Deleted", "No eligible messages found (they may be older than 14 days or system messages)."); + return isPrefix + ? channel.send({ embeds: [err] }) + : InteractionHelper.safeEditReply(interaction, { embeds: [err] }); + } + + const success = successEmbed(`🗑️ Deleted **${totalDeleted}** message${totalDeleted !== 1 ? 's' : ''}.`); + + if (isPrefix) { + const msg = await channel.send({ embeds: [success] }); + setTimeout(() => msg.delete().catch(() => {}), 3000); + } else { + await InteractionHelper.safeEditReply(interaction, { embeds: [success] }); + setTimeout(() => interaction.deleteReply().catch(() => {}), 3000); + } + + } catch (error) { + logger.error("Purge error:", error); + const err = errorEmbed("Error", "Failed to delete messages. They may be older than 14 days or be system messages."); + isPrefix + ? await channel.send({ embeds: [err] }) + : await InteractionHelper.safeEditReply(interaction, { embeds: [err] }); + } + } +}; diff --git a/src/commands/Moderation/quarantine.js b/src/commands/Moderation/quarantine.js new file mode 100644 index 000000000..2a3a205db --- /dev/null +++ b/src/commands/Moderation/quarantine.js @@ -0,0 +1,50 @@ +import { SlashCommandBuilder, PermissionsBitField, Colors } from 'discord.js'; +import { pgDb } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('quarantine') + .setDescription('Quarantine a member') + .addUserOption(option => option.setName('user').setDescription('Member to quarantine').setRequired(true)), + + async execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const member = interaction.options.getMember('user'); + if (!member) return interaction.editReply({ content: 'Member not found.' }); + + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) { + return interaction.editReply({ content: 'You do not have permission.' }); + } + + // Đảm bảo bảng tồn tại + try { + await pgDb.pool.query(`CREATE TABLE IF NOT EXISTS quarantine_data (user_id VARCHAR(20) PRIMARY KEY, roles TEXT NOT NULL)`); + } catch (e) { /* Bỏ qua nếu bảng đã tồn tại */ } + + let role = interaction.guild.roles.cache.find(r => r.name === 'Quarantine'); + if (!role) { + role = await interaction.guild.roles.create({ + name: 'Quarantine', + color: Colors.Red, + reason: 'Auto-created Quarantine role' + }); + } + + const rolesToSave = member.roles.cache.filter(r => r.id !== interaction.guild.id && r.id !== role.id).map(r => r.id); + + try { + await pgDb.pool.query( + 'INSERT INTO quarantine_data (user_id, roles) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET roles = $2', + [member.id, JSON.stringify(rolesToSave)] + ); + + await member.roles.set([role.id]); + await interaction.editReply({ content: `Successfully quarantined ${member.user.tag}.` }); + } catch (error) { + logger.error('Quarantine database error:', error); + await interaction.editReply({ content: 'Failed to apply quarantine. Check role hierarchy.' }); + } + } +}; diff --git a/src/commands/Moderation/quarantinesetup.js b/src/commands/Moderation/quarantinesetup.js new file mode 100644 index 000000000..056e63a73 --- /dev/null +++ b/src/commands/Moderation/quarantinesetup.js @@ -0,0 +1,60 @@ +import { SlashCommandBuilder, PermissionsBitField, Colors } from 'discord.js'; +import { logger } from '../../utils/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('setup-quarantine') + .setDescription('Create and setup the Quarantine role'), + + async execute(interaction) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return interaction.reply({ content: 'You need Administrator permissions!', ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: true }); + + try { + const botMember = await interaction.guild.members.fetch(interaction.client.user.id); + const botTopPosition = botMember.roles.highest.position; + + // Create the role without a position first (goes to bottom by default) + const role = await interaction.guild.roles.create({ + name: 'Quarantine', + color: Colors.Red, + reason: 'Automated setup for Quarantine system', + }); + + // Re-fetch bot member to get accurate position after role creation + // (role creation can shift positions) + const refreshedBot = await interaction.guild.members.fetch(interaction.client.user.id); + const currentBotTop = refreshedBot.roles.highest.position; + + // Place quarantine role exactly 1 below the bot's highest role + await role.setPosition(currentBotTop - 1); + + // Lock all channels from Quarantine role + const channels = interaction.guild.channels.cache; + for (const [, channel] of channels) { + if (channel.permissionOverwrites) { + await channel.permissionOverwrites.create(role, { + ViewChannel: false + }).catch(err => logger.warn(`Failed to update ${channel.name}: ${err.message}`)); + } + } + + // Verify the final position + const finalRole = await interaction.guild.roles.fetch(role.id); + const finalBot = await interaction.guild.members.fetch(interaction.client.user.id); + const gap = finalBot.roles.highest.position - finalRole.position; + + await interaction.editReply( + `✅ Quarantine role created (Red) and channels secured.\n` + + `**Role ID:** ${role.id}\n` + + `**Position:** ${finalRole.position} (${gap} below bot's highest role)` + ); + } catch (error) { + logger.error('Quarantine setup error:', error); + await interaction.editReply('An error occurred while setting up the quarantine system.'); + } + } +}; diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index 04f205a3f..5523b16ac 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -1,52 +1,100 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { PunishmentService } from '../../services/punishmentService.js'; +// Discord maximum timeout is 28 days +const MAX_TIMEOUT_MINUTES = 28 * 24 * 60; // 40320 -import { InteractionHelper } from '../../utils/interactionHelper.js'; -const durationChoices = [ - { name: "5 minutes", value: 5 }, - { name: "10 minutes", value: 10 }, - { name: "30 minutes", value: 30 }, - { name: "1 hour", value: 60 }, - { name: "6 hours", value: 360 }, - { name: "1 day", value: 1440 }, - { name: "1 week", value: 10080 }, +const DURATION_CHOICES = [ + { name: "5m — 5 minutes", value: "5m" }, + { name: "10m — 10 minutes", value: "10m" }, + { name: "30m — 30 minutes", value: "30m" }, + { name: "1h — 1 hour", value: "1h" }, + { name: "2h — 2 hours", value: "2h" }, + { name: "6h — 6 hours", value: "6h" }, + { name: "12h — 12 hours", value: "12h" }, + { name: "1d — 1 day", value: "1d" }, + { name: "2d — 2 days", value: "2d" }, + { name: "3d — 3 days", value: "3d" }, + { name: "5d — 5 days", value: "5d" }, + { name: "7d — 1 week", value: "7d" }, + { name: "14d — 2 weeks", value: "14d" }, + { name: "21d — 3 weeks", value: "21d" }, + { name: "28d — 4 weeks (max)", value: "28d" }, ]; + +/** + * Parses a duration string and returns { minutes, rest }. + * Handles compact ("2d", "30m", "1h") and spaced ("2 days", "1 hour") forms. + * Examples: + * "2d ban evade" → { minutes: 2880, rest: "ban evade" } + * "2 days ban evade" → { minutes: 2880, rest: "ban evade" } + * "60" → { minutes: 60, rest: "" } + * "1w" → { minutes: 10080, rest: "" } + */ +function parseDurationFromStr(str) { + if (!str || !str.trim()) return { minutes: null, rest: '' }; + str = str.trim(); + + const UNIT_MINUTES = { m: 1, h: 60, d: 1440, w: 10080, y: 525600 }; + + // Pattern: number (optional space) unit keyword (then rest) + const match = str.match( + /^(\d+)\s*(m(?:in(?:utes?)?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?|w(?:(?:ee)?ks?)?|y(?:(?:ea)?rs?)?)(?:\s+|$)(.*)/i + ); + if (match) { + const num = parseInt(match[1], 10); + const unitChar = match[2][0].toLowerCase(); + const rest = match[3].trim(); + const multiplier = UNIT_MINUTES[unitChar] ?? 1; + return { minutes: Math.min(num * multiplier, MAX_TIMEOUT_MINUTES), rest }; + } + + // Pure integer fallback → treat as minutes + const numMatch = str.match(/^(\d+)(?:\s+|$)(.*)/); + if (numMatch) { + return { minutes: Math.min(parseInt(numMatch[1], 10), MAX_TIMEOUT_MINUTES), rest: numMatch[2].trim() }; + } + + return { minutes: null, rest: str }; +} + +function formatDuration(minutes) { + if (minutes % 10080 === 0) return `${minutes / 10080}w`; + if (minutes % 1440 === 0) return `${minutes / 1440}d`; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} + export default { data: new SlashCommandBuilder() .setName("timeout") .setDescription("Timeout a user for a specific duration.") - .addUserOption((option) => - option - .setName("target") + .addUserOption(option => + option.setName("target") .setDescription("User to timeout") - .setRequired(true), + .setRequired(true) ) - .addIntegerOption( - (option) => - option - .setName("duration") - .setDescription("Duration of the timeout") - .setRequired(true) -.addChoices(...durationChoices), + .addStringOption(option => + option.setName("duration") + .setDescription("Duration (e.g. 5m, 1h, 2d, 1w). Prefix: nh!timeout @user 2d reason") + .setRequired(true) + .addChoices(...DURATION_CHOICES) ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the timeout"), + .addStringOption(option => + option.setName("reason") + .setDescription("Reason for the timeout") ) -.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: "moderation", + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), async execute(interaction, config, client) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) { - logger.warn(`Timeout interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'timeout' - }); + logger.warn('Timeout defer failed', { userId: interaction.user.id }); return; } @@ -60,46 +108,63 @@ export default { } const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const durationMinutes = interaction.options.getInteger("duration"); - const reason = interaction.options.getString("reason") || "No reason provided"; - if (targetUser.id === interaction.user.id) { + // Missing args guard + if (!targetUser) { throw new TitanBotError( - "Cannot timeout self", - ErrorTypes.VALIDATION, - "You cannot timeout yourself." + "Missing target", + ErrorTypes.USER_INPUT, + "Please mention the user to timeout.\nUsage: `nh!timeout @user [reason]`\nExamples: `nh!timeout @user 2d`, `nh!timeout @user 1h spam`" ); } - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot timeout bot", - ErrorTypes.VALIDATION, - "You cannot timeout the bot." - ); + + // Duration + reason parsing + // For prefix: getString returns "2d reason text" merged → split at first duration token + // For slash: getString("duration") = "2d", getString("reason") = reason separately + let durationMinutes, reason; + if (interaction._isPrefix) { + const rawStr = interaction.options.getString("duration") || ""; + const { minutes, rest } = parseDurationFromStr(rawStr); + durationMinutes = minutes; + reason = rest || "No reason provided"; + } else { + const rawDuration = interaction.options.getString("duration"); + const { minutes } = parseDurationFromStr(rawDuration || ""); + durationMinutes = minutes; + reason = interaction.options.getString("reason") || "No reason provided"; } - if (!member) { + + if (!durationMinutes || durationMinutes < 1) { throw new TitanBotError( - "Target not found", + "Invalid duration", ErrorTypes.USER_INPUT, - "The target user is not currently in this server." + "Invalid duration. Use formats like `5m`, `1h`, `2d`, `1w`.\nMax duration: 28 days." ); } + const member = interaction.options.getMember("target"); + + if (targetUser.id === interaction.user.id) { + throw new TitanBotError("Cannot timeout self", ErrorTypes.VALIDATION, "You cannot timeout yourself."); + } + if (targetUser.id === client.user.id) { + throw new TitanBotError("Cannot timeout bot", ErrorTypes.VALIDATION, "You cannot timeout the bot."); + } + if (!member) { + throw new TitanBotError("Target not found", ErrorTypes.USER_INPUT, "That user is not in this server."); + } if (!member.moderatable) { throw new TitanBotError( "Cannot timeout member", ErrorTypes.PERMISSION, - "I cannot timeout this user. They might have a higher role than me or you." + "I cannot timeout this user — they may have a higher role than me." ); } const durationMs = durationMinutes * 60 * 1000; await member.timeout(durationMs, reason); - const durationDisplay = - durationChoices.find((c) => c.value === durationMinutes) - ?.name || `${durationMinutes} minutes`; + const display = formatDuration(durationMinutes); const caseId = await logModerationAction({ client, @@ -108,8 +173,8 @@ export default { action: "Member Timed Out", target: `${targetUser.tag} (${targetUser.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason}\nDuration: ${durationDisplay}`, - duration: durationDisplay, + reason: `${reason}\nDuration: ${display}`, + duration: display, metadata: { userId: targetUser.id, moderatorId: interaction.user.id, @@ -119,26 +184,30 @@ export default { } }); + PunishmentService.record({ + guildId: interaction.guildId, + userId: targetUser.id, + moderatorId: interaction.user.id, + action: 'TIMEOUT', + reason, + durationMinutes, + caseId + }).catch(e => logger.warn('Failed to record timeout punishment:', e.message)); + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `⏳ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, - `**Reason:** ${reason}\n**Case ID:** #${caseId}`, - ), - ], + `⏳ **Timed out** ${targetUser.tag} for **${display}**`, + `**Reason:** ${reason}\n**Case ID:** #${caseId}` + ) + ] }); + } catch (error) { logger.error('Timeout command error:', error); await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - error.userMessage || "An unexpected error occurred during the timeout action. Please check my role permissions.", - ), - ], + embeds: [errorEmbed(error.userMessage || "An unexpected error occurred. Please check my role permissions.")] }); } } }; - - - diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js index d32196945..7e3740e17 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderationService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { PunishmentService } from '../../services/punishmentService.js'; export default { data: new SlashCommandBuilder() .setName("unban") @@ -46,6 +47,9 @@ export default { reason }); + PunishmentService.deactivate(interaction.guildId, targetUser.id, 'BAN') + .catch(e => logger.warn('Failed to deactivate ban punishment:', e.message)); + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index 876dc3e56..229ce891b 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -1,126 +1,41 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("unlock") - .setDescription( - "Unlocks the current channel (allows @everyone to send messages again).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - category: "moderation", + .setDescription("Unlock the channel for all roles") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Unlock interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'unlock' - }); - return; - } - - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Channels` permission to unlock channels.", - ), - ], - }); - + await InteractionHelper.safeDefer(interaction); const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; + const guild = interaction.guild; try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if ( - currentPermissions.has(PermissionFlagsBits.SendMessages) === - true || - currentPermissions.has(PermissionFlagsBits.SendMessages) === - null - ) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Already Unlocked", - `${channel} is not explicitly locked (everyone can already send messages).`, - ), - ], - }); - } - - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: true }, - { - type: 0, - reason: `Channel unlocked by ${interaction.user.tag}`, -}, - ); - - const unlockEmbed = createEmbed( - "🔓 Channel Unlocked (Action Log)", - `${channel} has been unlocked by ${interaction.user}.`, - ) -.setColor(getColor('success')) - .addFields( - { - name: "Channel", - value: channel.toString(), - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: true, - }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Unlocked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None' - } + const overwrites = channel.permissionOverwrites.cache; + const roleIds = [...overwrites.keys(), guild.roles.everyone.id]; + + for (const id of roleIds) { + try { + await channel.permissionOverwrites.edit(id, { SendMessages: null }); + } catch (e) { + logger.debug(`Skipping role ${id}: ${e.message}`); } - }); + } await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `🔓 **Channel Unlocked**`, - `${channel} is now unlocked. You may speak now.`, - ), - ], + embeds: [successEmbed("🔓 Channel unlocked successfully.")], + flags: MessageFlags.Ephemeral, }); + } catch (error) { - logger.error('Unlock command error:', error); + logger.error("Critical unlock error:", error); await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "An unexpected error occurred while trying to unlock the channel. Check my permissions (I need 'Manage Channels').", - ), - ], + embeds: [errorEmbed("Error", "Failed to process unlock command.")], }); } } }; - - - diff --git a/src/commands/Moderation/unquarantine.js b/src/commands/Moderation/unquarantine.js new file mode 100644 index 000000000..fd22f88b3 --- /dev/null +++ b/src/commands/Moderation/unquarantine.js @@ -0,0 +1,40 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { pgDb } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('unquarantine') + .setDescription('Remove quarantine and restore roles') + .addUserOption(option => option.setName('user').setDescription('Member to unquarantine').setRequired(true)), + + async execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const target = interaction.options.getMember('user'); + if (!target) return interaction.editReply({ content: 'Member not found.' }); + + try { + const res = await pgDb.pool.query('SELECT roles FROM quarantine_data WHERE user_id = $1', [target.id]); + + if (res.rows.length === 0) { + return interaction.editReply({ content: 'This user is not in quarantine database.' }); + } + + let oldRoles; + try { + oldRoles = JSON.parse(res.rows[0].roles); + } catch (parseError) { + return interaction.editReply({ content: 'Failed to parse quarantine data. The data may be corrupted.' }); + } + + await target.roles.set(oldRoles); + await pgDb.pool.query('DELETE FROM quarantine_data WHERE user_id = $1', [target.id]); + + await interaction.editReply({ content: `Successfully unquarantined ${target.user.tag}.` }); + } catch (error) { + logger.error('Unquarantine database error:', error); + await interaction.editReply({ content: 'Database error or missing permissions.' }); + } + } +}; diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index a55882953..103c561aa 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderationService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { PunishmentService } from '../../services/punishmentService.js'; export default { data: new SlashCommandBuilder() .setName("untimeout") @@ -40,6 +41,9 @@ export default { moderator: interaction.member }); + PunishmentService.deactivate(interaction.guildId, targetUser.id, 'TIMEOUT') + .catch(e => logger.warn('Failed to deactivate timeout punishment:', e.message)); + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 571214ece..33f960896 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -1,25 +1,21 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { WarningService } from '../../services/warningService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { PunishmentService } from '../../services/punishmentService.js'; + export default { data: new SlashCommandBuilder() .setName("warn") .setDescription("Warn a user") - .addUserOption((o) => - o - .setName("target") - .setRequired(true) - .setDescription("User to warn"), + .addUserOption(o => + o.setName("target").setRequired(true).setDescription("User to warn") ) - .addStringOption((o) => - o - .setName("reason") - .setRequired(true) - .setDescription("Reason for the warning"), + .addStringOption(o => + o.setName("reason").setRequired(false).setDescription("Reason for the warning") ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", @@ -27,76 +23,99 @@ export default { async execute(interaction, config, client) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) { - logger.warn(`Warn interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'warn' - }); + logger.warn('Warn defer failed', { userId: interaction.user.id }); return; } try { - if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { - throw new Error("You need the `Moderate Members` permission to issue warnings."); - } + if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + throw new TitanBotError( + "User lacks permission", + ErrorTypes.PERMISSION, + "You need the `Moderate Members` permission to issue warnings." + ); + } - const target = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason"); - const moderator = interaction.user; - const guildId = interaction.guildId; + const target = interaction.options.getUser("target"); - if (!member) { - throw new Error("The target user is not currently in this server."); - } + // Missing args guard + if (!target) { + throw new TitanBotError( + "Missing target", + ErrorTypes.USER_INPUT, + "Please mention the user to warn.\nUsage: `nh!warn @user [reason]`" + ); + } - - const result = await WarningService.addWarning({ - guildId, - userId: target.id, - moderatorId: moderator.id, - reason, - timestamp: Date.now() - }); + const member = interaction.options.getMember("target"); + // Default reason instead of null + const reason = interaction.options.getString("reason") || "No reason provided"; + const moderator = interaction.user; + const guildId = interaction.guildId; - if (!result.success) { - throw new Error("Failed to store warning in database"); - } + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "That user is not currently in this server." + ); + } + + const result = await WarningService.addWarning({ + guildId, + userId: target.id, + moderatorId: moderator.id, + reason, + timestamp: Date.now() + }); - const totalWarns = result.totalCount; + if (!result.success) { + throw new Error("Failed to store warning in database"); + } - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "User Warned", - target: `${target.tag} (${target.id})`, - executor: `${moderator.tag} (${moderator.id})`, - reason, - metadata: { - userId: target.id, - moderatorId: moderator.id, - totalWarns, - warningNumber: totalWarns, - warningId: result.id - } + const totalWarns = result.totalCount; + + await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "User Warned", + target: `${target.tag} (${target.id})`, + executor: `${moderator.tag} (${moderator.id})`, + reason, + metadata: { + userId: target.id, + moderatorId: moderator.id, + totalWarns, + warningNumber: totalWarns, + warningId: result.id } - }); + } + }); + + PunishmentService.record({ + guildId, + userId: target.id, + moderatorId: moderator.id, + action: 'WARN', + reason, + caseId: result.id + }).catch(e => logger.warn('Failed to record warn punishment:', e.message)); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `⚠️ **Warned** ${target.tag}`, + `**Reason:** ${reason}\n**Total Warns:** ${totalWarns}` + ) + ] + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `⚠️ **Warned** ${target.tag}`, - `**Reason:** ${reason}\n**Total Warns:** ${totalWarns}`, - ), - ], - }); } catch (error) { logger.error('Warn command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warn_failed' }); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed(error.userMessage || "An unexpected error occurred.")] + }); } } }; - - - diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js index 834bdd1da..b4d7d3783 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.js @@ -1,20 +1,20 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { WarningService } from '../../services/warningService.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + export default { data: new SlashCommandBuilder() .setName("warnings") .setDescription("View all warnings for a user") - .addUserOption((o) => - o - .setName("target") + .addUserOption(o => + o.setName("target") .setRequired(true) - .setDescription("User to check warnings for"), + .setDescription("User to check warnings for") ) .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: "moderation", @@ -22,44 +22,50 @@ export default { async execute(interaction, config, client) { const deferSuccess = await InteractionHelper.safeDefer(interaction); if (!deferSuccess) { - logger.warn(`Warnings interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'warnings' - }); + logger.warn('Warnings defer failed', { userId: interaction.user.id }); return; } try { const target = interaction.options.getUser("target"); - const guildId = interaction.guildId; - + // Missing args guard + if (!target) { + throw new TitanBotError( + "Missing target", + ErrorTypes.USER_INPUT, + "Please mention the user to check.\nUsage: `nh!warnings @user`" + ); + } + + const guildId = interaction.guildId; const validWarnings = await WarningService.getWarnings(guildId, target.id); const totalWarns = validWarnings.length; if (totalWarns === 0) { await InteractionHelper.safeEditReply(interaction, { embeds: [ - createEmbed({ - title: `Warnings: ${target.tag}`, - description: "✅ This user has no recorded warnings." - }).setColor(getColor('success')), - ], + createEmbed({ + title: `Warnings: ${target.tag}`, + description: "✅ This user has no recorded warnings." + }).setColor(getColor('success')) + ] }); return; } - const embed = createEmbed({ - title: `Warnings: ${target.tag}`, - description: `Total Warnings: **${totalWarns}**` + const embed = createEmbed({ + title: `Warnings: ${target.tag}`, + description: `Total Warnings: **${totalWarns}**` }).setColor(getColor('warning')); const warningFields = validWarnings .map((w, i) => { - const discordTimestamp = Math.floor(w.timestamp / 1000); + const discordTimestamp = Math.floor((w.timestamp || Date.now()) / 1000); + // Guard against null/undefined reason + const reason = (w.reason || 'No reason provided').substring(0, 100); return { - name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`, + name: `[#${i + 1}] ${reason}`, value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`, inline: false, }; @@ -76,21 +82,17 @@ export default { target: `${target.tag} (${target.id})`, executor: `${interaction.user.tag} (${interaction.user.id})`, reason: `Viewed ${totalWarns} warnings`, - metadata: { - userId: target.id, - moderatorId: interaction.user.id, - totalWarnings: totalWarns - } + metadata: { userId: target.id, moderatorId: interaction.user.id, totalWarnings: totalWarns } } }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { logger.error('Warnings command error:', error); - await handleInteractionError(interaction, error, { subtype: 'warnings_view_failed' }); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed(error.userMessage || "Failed to retrieve warnings. Please try again.")] + }); } } }; - - - diff --git a/src/commands/Search/movie.js b/src/commands/Search/movie.js index a4e72ebc0..dc84a430a 100644 --- a/src/commands/Search/movie.js +++ b/src/commands/Search/movie.js @@ -8,7 +8,6 @@ import { getGuildConfig } from '../../services/guildConfig.js'; import { getColor } from '../../config/bot.js'; const TMDB_API_KEY = process.env.TMDB_API_KEY || '4e44d9029b1270a757cddc766a1bcb63'; - "4e44d9029b1270a757cddc766a1bcb63"; const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; const MAX_RESULTS = 5; diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js index e2020025b..1a5b2df1c 100644 --- a/src/commands/Search/urban.js +++ b/src/commands/Search/urban.js @@ -76,8 +76,8 @@ export default { } const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); + const cleanDefinition = (definition.definition || '').replace(/\[|\]/g, ''); + const cleanExample = (definition.example || '').replace(/\[|\]/g, ''); const formattedDefinition = cleanDefinition .replace(/\n\s*\n/g, '\n\n') diff --git a/src/config/aliases.js b/src/config/aliases.js new file mode 100644 index 000000000..e8fe59754 --- /dev/null +++ b/src/config/aliases.js @@ -0,0 +1,7 @@ +// File: config/aliases.js +export const COMMAND_MAP = { + "cf": "coinflip", + "q": "quarantine", + "uq": "unquarantine", + // Add more aliases here as you wish +}; diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd4..bd7ff010c 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -25,7 +25,7 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❤️", + name: "https://foxname.top/", // Activity type number (0 = Playing). type: 0, }, @@ -88,7 +88,7 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", + primary: "#BC9EFF", secondary: "#2F3136", // Standard status colors for success/error/warning/info messages. @@ -136,7 +136,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "Nh_starlight bot", // Footer icon URL (null = no icon). icon: null, }, diff --git a/src/config/postgres.js b/src/config/postgres.js index b70d1875a..d990c9d9d 100644 --- a/src/config/postgres.js +++ b/src/config/postgres.js @@ -18,6 +18,7 @@ const configuredTables = { verification_audit: 'verification_audit', temp_data: 'temp_data', cache_data: 'cache_data', + moderation_punishments: 'moderation_punishments', }; const allowedTableIdentifiers = new Set([ @@ -37,6 +38,7 @@ const allowedTableIdentifiers = new Set([ 'verification_audit', 'temp_data', 'cache_data', + 'moderation_punishments', ]); const validatedTables = Object.fromEntries( @@ -49,15 +51,15 @@ const validatedTables = Object.fromEntries( export const pgConfig = { - url: process.env.POSTGRES_URL || 'postgresql://localhost:5432/titanbot', + url: process.env.POSTGRES_URL || process.env.DATABASE_URL || 'postgresql://localhost:5432/titanbot', options: { - host: process.env.POSTGRES_HOST || 'localhost', - port: parseInt(process.env.POSTGRES_PORT) || 5432, - database: process.env.POSTGRES_DB || 'titanbot', - user: process.env.POSTGRES_USER || 'postgres', - password: (process.env.POSTGRES_PASSWORD || '').toString(), + host: process.env.POSTGRES_HOST || process.env.PGHOST || 'localhost', + port: parseInt(process.env.POSTGRES_PORT || process.env.PGPORT) || 5432, + database: process.env.POSTGRES_DB || process.env.PGDATABASE || 'titanbot', + user: process.env.POSTGRES_USER || process.env.PGUSER || 'postgres', + password: (process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || '').toString(), ssl: false, diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 42c2d8450..7f7216956 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -4,10 +4,14 @@ import { getGuildConfig } from '../services/guildConfig.js'; import { getWelcomeConfig } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; +import { logEvent as logModEvent } from '../utils/moderation.js'; import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { setBirthday as dbSetBirthday } from '../utils/database.js'; +import { PunishmentService } from '../services/punishmentService.js'; import { logger } from '../utils/logger.js'; +const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000; // 28 days — Discord max + export default { name: Events.GuildMemberAdd, once: false, @@ -164,13 +168,115 @@ export default { } catch (error) { logger.debug('Error restoring birthday on member join:', error); } - + + // Punishment evasion detection + checkPunishmentEvasion(member, guild).catch(err => + logger.debug('Evasion check error:', err.message) + ); + } catch (error) { logger.error('Error in guildMemberAdd event:', error); } } }; +/** + * Check for punishment evasion when a member joins. + * - Re-applies active timeouts (user left during timeout). + * - Alerts moderators when previously banned/kicked users rejoin. + * - Flags accounts less than 7 days old. + */ +async function checkPunishmentEvasion(member, guild) { + const { user } = member; + + // 1. Re-apply active timeouts + try { + const active = await PunishmentService.getActive(guild.id, user.id); + const activeTimeout = active.find(p => p.action === 'TIMEOUT'); + + if (activeTimeout && member.moderatable) { + const expiresAt = new Date(activeTimeout.expires_at || activeTimeout.expiresAt); + const remaining = expiresAt.getTime() - Date.now(); + + if (remaining > 0) { + const applyMs = Math.min(remaining, MAX_TIMEOUT_MS); + await member.timeout(applyMs, 'Auto-reapplied: timeout evasion prevention'); + logger.info(`Timeout re-applied for ${user.tag} in ${guild.name} (evasion prevention)`); + + await logModEvent({ + client: member.client, + guild, + event: { + action: '⚠️ Timeout Evasion', + target: `${user.tag} (${user.id})`, + executor: 'TitanBot (Auto)', + reason: `User rejoined during active timeout. Auto-reapplied.\nOriginal reason: ${activeTimeout.reason || 'No reason provided'}`, + duration: `Expires `, + color: 0xff6b00, + } + }); + } + } + } catch (err) { + logger.debug('Timeout evasion check failed:', err.message); + } + + // 2. Alert mods when a previously banned/kicked user rejoins + try { + const history = await PunishmentService.getUserHistory(guild.id, user.id, 10); + const severe = history.filter(p => p.action === 'BAN' || p.action === 'KICK'); + + if (severe.length > 0) { + const latest = severe[0]; + const ts = Math.floor( + new Date(latest.created_at || latest.createdAt || Date.now()).getTime() / 1000 + ); + + await logModEvent({ + client: member.client, + guild, + event: { + action: '⚠️ Previously Punished User Rejoined', + target: `${user.tag} (${user.id})`, + executor: 'TitanBot (Auto)', + reason: `User has **${severe.length}** prior ban/kick record(s).\nMost recent: **${latest.action}** — \nReason: ${latest.reason || 'No reason provided'}`, + color: 0xff0000, + metadata: { + totalRecords: `${severe.length} ban/kick(s)`, + 'Run /history': `Use \`/history @${user.username}\` to review their full history`, + } + } + }); + } + } catch (err) { + logger.debug('Punishment history check failed:', err.message); + } + + // 3. Flag new accounts (less than 7 days old) + try { + const accountAgeDays = (Date.now() - user.createdTimestamp) / (1000 * 60 * 60 * 24); + if (accountAgeDays < 7) { + await logModEvent({ + client: member.client, + guild, + event: { + action: '🆕 New Account Alert', + target: `${user.tag} (${user.id})`, + executor: 'TitanBot (Auto)', + reason: `Account is only **${accountAgeDays.toFixed(1)} days** old (under 7 days).`, + color: 0xffcc00, + metadata: { + 'Account Age': `${accountAgeDays.toFixed(1)} days`, + 'Created': ``, + } + } + }); + } + } catch (err) { + logger.debug('New account check failed:', err.message); + } +} + async function handleVerification(member, guild, verificationConfig, client) { const { autoVerifyOnJoin } = await import('../services/verificationService.js'); diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 450ace66b..02a23cf62 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -20,6 +20,15 @@ function withTraceContext(context = {}, traceContext = {}) { }; } +// HÀM TỰ ĐỘNG XÁC NHẬN TƯƠNG TÁC +async function autoAcknowledge(interaction) { + if (!interaction.deferred && !interaction.replied) { + if (interaction.isButton() || interaction.isStringSelectMenu()) { + await interaction.deferUpdate().catch(() => {}); + } + } +} + export default { name: Events.InteractionCreate, async execute(interaction, client) { @@ -31,376 +40,44 @@ export default { try { InteractionHelper.patchInteractionResponses(interaction); - if (interaction.isChatInputCommand()) { - try { - logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`, { - event: 'interaction.command.received', - traceId: interactionTraceContext.traceId, - guildId: interaction.guildId, - userId: interaction.user?.id, - command: interaction.commandName - }); - - validateChatInputPayloadOrThrow(interaction, withTraceContext({ - type: 'command_input_validation', - commandName: interaction.commandName - }, interactionTraceContext)); - - const command = client.commands.get(interaction.commandName); - - if (!command) { - throw createError( - `No command matching ${interaction.commandName} was found.`, - ErrorTypes.CONFIGURATION, - 'Sorry, that command does not exist.', - withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) - ); - } - - const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName); - if (!abuseProtection.allowed) { - const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); - throw createError( - `Risky command cooldown active for ${interaction.commandName}`, - ErrorTypes.RATE_LIMIT, - `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, - withTraceContext({ - commandName: interaction.commandName, - subtype: 'command_cooldown', - expected: true, - cooldownMs: abuseProtection.remainingMs, - cooldownWindowMs: abuseProtection.policy?.windowMs, - cooldownMaxAttempts: abuseProtection.policy?.maxAttempts - }, interactionTraceContext) - ); - } - - let guildConfig = null; - if (interaction.guild) { - guildConfig = await getGuildConfig(client, interaction.guild.id, interactionTraceContext); - if (guildConfig?.disabledCommands?.[interaction.commandName]) { - throw createError( - `Command ${interaction.commandName} is disabled in this guild`, - ErrorTypes.CONFIGURATION, - 'This command has been disabled for this server.', - withTraceContext({ commandName: interaction.commandName, guildId: interaction.guild.id }, interactionTraceContext) - ); - } - } - - await command.execute(interaction, guildConfig, client); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'command', - commandName: interaction.commandName - }, interactionTraceContext)); - } - } else if (interaction.isAutocomplete()) { - // Handle autocomplete interactions - const focusedOption = interaction.options.getFocused(true); - - if (interaction.commandName === 'apply' && focusedOption.name === 'application') { - try { - const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); - const roleName = interaction.options.getString('application', false); - - // Filter: only show enabled applications - const filtered = roles.filter(role => - role.enabled !== false && - role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { - try { - const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); - const appName = interaction.options.getString('application', false); - - // Show all applications (enabled and disabled), but mark disabled ones - const filtered = roles.filter(role => - role.name.toLowerCase().startsWith(appName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling app-admin autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { - try { - const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); - const guildId = interaction.guildId; - const guild = interaction.guild; - - let panels = await getAllReactionRoleMessages(client, guildId); - - if (!panels || panels.length === 0) { - await interaction.respond([]); - return; - } - - // Filter out panels whose messages no longer exist - const validPanels = []; - for (const panel of panels) { - if (!panel.messageId || !panel.channelId) { - continue; - } - - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } - - if (validPanels.length === 0) { - await interaction.respond([]); - return; - } - - const choices = await Promise.all( - validPanels.slice(0, 25).map(async panel => { - try { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) return null; - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) return null; - - const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const channelName = channel?.name ?? 'unknown'; - - return { - name: `${title} (${channelName})`.substring(0, 100), - value: panel.messageId - }; - } catch (e) { - return null; - } - }) - ); - - const validChoices = choices.filter(c => c !== null); - await interaction.respond(validChoices); - } catch (error) { - logger.error('Error handling reactroles autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } - } else if (interaction.isButton()) { - if (interaction.customId.startsWith('shared_todo_')) { - const parts = interaction.customId.split('_'); - const buttonType = parts.slice(0, 3).join('_'); - const listId = parts[3]; - const button = client.buttons.get(buttonType); + // TỰ ĐỘNG XÁC NHẬN NÚT BẤM / MENU NGAY TẠI ĐÂY + if (interaction.isButton() || interaction.isStringSelectMenu()) { + await autoAcknowledge(interaction); + } - if (button) { - try { - await button.execute(interaction, client, [listId]); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'todo' - }, interactionTraceContext)); - } - } else { - throw createError( - `No button handler found for ${buttonType}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - withTraceContext({ buttonType }, interactionTraceContext) - ); - } + if (interaction.isChatInputCommand()) { + const command = client.commands.get(interaction.commandName); + if (!command) { + logger.warn(`Unknown command: ${interaction.commandName}`); return; } - + await command.execute(interaction, await getGuildConfig(client, interaction.guildId), client); + } + else if (interaction.isButton()) { + logger.debug(`Button pressed: ${interaction.customId}`); const [customId, ...args] = interaction.customId.split(':'); + const button = client.buttons.get(customId); - - if (!button) { - if (!interaction.customId.includes(':')) { - return; - } - - throw createError( - `No button handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await button.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'general' - }, interactionTraceContext)); - } - } else if (interaction.isStringSelectMenu()) { + + if (button) await button.execute(interaction, client, args); + else logger.warn(`No button handler found for: ${customId}`); + } + else if (interaction.isStringSelectMenu()) { + logger.debug(`Select menu: ${interaction.customId}`); const [customId, ...args] = interaction.customId.split(':'); + const selectMenu = client.selectMenus.get(customId); - - if (!selectMenu) { - if (!interaction.customId.includes(':')) { - // No registered handler and no ':' delimiter — this is an inline-collected - // select menu (e.g. ticket_config_, jointocreate_config_). - // Return silently so the existing MessageComponentCollector handles it. - return; - } - - throw createError( - `No select menu handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This select menu is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await selectMenu.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'select_menu', - customId: interaction.customId - }, interactionTraceContext)); - } - } else if (interaction.isModalSubmit()) { - if (interaction.customId.startsWith('app_modal_')) { - try { - await handleApplicationModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application' - }, interactionTraceContext)); - } - return; - } - - if (interaction.customId.startsWith('app_review_')) { - try { - await handleApplicationReviewModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application_review' - }, interactionTraceContext)); - } - return; - } - - if (interaction.customId.startsWith('jtc_')) { - logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`, { - event: 'interaction.modal.inline_skipped', - traceId: interactionTraceContext.traceId - }); - return; - } - - const [customId, ...args] = interaction.customId.split(':'); - const modal = client.modals.get(customId); - - if (!modal) { - if (!interaction.customId.includes(':')) { - // No registered handler and no ':' delimiter — this is an inline-awaited - // modal (e.g. via awaitModalSubmit). Return silently so the caller handles it. - return; - } - - throw createError( - `No modal handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This form is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); - } - - try { - await modal.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'general' - }, interactionTraceContext)); - } + if (selectMenu) await selectMenu.execute(interaction, client, args); + else logger.warn(`No select menu handler found for: ${customId}`); } - } catch (error) { - logger.error('Unhandled error in interactionCreate:', { - event: 'interaction.unhandled_error', - errorCode: 'INTERACTION_UNHANDLED_ERROR', - error, - traceId: interactionTraceContext.traceId, - interactionId: interaction.id, - guildId: interaction.guildId, - userId: interaction.user?.id - }); - - try { - const ephemeralErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')], - flags: MessageFlags.Ephemeral - }; - const editErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')] - }; - - if (interaction.deferred) { - await interaction.editReply(editErrorMessage); - } else if (interaction.replied) { - await interaction.followUp(ephemeralErrorMessage); - } else { - await interaction.reply(ephemeralErrorMessage); - } - } catch (replyError) { - logger.error('Failed to send fallback error response:', { - event: 'interaction.error_response_failed', - errorCode: 'INTERACTION_ERROR_RESPONSE_FAILED', - error: replyError, - traceId: interactionTraceContext.traceId - }); + else if (interaction.isModalSubmit()) { + // ... (giữ nguyên logic modal cũ) ... + const [customId, ...args] = interaction.customId.split(':'); + const modal = client.modals.get(customId); + if (modal) await modal.execute(interaction, client, args); } + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({type: 'general'}, interactionTraceContext)); } }); } diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index b7f9841dc..9ee2f0da3 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,117 +1,132 @@ - - - - - import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; -import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; -import { addXp } from '../services/xpSystem.js'; -import { checkRateLimit } from '../utils/rateLimiter.js'; +import { getGuildConfig } from '../services/guildConfig.js'; -const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; -const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; +// Discord mention syntax: <@123>, <@!123>, <@&123>, <#123> +const MENTION_REGEX = /^<[@#][!&]?\d+>$/; +// Pure integer (for subcommand detection only — NOT filtered from getString) +const INTEGER_REGEX = /^-?\d+$/; export default { - name: Events.MessageCreate, - async execute(message, client) { - try { - - if (message.author.bot || !message.guild) return; - - await handleLeveling(message, client); - } catch (error) { - logger.error('Error in messageCreate event:', error); + name: Events.MessageCreate, + async execute(message, client) { + const PREFIX = "nh!"; + if (!message.content.startsWith(PREFIX) || message.author.bot || !message.guild) return; + + const rawArgs = message.content.slice(PREFIX.length).trim().split(/ +/); + const commandName = rawArgs.shift().toLowerCase(); + if (!commandName) return; + + const command = client.commands.get(commandName); + if (!command) return; + + const args = [...rawArgs]; + + // Non-mention args — keep integers so "2 days", "2d", "60" all pass through getString intact + const nonMentionArgs = args.filter(a => !MENTION_REGEX.test(a)); + + // Subcommand detection: args[0] is text (not a mention, not a pure integer) + // e.g. nh!todo add task → args[0]="add" → isSubcommand = true + // nh!ban @user reason → args[0]="<@123>" → isSubcommand = false + // nh!purge 10 → args[0]="10" → isSubcommand = false + const firstArgIsSubcommand = !!args[0] && !MENTION_REGEX.test(args[0]) && !INTEGER_REGEX.test(args[0]); + + // stringArgs: everything that is not a mention, skip subcommand token if present + const stringArgs = firstArgIsSubcommand ? nonMentionArgs.slice(1) : nonMentionArgs; + + let _deferred = false; + let _replied = false; + + const stripFlags = (opts) => { + if (!opts || typeof opts !== 'object') return opts; + const { flags, ephemeral, ...safe } = opts; + return safe; + }; + + const fakeInteraction = { + _isPrefix: true, + id: `prefix-${message.id}`, + createdTimestamp: message.createdTimestamp, + guildId: message.guild.id, + channelId: message.channel.id, + commandName, + type: 0, + + member: message.member, + guild: message.guild, + channel: message.channel, + user: message.author, + client: client, + // memberPermissions for commands that check interaction.memberPermissions + memberPermissions: message.member.permissions, + + get deferred() { return _deferred; }, + get replied() { return _replied; }, + + deferReply: async (_opts) => { _deferred = true; }, + + reply: async (content) => { + const opts = stripFlags(typeof content === 'string' ? { content } : content); + const msg = await message.reply(opts); + _replied = true; + return msg; + }, + + editReply: async (content) => { + const opts = stripFlags(typeof content === 'string' ? { content } : content); + const msg = await message.channel.send(opts); + _replied = true; + return msg; + }, + + followUp: async (content) => { + const opts = stripFlags(typeof content === 'string' ? { content } : content); + return message.channel.send(opts); + }, + + deleteReply: async () => {}, + + // showModal cannot work in prefix mode — inform user to use slash command + showModal: async (_modal) => { + await message.reply('❌ This feature requires the slash command (`/`). Please use `/` version instead.'); + }, + + // fetchReply returns the channel message (best approximation) + fetchReply: async () => message, + + options: { + getSubcommand: () => firstArgIsSubcommand ? args[0] : null, + + // Finds the first pure integer in args + getInteger: (_name) => { + const found = args.find(a => INTEGER_REGEX.test(a)); + return found !== undefined ? parseInt(found, 10) : null; + }, + + // Finds the first float in args + getNumber: (_name) => { + const found = args.find(a => /^-?[\d.]+$/.test(a) && !isNaN(parseFloat(a))); + return found !== undefined ? parseFloat(found) : null; + }, + + // Returns non-mention args as a string (integers included so "2 days", "30m" etc. survive) + getString: (_name) => stringArgs.join(' ') || null, + + getUser: (_name) => message.mentions.users.first() ?? null, + getMember: (_name) => message.mentions.members.first() ?? null, + getChannel: (_name) => message.mentions.channels.first() ?? message.channel, + getRole: (_name) => message.mentions.roles.first() ?? null, + getBoolean: (_name) => args.includes('true'), + getAttachment: (_name) => message.attachments.first() ?? null, + }, + }; + + try { + const guildConfig = await getGuildConfig(client, message.guild.id); + await command.execute(fakeInteraction, guildConfig, client); + } catch (error) { + logger.error(`Prefix command "${commandName}" failed:`, error); + await message.channel.send('❌ Lệnh gặp lỗi. Hãy thử dùng Slash Command (/) để có đầy đủ tính năng.').catch(() => {}); + } } - } }; - - - - - - - - -async function handleLeveling(message, client) { - try { - const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; - const canProcess = await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS); - if (!canProcess) { - return; - } - - const levelingConfig = await getLevelingConfig(client, message.guild.id); - - if (!levelingConfig?.enabled) { - return; - } - - - if (levelingConfig.ignoredChannels?.includes(message.channel.id)) { - return; - } - - - if (levelingConfig.ignoredRoles?.length > 0) { - const member = await message.guild.members.fetch(message.author.id).catch(() => { - return null; - }); - if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) { - return; - } - } - - - if (levelingConfig.blacklistedUsers?.includes(message.author.id)) { - return; - } - - - if (!message.content || message.content.trim().length === 0) { - return; - } - - const userData = await getUserLevelData(client, message.guild.id, message.author.id); - - - const cooldownTime = levelingConfig.xpCooldown || 60; - const now = Date.now(); - const timeSinceLastMessage = now - (userData.lastMessage || 0); - - - if (timeSinceLastMessage < cooldownTime * 1000) { - return; - } - - - const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15; - const maxXP = levelingConfig.xpRange?.max || levelingConfig.xpPerMessage?.max || 25; - - - const safeMinXP = Math.max(1, minXP); - const safeMaxXP = Math.max(safeMinXP, maxXP); - - - const xpToGive = Math.floor(Math.random() * (safeMaxXP - safeMinXP + 1)) + safeMinXP; - - - let finalXP = xpToGive; - if (levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1) { - finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier); - } - - - const result = await addXp(client, message.guild, message.member, finalXP); - - if (result.success && result.leveledUp) { - logger.info( - `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` - ); - } - } catch (error) { - logger.error('Error handling leveling for message:', error); - } -} - - diff --git a/src/handlers/countdownButtons.js b/src/handlers/countdownButtons.js index 2b5e916dd..c9c0a47df 100644 --- a/src/handlers/countdownButtons.js +++ b/src/handlers/countdownButtons.js @@ -1,4 +1,4 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } from 'discord.js'; import { successEmbed, errorEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; @@ -112,7 +112,7 @@ async function countdownButtonHandler(interaction, client, args) { }); } - if (!interaction.member.permissions.has("MANAGE_MESSAGES")) { + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { return await interaction.reply({ content: 'You need the "Manage Messages" permission to control countdowns.', flags: ["Ephemeral"], diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js index 0c989452a..7e6bcbb5c 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/helpButtons.js @@ -1,150 +1,54 @@ -import { createEmbed } from '../utils/embeds.js'; -import { createAllCommandsMenu } from './helpSelectMenus.js'; -import { createInitialHelpMenu } from '../commands/Core/help.js'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; - -const COMMAND_LIST_ID = "help-command-list"; -const BACK_BUTTON_ID = "help-back-to-main"; -const PAGINATION_PREFIX = "help-page"; -const BUG_REPORT_BUTTON_ID = "help-bug-report"; - -export const helpBackButton = { - name: BACK_BUTTON_ID, - async execute(interaction, client) { - try { - if (!interaction.deferred && !interaction.replied) { - await interaction.deferUpdate(); - } - - const { embeds, components } = await createInitialHelpMenu(client); - await interaction.editReply({ - embeds, - components, - }); - } catch (error) { - if (error?.code === 40060 || error?.code === 10062) { - logger.warn('Help back button interaction already acknowledged or expired.', { - event: 'interaction.help.button.unavailable', - errorCode: String(error.code), - customId: interaction.customId, - interactionId: interaction.id, - }); - return; - } - - throw error; - } - }, -}; - -export const helpBugReportButton = { - name: BUG_REPORT_BUTTON_ID, - async execute(interaction, client) { - const githubButton = new ButtonBuilder() - .setLabel('🐛 Report Bug on GitHub') - .setStyle(ButtonStyle.Link) - .setURL('https://github.com/codebymitch/TitanBot/issues'); - - const bugRow = new ActionRowBuilder().addComponents(githubButton); - - const bugReportEmbed = createEmbed({ - title: '🐛 Bug Report', - description: 'Found a bug? Please report it on our GitHub Issues page!\n\n' + - '**When reporting a bug, please include:**\n' + - '• 📝 Detailed description of the issue\n' + - '• 📋 Steps to reproduce the problem\n' + - '• 📸 Screenshots if applicable\n' + - '• 💻 Your bot version and environment\n\n' + - 'This helps us fix issues faster and more effectively!', - color: 'error' - }); - bugReportEmbed.setFooter({ - text: 'TitanBot Bug Reporting System', - iconURL: client.user.displayAvatarURL() - }); - bugReportEmbed.setTimestamp(); - - await interaction.reply({ - embeds: [bugReportEmbed], - components: [bugRow], - flags: MessageFlags.Ephemeral - }); - }, -}; - -export const helpReportCommand = { - name: COMMAND_LIST_ID, - categoryName: null, - async execute(interaction, client) { - - } -}; - -function getPaginationInfo(components) { - for (const row of components || []) { - for (const component of row.components || []) { - if (component.customId === `${PAGINATION_PREFIX}_page`) { - const label = component.label || ''; - const match = label.match(/Page\s+(\d+)\s+of\s+(\d+)/i); - if (match) { - return { - currentPage: Number(match[1]), - totalPages: Number(match[2]), - }; - } - } - } - } - - return { currentPage: 1, totalPages: 1 }; +import { SlashCommandBuilder, ActionRowBuilder } 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"; + +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"; + +export async function createInitialHelpMenu(client) { + const commandsPath = path.join(__dirname, "../../commands"); + const categoryDirs = (await fs.readdir(commandsPath, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort(); + + const options = [{ label: "📋 All Commands", description: "View all commands", value: ALL_COMMANDS_ID }, + ...categoryDirs.map((category) => ({ + label: `📁 ${category.charAt(0).toUpperCase() + category.slice(1)}`, + description: `View ${category} commands`, + value: category + })) + ]; + + const embed = createEmbed({ + title: `🤖 Starlight Security`, + description: "Welcome! Use the menu below to navigate through my commands.", + color: 'primary' + }); + + embed.setFooter({ text: "Starlight Security | Secure & Professional" }); + embed.setTimestamp(); + + const selectRow = createSelectMenu(CATEGORY_SELECT_ID, "Select a category to view commands", options); + + return { + embeds: [embed], + components: [selectRow], // Chỉ để lại select menu, xóa bỏ buttonRow + }; } -export const helpPaginationButton = { - name: `${PAGINATION_PREFIX}_next`, - async execute(interaction, client) { - try { - if (!interaction.deferred && !interaction.replied) { - await interaction.deferUpdate(); - } - - const { currentPage, totalPages } = getPaginationInfo(interaction.message?.components); - - let nextPage = currentPage; - switch (interaction.customId) { - case `${PAGINATION_PREFIX}_first`: - nextPage = 1; - break; - case `${PAGINATION_PREFIX}_prev`: - nextPage = Math.max(1, currentPage - 1); - break; - case `${PAGINATION_PREFIX}_next`: - nextPage = Math.min(totalPages, currentPage + 1); - break; - case `${PAGINATION_PREFIX}_last`: - nextPage = totalPages; - break; - default: - nextPage = currentPage; - break; - } - - const { embeds, components } = await createAllCommandsMenu(nextPage, client); - await interaction.editReply({ embeds, components }); - } catch (error) { - if (error?.code === 40060 || error?.code === 10062) { - logger.warn('Help pagination interaction already acknowledged or expired.', { - event: 'interaction.help.pagination.unavailable', - errorCode: String(error.code), - customId: interaction.customId, - interactionId: interaction.id, - }); - return; - } - - throw error; - } +export default { + data: new SlashCommandBuilder().setName("help").setDescription("Displays the help menu"), + async execute(interaction, guildConfig, client) { + await InteractionHelper.safeDefer(interaction); + const { embeds, components } = await createInitialHelpMenu(client); + await InteractionHelper.safeEditReply(interaction, { embeds, components }); }, }; - - diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js index b71a26b3d..a2f65d530 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/helpSelectMenus.js @@ -1,392 +1,39 @@ -import { createEmbed } from '../utils/embeds.js'; -import { createButton, getPaginationRow } from '../utils/components.js'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; +import { getCategoryEmbedAndPageCount, createHelpPaginationButtons } from '../utils/helpMenuHelper.js'; import { logger } from '../utils/logger.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const BACK_BUTTON_ID = "help-back-to-main"; -const ALL_COMMANDS_ID = "help-all-commands"; -const PAGINATION_PREFIX = "help-page"; -const CATEGORY_SELECT_ID = "help-category-select"; -const FOOTER_TEXT = "Made with ❤️"; -const SUBCOMMAND_TYPE = 1; -const SUBCOMMAND_GROUP_TYPE = 2; - -const CATEGORY_ICONS = { - Core: "ℹ️", - Moderation: "🛡️", - Economy: "💰", - Fun: "🎮", - Leveling: "📊", - Utility: "🔧", - Ticket: "🎫", - Welcome: "👋", - Giveaway: "🎉", - Counter: "🔢", - Tools: "🛠️", - Search: "🔍", - Reaction_Roles: "🎭", - Community: "👥", - Birthday: "🎂", - Config: "⚙️", -}; - -function buildHelpEntries(command, category) { - const commandData = normalizeCommandData(command); - if (!commandData?.name) { - return []; - } - - const baseName = commandData.name; - const baseDescription = commandData.description || "No description"; - const options = commandData.options || []; - - const entries = []; - - for (const option of options) { - if (!option) continue; - - if (option.type === SUBCOMMAND_TYPE) { - entries.push({ - baseName, - displayName: `${baseName} ${option.name}`, - description: option.description || baseDescription, - category, - }); - continue; - } - - if (option.type === SUBCOMMAND_GROUP_TYPE) { - const nestedOptions = option.options || []; - for (const nested of nestedOptions) { - if (nested?.type !== SUBCOMMAND_TYPE) continue; - - entries.push({ - baseName, - displayName: `${baseName} ${option.name} ${nested.name}`, - description: nested.description || option.description || baseDescription, - category, - }); - } - } - } - - if (entries.length === 0) { - entries.push({ - baseName, - displayName: baseName, - description: baseDescription, - category, - }); - } - - return entries; -} - -function normalizeCommandData(command) { - const rawData = command?.data; - if (!rawData) { - return null; - } - - const jsonData = typeof rawData.toJSON === 'function' ? rawData.toJSON() : rawData; - if (!jsonData?.name) { - return null; - } - - return { - ...jsonData, - options: Array.isArray(jsonData.options) - ? jsonData.options.map((option) => - typeof option?.toJSON === 'function' ? option.toJSON() : option, - ) - : [], - }; -} - -async function createCategoryCommandsMenu(category, client) { - const categoryName = - category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); - const icon = CATEGORY_ICONS[categoryName] || "🔍"; - - const categoryCommands = []; - - try { - const categoryPath = path.join(__dirname, "../commands", category); - const commandFiles = (await fs.readdir(categoryPath)) - .filter((file) => file.endsWith(".js")) - .sort(); - - for (const file of commandFiles) { - const filePath = path.join(categoryPath, file); - const commandModule = await import(`file://${filePath}`); - const command = commandModule.default; - const commandData = normalizeCommandData(command); - - if (commandData) { - if ( - commandData.name === "help" || - commandData.name === "commandlist" - ) - continue; - - categoryCommands.push(...buildHelpEntries(command, categoryName)); - } - } - } catch (error) { - logger.error( - `Error reading commands from category ${category}:`, - error, - ); - } - - categoryCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); - - let registeredCommands = new Collection(); - try { - if (client?.application?.commands?.fetch) { - const commands = await client.application.commands.fetch(); - for (const cmd of commands.values()) { - registeredCommands.set(cmd.name, cmd); - } - } - } catch (error) { - logger.error('Error fetching registered commands:', error); - } - - const embed = createEmbed({ - title: `${icon} ${categoryName} Commands`, - description: categoryCommands.length > 0 - ? `Click any command mention below to use it:` - : `No commands found in the **${categoryName}** category.` - }); - - if (categoryCommands.length > 0) { - const commandMentions = categoryCommands - .map((cmd) => { - const registeredCmd = registeredCommands.get(cmd.baseName); - if (registeredCmd && registeredCmd.id) { - return ` · ${cmd.description}`; - } - return `\`/${cmd.displayName}\` · ${cmd.description}`; - }) - .join("\n"); - - const maxLength = 1000; - if (commandMentions.length <= maxLength) { - embed.addFields({ - name: "Commands", - value: commandMentions, - inline: false, - }); - } else { - const chunks = []; - let currentChunk = ""; - const lines = commandMentions.split("\n"); - - for (const line of lines) { - if ((currentChunk + "\n" + line).length > maxLength) { - if (currentChunk) chunks.push(currentChunk); - currentChunk = line; - } else { - currentChunk += (currentChunk ? "\n" : "") + line; - } - } - if (currentChunk) chunks.push(currentChunk); - - chunks.forEach((chunk, index) => { - embed.addFields({ - name: `Commands (Part ${index + 1})`, - value: chunk, - inline: false, - }); - }); - } - } - - embed.setFooter({ text: FOOTER_TEXT }); - embed.setTimestamp(); - - const backButton = createButton( - BACK_BUTTON_ID, - "Back", - "primary", - "⬅️", - false, - ); - - const buttonRow = new ActionRowBuilder().addComponents(backButton); - - return { - embeds: [embed], - components: [buttonRow], - }; -} - -export async function createAllCommandsMenu(page = 1, client) { - const commandsPerPage = 45; - const allCommands = []; - - const commandsPath = path.join(__dirname, "../commands"); - const categoryDirs = ( - await fs.readdir(commandsPath, { withFileTypes: true }) - ) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .sort(); - - for (const category of categoryDirs) { - try { - const categoryPath = path.join( - __dirname, - "../commands", - category, - ); - const commandFiles = (await fs.readdir(categoryPath)) - .filter((file) => file.endsWith(".js")) - .sort(); - - for (const file of commandFiles) { - const filePath = path.join(categoryPath, file); - const commandModule = await import(`file://${filePath}`); - const command = commandModule.default; - const commandData = normalizeCommandData(command); - - if (commandData) { - if ( - commandData.name === "help" || - commandData.name === "commandlist" - ) - continue; - - const categoryName = - category.charAt(0).toUpperCase() + - category.slice(1).toLowerCase(); - - allCommands.push(...buildHelpEntries(command, categoryName)); - } - } - } catch (error) { - logger.error( - `Error reading commands from category ${category}:`, - error, - ); - } - } - - allCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); - - let registeredCommands = new Collection(); - try { - if (client?.application?.commands?.fetch) { - const commands = await client.application.commands.fetch(); - for (const cmd of commands.values()) { - registeredCommands.set(cmd.name, cmd); - } - } - } catch (error) { - logger.error('Error fetching registered commands:', error); - } - - const totalPages = Math.ceil(allCommands.length / commandsPerPage); - const startIndex = (page - 1) * commandsPerPage; - const endIndex = startIndex + commandsPerPage; - const pageCommands = allCommands.slice(startIndex, endIndex); - - const embed = createEmbed({ - title: "📋 All Commands", - description: `(${allCommands.length} total commands, including subcommands)` - }); - - embed.setFooter({ text: FOOTER_TEXT }); - embed.setTimestamp(); - - if (pageCommands.length > 0) { - const commandMentions = pageCommands.map((cmd) => { - const registeredCmd = registeredCommands.get(cmd.baseName); - if (registeredCmd && registeredCmd.id) { - return ` · ${cmd.category}`; - } - return `\`/${cmd.displayName}\` · ${cmd.category}`; - }); - - const columnCount = pageCommands.length > 20 ? 3 : (pageCommands.length > 10 ? 2 : 1); - const chunkSize = Math.ceil(commandMentions.length / columnCount); - - for (let i = 0; i < columnCount; i++) { - const chunk = commandMentions - .slice(i * chunkSize, (i + 1) * chunkSize) - .join("\n"); - - if (!chunk) continue; - - embed.addFields({ - name: i === 0 ? `Commands (Page ${page})` : "Commands (cont.)", - value: chunk, - inline: columnCount > 1, - }); - } - } - - const components = []; - - if (totalPages > 1) { - const paginationRow = getPaginationRow( - PAGINATION_PREFIX, - page, - totalPages, - ); - components.push(paginationRow); - } - - const backButton = createButton( - BACK_BUTTON_ID, - "Back", - "primary", - "⬅️", - false, - ); - - const buttonRow = new ActionRowBuilder().addComponents(backButton); - components.push(buttonRow); - - return { - embeds: [embed], - components, - currentPage: page, - totalPages, - }; -} +import { MessageFlags } from 'discord.js'; export const helpCategorySelectMenu = { - name: CATEGORY_SELECT_ID, + name: 'help-category-select', async execute(interaction, client) { try { if (!interaction.deferred && !interaction.replied) { await interaction.deferUpdate(); } + // 1. Lấy category mà người dùng chọn 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, + + // Nếu chọn "All Commands" (ID: help-all-commands) thì xử lý riêng hoặc ignore + if (selectedCategory === 'help-all-commands') { + return await interaction.editReply({ + content: 'Coming soon: All commands view! Please use the category view instead.' }); } + + // 2. Lấy Embed trang đầu tiên (page 1) của category đó + const { embed, totalPages } = await getCategoryEmbedAndPageCount(selectedCategory, 1, client); + + // 3. Tạo nút bấm chuyển trang cho category này + const row = createHelpPaginationButtons(1, totalPages, selectedCategory); + + // 4. Cập nhật tin nhắn + await interaction.editReply({ + embeds: [embed], + components: [row] + }); } catch (error) { + logger.error('Error in help category select menu handler:', error); + if (error?.code === 40060 || error?.code === 10062) { logger.warn('Help category select interaction already acknowledged or expired.', { event: 'interaction.help.select.unavailable', @@ -396,18 +43,13 @@ export const helpCategorySelectMenu = { }); return; } - - logger.error('Error in help category select menu handler:', error); + if (!interaction.replied && !interaction.deferred) { await interaction.reply({ - content: 'An error occurred while loading help categories.', + content: '❌ An error occurred while loading help categories.', flags: MessageFlags.Ephemeral, }); } } }, }; - - - - diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help.js index 9e7a01126..1bdad99a5 100644 --- a/src/interactions/buttons/help.js +++ b/src/interactions/buttons/help.js @@ -1,19 +1,34 @@ -import { - helpBackButton, - helpBugReportButton, - helpPaginationButton, -} from '../../handlers/helpButtons.js'; +import { getCategoryEmbedAndPageCount, getAllCommandsEmbedAndPageCount, createHelpPaginationButtons } from '../../utils/helpMenuHelper.js'; +import { logger } from '../../utils/logger.js'; -const paginationIds = [ - 'help-page_first', - 'help-page_prev', - 'help-page_next', - 'help-page_last', -]; +export default { + name: 'help', + async execute(interaction, client, args) { + try { + // args format: [action, page, category] + const action = args[0]; // 'next' or 'back' + const currentPage = parseInt(args[1]) || 1; + const category = args[2] || 'help-all-commands'; -const paginationInteractions = paginationIds.map((name) => ({ - name, - execute: helpPaginationButton.execute, -})); + const newPage = action === 'next' ? currentPage + 1 : currentPage - 1; -export default [helpBackButton, helpBugReportButton, ...paginationInteractions]; \ No newline at end of file + let result; + if (category === 'help-all-commands') { + result = await getAllCommandsEmbedAndPageCount(newPage, client); + } else { + result = await getCategoryEmbedAndPageCount(category, newPage, client); + } + + const { embed, totalPages } = result; + const row = createHelpPaginationButtons(newPage, totalPages, category); + + await interaction.editReply({ + embeds: [embed], + components: [row] + }); + } catch (error) { + logger.error('Error in help button handler:', error); + await interaction.editReply({ content: '❌ An error occurred while updating the help menu.' }); + } + } +}; diff --git a/src/interactions/selectMenus/help-category-select.js b/src/interactions/selectMenus/help-category-select.js new file mode 100644 index 000000000..37d62d3ba --- /dev/null +++ b/src/interactions/selectMenus/help-category-select.js @@ -0,0 +1,32 @@ +import { getCategoryEmbedAndPageCount, getAllCommandsEmbedAndPageCount, createHelpPaginationButtons } from '../../utils/helpMenuHelper.js'; +import { logger } from '../../utils/logger.js'; + +export default { + name: 'help-category-select', + async execute(interaction, client, args) { + try { + await interaction.deferUpdate(); // Quan trọng: Đánh dấu đã nhận tương tác + + const selectedCategory = interaction.values[0]; + let result; + + // Xử lý đúng cho All Commands + if (selectedCategory === 'help-all-commands') { + result = await getAllCommandsEmbedAndPageCount(1, client); + } else { + result = await getCategoryEmbedAndPageCount(selectedCategory, 1, client); + } + + const { embed, totalPages } = result; + const row = createHelpPaginationButtons(1, totalPages, selectedCategory); + + await interaction.editReply({ + embeds: [embed], + components: [row] + }); + } catch (error) { + logger.error('Error in select menu handler:', error); + await interaction.editReply({ content: '❌ Failed to load category.' }); + } + } +}; diff --git a/src/services/punishmentService.js b/src/services/punishmentService.js new file mode 100644 index 000000000..e68665bb2 --- /dev/null +++ b/src/services/punishmentService.js @@ -0,0 +1,161 @@ +import { pgDb } from '../utils/postgresDatabase.js'; +import { getFromDb, setInDb } from '../utils/database.js'; +import { logger } from '../utils/logger.js'; + +const TABLE = 'moderation_punishments'; + +/** + * PunishmentService — persistent record of all moderation actions. + * Writes to PostgreSQL when available, falls back to key-value store. + * Used to detect & prevent punishment evasion when users rejoin. + */ +export class PunishmentService { + + /** + * Record a new punishment. + * @param {object} opts + * @param {string} opts.guildId + * @param {string} opts.userId + * @param {string} opts.moderatorId + * @param {'BAN'|'KICK'|'TIMEOUT'|'WARN'} opts.action + * @param {string} [opts.reason] + * @param {number|null} [opts.durationMinutes] — null = no expiry (ban/kick/warn) + * @param {number|null} [opts.caseId] + */ + static async record({ guildId, userId, moderatorId, action, reason = 'No reason provided', durationMinutes = null, caseId = null }) { + const expiresAt = durationMinutes + ? new Date(Date.now() + durationMinutes * 60 * 1000) + : null; + + try { + if (pgDb.isAvailable()) { + const result = await pgDb.pool.query( + `INSERT INTO ${TABLE} + (guild_id, user_id, moderator_id, action, reason, duration_minutes, expires_at, active, case_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,TRUE,$8) + RETURNING id`, + [guildId, userId, moderatorId, action, reason, durationMinutes, expiresAt, caseId] + ); + logger.debug(`Punishment recorded [${action}] for ${userId} in ${guildId} (id=${result.rows[0]?.id})`); + return { success: true, id: result.rows[0]?.id }; + } + } catch (err) { + logger.warn(`PunishmentService.record PG failed, falling back to KV:`, err.message); + } + + // KV fallback + const key = `punishments:${guildId}:${userId}`; + const list = await getFromDb(key, []); + const entry = { + id: Date.now(), + guildId, userId, moderatorId, action, reason, + durationMinutes, + expiresAt: expiresAt?.toISOString() ?? null, + active: true, + caseId, + createdAt: new Date().toISOString(), + }; + list.push(entry); + if (list.length > 200) list.splice(0, list.length - 200); + await setInDb(key, list); + return { success: true, id: entry.id }; + } + + /** + * Return active (non-expired, non-revoked) punishments for a user. + */ + static async getActive(guildId, userId) { + try { + if (pgDb.isAvailable()) { + const result = await pgDb.pool.query( + `SELECT * FROM ${TABLE} + WHERE guild_id = $1 + AND user_id = $2 + AND active = TRUE + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC`, + [guildId, userId] + ); + return result.rows; + } + } catch (err) { + logger.warn('PunishmentService.getActive PG failed:', err.message); + } + + const key = `punishments:${guildId}:${userId}`; + const list = await getFromDb(key, []); + const now = Date.now(); + return list.filter(p => + p.active && (!p.expiresAt || new Date(p.expiresAt).getTime() > now) + ); + } + + /** + * Return the full punishment history for a user (newest first). + */ + static async getUserHistory(guildId, userId, limit = 25) { + try { + if (pgDb.isAvailable()) { + const result = await pgDb.pool.query( + `SELECT * FROM ${TABLE} + WHERE guild_id = $1 AND user_id = $2 + ORDER BY created_at DESC + LIMIT $3`, + [guildId, userId, limit] + ); + return result.rows; + } + } catch (err) { + logger.warn('PunishmentService.getUserHistory PG failed:', err.message); + } + + const key = `punishments:${guildId}:${userId}`; + const list = await getFromDb(key, []); + return [...list] + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + .slice(0, limit); + } + + /** + * Deactivate all active punishments of a given action type for a user. + * Called on unban / untimeout. + */ + static async deactivate(guildId, userId, action) { + try { + if (pgDb.isAvailable()) { + await pgDb.pool.query( + `UPDATE ${TABLE} + SET active = FALSE, updated_at = NOW() + WHERE guild_id = $1 AND user_id = $2 AND action = $3 AND active = TRUE`, + [guildId, userId, action] + ); + return true; + } + } catch (err) { + logger.warn('PunishmentService.deactivate PG failed:', err.message); + } + + // KV fallback + const key = `punishments:${guildId}:${userId}`; + const list = await getFromDb(key, []); + let changed = false; + for (const p of list) { + if (p.action === action && p.active) { p.active = false; changed = true; } + } + if (changed) await setInDb(key, list); + return true; + } + + /** + * Count total punishments by type for a user. + */ + static async countByAction(guildId, userId) { + const history = await this.getUserHistory(guildId, userId, 1000); + const counts = { BAN: 0, KICK: 0, TIMEOUT: 0, WARN: 0 }; + for (const p of history) { + if (counts[p.action] !== undefined) counts[p.action]++; + } + counts.total = history.length; + return counts; + } +} diff --git a/src/utils/components.js b/src/utils/components.js index 4babb09f9..cb8619722 100644 --- a/src/utils/components.js +++ b/src/utils/components.js @@ -84,7 +84,7 @@ export function createButton(customId, label, style = 'primary', emoji = null, d try { button.setEmoji(emoji); } catch (error) { - + // Invalid emoji format — skip silently } } @@ -111,7 +111,7 @@ export function createLinkButton(label, url, emoji = null) { try { button.setEmoji(emoji); } catch (error) { - + // Invalid emoji format — skip silently } } @@ -142,7 +142,7 @@ export function createButtonRow(buttons) { )); } } catch (error) { - + // Skip invalid button definition continue; } } diff --git a/src/utils/embeds.js b/src/utils/embeds.js index b3ca90607..c67ff1139 100644 --- a/src/utils/embeds.js +++ b/src/utils/embeds.js @@ -50,7 +50,7 @@ export function createEmbed({ embed.setAuthor(author); } } catch (error) { - + // Invalid author URL or format — skip silently to avoid crashing the embed } } @@ -63,7 +63,7 @@ export function createEmbed({ embed.setFooter(footer); } } catch (error) { - + // Invalid footer iconURL or format — skip silently to avoid crashing the embed } } @@ -76,7 +76,7 @@ export function createEmbed({ embed.setThumbnail(thumbnail.url); } } catch (error) { - + // Invalid thumbnail URL — skip silently to avoid crashing the embed } } @@ -89,7 +89,7 @@ export function createEmbed({ embed.setImage(image.url); } } catch (error) { - + // Invalid image URL — skip silently to avoid crashing the embed } } @@ -105,7 +105,7 @@ export function createEmbed({ try { embed.setURL(url); } catch (error) { - + // Invalid URL format — skip silently to avoid crashing the embed } } diff --git a/src/utils/helpMenuHelper.js b/src/utils/helpMenuHelper.js new file mode 100644 index 000000000..50d185524 --- /dev/null +++ b/src/utils/helpMenuHelper.js @@ -0,0 +1,254 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +import { createEmbed } from "./embeds.js"; +import { createSelectMenu } from "./components.js"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CATEGORY_SELECT_ID = "help-category-select"; +const ALL_COMMANDS_ID = "help-all-commands"; + +const CATEGORY_ICONS = { + Core: "ℹ️", Moderation: "🛡️", Fun: "🎮", Leveling: "📊", Utility: "🔧", + Ticket: "🎫", Welcome: "👋", Giveaway: "🎉", Counter: "🔢", Tools: "🛠️", + Search: "🔍", Reaction_Roles: "🎭", Community: "👥", Birthday: "🎂", Config: "⚙️", +}; + +/** + * Tạo menu help ban đầu + * @param {Client} client - Discord bot client + * @returns {Promise<{embeds: Array, components: Array}>} + */ +export async function createInitialHelpMenu(client) { + const commandsPath = path.join(__dirname, "../commands"); + const categoryDirs = (await fs.readdir(commandsPath, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort(); + + const options = [ + { label: "📋 All Commands", description: "View all available commands", value: ALL_COMMANDS_ID }, + ...categoryDirs.map((category) => { + const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); + const icon = CATEGORY_ICONS[categoryName] || "🔍"; + return { label: `${icon} ${categoryName}`, description: `View commands in ${categoryName}`, value: category }; + }), + ]; + + const botName = client?.user?.username || "Starlight Security"; + const embed = createEmbed({ + title: `🤖 ${botName} Help Center`, + description: "Welcome! Here is the list of available modules.", + color: 'primary' + }); + + embed.addFields( + { name: "🛡️ Moderation", value: "Tools for server protection", inline: true }, + { name: "🎮 Fun", value: "Entertainment commands", inline: true }, + { name: "📊 Leveling", value: "XP and progression", inline: true }, + { name: "🎫 Tickets", value: "Support ticket system", inline: true }, + { name: "🎉 Giveaways", value: "Automated giveaways", inline: true }, + { name: "✅ Verification", value: "Access gating", inline: true } + ); + embed.setFooter({ text: "Starlight Security | Secured by Dev" }); + embed.setTimestamp(); + + const bugReportButton = new ButtonBuilder() + .setLabel("Contact Developer") + .setStyle(ButtonStyle.Link) + .setURL("https://discord.com/users/1198136184526864475"); + + const selectRow = createSelectMenu(CATEGORY_SELECT_ID, "Select a category", options); + const buttonRow = new ActionRowBuilder().addComponents([bugReportButton]); + + return { embeds: [embed], components: [buttonRow, selectRow] }; +} + +/** + * Lấy danh sách tất cả categories + * @returns {Promise} + */ +export async function getAllCategories() { + const commandsPath = path.join(__dirname, "../commands"); + const categoryDirs = (await fs.readdir(commandsPath, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort(); + return categoryDirs; +} + +/** + * Lấy description của command từ file + * @param {string} filePath - Path đến file command + * @returns {Promise} + */ +async function getCommandDescription(filePath) { + try { + const commandModule = await import(`file://${filePath}`); + const command = commandModule.default; + + if (command?.data?.description) { + return command.data.description; + } + } catch (error) { + // Silently skip — command file may not export a description + } + return "No description available"; +} + +/** + * Tạo embed cho một category cụ thể với pagination + * @param {string} category - Category name + * @param {number} page - Page number + * @param {Client} client - Discord bot client + * @returns {Promise<{embed: EmbedBuilder, totalPages: number, currentPage: number}>} + */ +export async function getCategoryEmbedAndPageCount(category, page = 1, client) { + const commandsPath = path.join(__dirname, "../commands"); + const categoryPath = path.join(commandsPath, category); + + try { + const files = (await fs.readdir(categoryPath)) + .filter(file => file.endsWith('.js')) + .sort(); + + const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); + const icon = CATEGORY_ICONS[categoryName] || "🔍"; + const pageSize = 5; + const totalPages = Math.ceil(files.length / pageSize) || 1; + + // Validate page number - FIX: ensure page is within valid range + const validPage = Math.max(1, Math.min(page, totalPages)); + + const startIndex = (validPage - 1) * pageSize; + const paginatedFiles = files.slice(startIndex, startIndex + pageSize); + + const embed = createEmbed({ + title: `${icon} ${categoryName} Commands`, + description: `Page ${validPage} of ${totalPages}`, + color: 'primary' + }); + + // Load descriptions for each command + for (const file of paginatedFiles) { + const commandName = file.replace('.js', ''); + const filePath = path.join(categoryPath, file); + const description = await getCommandDescription(filePath); + + embed.addFields({ + name: `• ${commandName}`, + value: description, + inline: false + }); + } + + embed.setFooter({ text: `Starlight Security | Page ${validPage}/${totalPages}` }); + embed.setTimestamp(); + + return { embed, totalPages, currentPage: validPage }; + } catch (error) { + throw error; + } +} + +/** + * Tạo embed cho "All Commands" view + * @param {number} page - Page number + * @param {Client} client - Discord bot client + * @returns {Promise<{embed: EmbedBuilder, totalPages: number, currentPage: number}>} + */ +export async function getAllCommandsEmbedAndPageCount(page = 1, client) { + const commandsPath = path.join(__dirname, "../commands"); + const categoryDirs = (await fs.readdir(commandsPath, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort(); + + try { + const allCommands = []; + + // Collect all commands from all categories + for (const category of categoryDirs) { + const categoryPath = path.join(commandsPath, category); + const files = (await fs.readdir(categoryPath)) + .filter(file => file.endsWith('.js')) + .sort(); + + for (const file of files) { + const commandName = file.replace('.js', ''); + const filePath = path.join(categoryPath, file); + const description = await getCommandDescription(filePath); + const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); + + allCommands.push({ + name: commandName, + description, + category: categoryName, + icon: CATEGORY_ICONS[categoryName] || "🔍" + }); + } + } + + allCommands.sort((a, b) => a.name.localeCompare(b.name)); + + const pageSize = 10; + const totalPages = Math.ceil(allCommands.length / pageSize) || 1; + + // Validate page number - FIX: ensure page is within valid range + const validPage = Math.max(1, Math.min(page, totalPages)); + + const startIndex = (validPage - 1) * pageSize; + const paginatedCommands = allCommands.slice(startIndex, startIndex + pageSize); + + const embed = createEmbed({ + title: `📋 All Commands`, + description: `Page ${validPage} of ${totalPages} (Total: ${allCommands.length} commands)`, + color: 'primary' + }); + + for (const cmd of paginatedCommands) { + embed.addFields({ + name: `${cmd.icon} ${cmd.name}`, + value: `${cmd.description}\n*Category: ${cmd.category}*`, + inline: false + }); + } + + embed.setFooter({ text: `Starlight Security | Page ${validPage}/${totalPages}` }); + embed.setTimestamp(); + + return { embed, totalPages, currentPage: validPage }; + } catch (error) { + throw error; + } +} + +/** + * Tạo pagination buttons cho help menu + * Format customId: help:action:page:category + * @param {number} currentPage - Current page number + * @param {number} totalPages - Total number of pages + * @param {string} category - Category name (or 'all' for all commands) + * @returns {ActionRowBuilder} + */ +export function createHelpPaginationButtons(currentPage, totalPages, category = '') { + const canGoBack = currentPage > 1; + const canGoNext = currentPage < totalPages; + + const backButton = new ButtonBuilder() + .setCustomId(`help:back:${currentPage - 1}:${category}`) + .setLabel('← Back') + .setStyle(ButtonStyle.Primary) + .setDisabled(!canGoBack); + + const nextButton = new ButtonBuilder() + .setCustomId(`help:next:${currentPage + 1}:${category}`) + .setLabel('Next →') + .setStyle(ButtonStyle.Primary) + .setDisabled(!canGoNext); + + return new ActionRowBuilder().addComponents(backButton, nextButton); +} diff --git a/src/utils/moderation.js b/src/utils/moderation.js index 24690cd1f..e12bd471a 100644 --- a/src/utils/moderation.js +++ b/src/utils/moderation.js @@ -4,22 +4,6 @@ import { logger } from './logger.js'; import { getFromDb, setInDb } from './database.js'; import { getColor } from '../config/bot.js'; - - - - - - - - - - - - - - - - export async function logEvent({ client, guild, guildId, event }) { try { if (!guild && guildId) { @@ -48,7 +32,6 @@ export async function logEvent({ client, guild, guildId, event }) { return; } - const actionStyles = { 'Member Banned': { color: getColor('error'), icon: '🔨' }, 'Member Kicked': { color: getColor('warning'), icon: '👢' }, @@ -125,12 +108,6 @@ export async function logEvent({ client, guild, guildId, event }) { } } - - - - - - export async function generateCaseId(client, guildId) { try { const caseKey = `moderation_cases_${guildId}`; @@ -140,7 +117,7 @@ export async function generateCaseId(client, guildId) { return nextCase; } catch (error) { logger.error("Error generating case ID:", error); -return Date.now(); + return Date.now(); } } @@ -179,29 +156,32 @@ export async function storeModerationCase({ guildId, caseId, caseData }) { } } - - - - - - +/** + * Get filtered moderation cases + * @param {string} guildId - The guild ID + * @param {Object} filters - Search filters + * @returns {Promise} Filtered cases + */ export async function getModerationCases(guildId, filters = {}) { try { const { userId, moderatorId, action, limit = 50, offset = 0 } = filters; - const allCases = []; - const caseListKey = `moderation_cases_list_${guildId}`; const caseList = await getFromDb(caseListKey, []); let filteredCases = caseList; + // TỐI ƯU HÓA: Tìm kiếm thông minh qua cả ID trực tiếp lẫn quét chuỗi text hiển thị 'target' if (userId) { - filteredCases = filteredCases.filter(case_ => case_.targetUserId === userId); + filteredCases = filteredCases.filter(case_ => { + return case_.targetUserId === userId || (case_.target && case_.target.includes(userId)); + }); } if (moderatorId) { - filteredCases = filteredCases.filter(case_ => case_.moderatorId === moderatorId); + filteredCases = filteredCases.filter(case_ => { + return case_.moderatorId === moderatorId || (case_.executor && case_.executor.includes(moderatorId)); + }); } if (action) { @@ -252,5 +232,3 @@ export async function logModerationAction({ client, guild, event }) { return caseId; } - - diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index 7e8d76d90..037a0d94c 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -405,6 +405,21 @@ class PostgreSQLDatabase { value JSONB NOT NULL, expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + + `CREATE TABLE IF NOT EXISTS ${pgConfig.tables.moderation_punishments} ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(20) NOT NULL, + user_id VARCHAR(20) NOT NULL, + moderator_id VARCHAR(20) NOT NULL, + action VARCHAR(20) NOT NULL, + reason TEXT DEFAULT 'No reason provided', + duration_minutes INTEGER, + expires_at TIMESTAMP, + active BOOLEAN DEFAULT TRUE, + case_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )` ]; @@ -442,6 +457,10 @@ class PostgreSQLDatabase { `CREATE INDEX IF NOT EXISTS idx_economy_guild_id ON ${pgConfig.tables.economy}(guild_id)`, `CREATE INDEX IF NOT EXISTS idx_verification_audit_guild_id ON ${pgConfig.tables.verification_audit}(guild_id)`, `CREATE INDEX IF NOT EXISTS idx_verification_audit_user_id ON ${pgConfig.tables.verification_audit}(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_punishments_guild_user ON ${pgConfig.tables.moderation_punishments}(guild_id, user_id)`, + `CREATE INDEX IF NOT EXISTS idx_punishments_active ON ${pgConfig.tables.moderation_punishments}(guild_id, user_id, active)`, + `CREATE INDEX IF NOT EXISTS idx_punishments_expires_at ON ${pgConfig.tables.moderation_punishments}(expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_punishments_guild_action ON ${pgConfig.tables.moderation_punishments}(guild_id, action)`, `CREATE INDEX IF NOT EXISTS idx_verification_audit_created_at ON ${pgConfig.tables.verification_audit}(created_at)`, `CREATE INDEX IF NOT EXISTS idx_temp_data_expires_at ON ${pgConfig.tables.temp_data}(expires_at)`, `CREATE INDEX IF NOT EXISTS idx_cache_data_expires_at ON ${pgConfig.tables.cache_data}(expires_at)`