diff --git a/src/app.js b/src/app.js index 693cd228ea..f4ed84bc85 100644 --- a/src/app.js +++ b/src/app.js @@ -11,7 +11,7 @@ import { getServerCounters, saveServerCounters, updateCounter } from './services import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; -import { loadCommands, registerCommands as registerSlashCommands } from './handlers/loaders/commandLoader.js'; +import { loadCommands, registerCommands as registerGlobalSlashCommands } from './handlers/loaders/commandLoader.js'; import { runSafeTask, handleTaskError, ErrorCodes } from './utils/errorHandler.js'; import { initializeMusic } from './services/music/riffySetup.js'; import { shutdownMusic } from './services/music/playerHandler.js'; @@ -324,9 +324,9 @@ class TitanBot extends Client { async registerCommands() { try { - await registerSlashCommands(this, { clientId: this.config.bot.clientId }); + await registerGlobalSlashCommands(this, { clientId: this.config.bot.clientId }); } catch (error) { - logger.error('Error registering commands:', error); + logger.error('Error registering slash commands:', error); } } @@ -428,4 +428,4 @@ try { process.exit(1); } -export default TitanBot; \ No newline at end of file +export default TitanBot; diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index 0e2c2c9249..075a93a310 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -35,7 +35,6 @@ function getApplicationStatusPresentation(statusValue) { } export default { - slashOnly: true, data: new SlashCommandBuilder() .setName("apply") .setDescription("Manage role applications") diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js index 856a28b918..a66fef4717 100644 --- a/src/commands/Community/modules/app_dashboard.js +++ b/src/commands/Community/modules/app_dashboard.js @@ -137,7 +137,6 @@ async function refreshDashboard(rootInteraction, settings, roles, guildId, clien } export default { - prefixOnly: false, async execute(interaction, config, client, selectedAppName = null) { try { const guildId = interaction.guild.id; diff --git a/src/commands/Core/configWizard.js b/src/commands/Core/configWizard.js index 22ac013ec0..02bf6385ce 100644 --- a/src/commands/Core/configWizard.js +++ b/src/commands/Core/configWizard.js @@ -603,7 +603,6 @@ async function handleSettingModalSubmit(selectInteraction, rootInteraction, sett } export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('configwizard') .setDescription('Open the server configuration dashboard and setup wizard') diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..15fe6219bf 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -140,7 +140,6 @@ export async function createInitialHelpMenu(client) { } export default { - slashOnly: true, data: new SlashCommandBuilder() .setName("help") .setDescription("Displays the help menu with all available commands"), diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 1a90387315..153c9fb103 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -7,6 +7,14 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +const LUCK_POTION_IDS = new Set([ + 'luck_potion_1', + 'luck_potion_2', + 'luck_potion_3', + 'luck_potion_4', + 'luck_potion_5', +]); +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; export default { data: new SlashCommandBuilder() @@ -28,7 +36,9 @@ export default { ), execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); + const deferred = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); if (!deferred) return; const userId = interaction.user.id; @@ -62,6 +72,8 @@ export default { const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; const userData = await getEconomyData(client, guildId, userId); + const alreadyOwnsFishingRod = userData.fishingRodOwned === true + || Number(userData.inventory?.fishing_rod || 0) > 0; if (userData.wallet < totalCost) { throw createError( @@ -136,6 +148,14 @@ export default { } else if (item.type === "upgrade") { userData.upgrades[itemId] = true; successDescription += `\n\n**✨ Your upgrade is now active!**`; + } else if (item.type === "consumable" && LUCK_POTION_IDS.has(itemId)) { + const now = Date.now(); + const expiresAt = userData.luckPotionExpiresAt || {}; + const currentExpiry = Math.max(now, Number(expiresAt[itemId]) || 0); + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + expiresAt[itemId] = currentExpiry + (duration * quantity); + userData.luckPotionExpiresAt = expiresAt; + successDescription += `\n\nšŸ€ **Active for ${Math.ceil((expiresAt[itemId] - now) / 60000)} minutes. Timer started at purchase.**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; @@ -144,6 +164,10 @@ export default { } } + if (alreadyOwnsFishingRod || itemId === "fishing_rod") { + userData.fishingRodOwned = true; + } + await setEconomyData(client, guildId, userId, userData); const embed = successEmbed( @@ -155,6 +179,21 @@ export default { inline: true, }); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); + if (interaction._isPrefixCommand) { + try { + await interaction.user.send({ embeds: [embed] }); + const sourceMessage = InteractionHelper.getCoordinator(interaction)?.message; + await sourceMessage?.delete().catch(() => {}); + } catch { + const sourceMessage = InteractionHelper.getCoordinator(interaction)?.message; + await sourceMessage?.delete().catch(() => {}); + await interaction.channel.send( + `${interaction.user}, pembelian berhasil, tetapi DM bot kamu tertutup. Aktifkan DM agar struk pembelian bisa dikirim secara pribadi.` + ); + } + return; + } + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'buy' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index 4045d23ed0..32cf3977a0 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; import { formatDuration } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; @@ -10,7 +9,6 @@ import { botConfig } from '../../config/bot.js'; const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -const PREMIUM_BONUS_PERCENTAGE = 0.1; export default { data: new SlashCommandBuilder() @@ -50,25 +48,7 @@ export default { ); } - 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; - } + const earned = DAILY_AMOUNT; userData.wallet = (userData.wallet || 0) + earned; userData.lastDaily = now; @@ -80,13 +60,12 @@ export default { 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}` + `You have claimed your daily **$${earned.toLocaleString()}**!` ) .addFields({ name: "New Cash Balance", @@ -94,11 +73,9 @@ export default { inline: true, }) .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, + text: `Next claim in 24 hours.`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'daily' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.js index d702eb6157..ca73dee69a 100644 --- a/src/commands/Economy/economy.js +++ b/src/commands/Economy/economy.js @@ -4,7 +4,6 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import economyDashboard from './modules/economy_dashboard.js'; export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('economy') .setDescription('Economy management commands') diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 5288f048bb..90ed06015f 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -1,13 +1,25 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { getItemById } from '../../config/shop/items.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; +const BASE_MIN_REWARD = 1500; +const BASE_MAX_REWARD = 4500; +const FISHING_ROD_MULTIPLIER = getItemById('fishing_rod')?.effect?.multiplier ?? 1.1; +const PREMIUM_FISHING_MULTIPLIER = getItemById('premium_role')?.effect?.multiplier ?? 1.1; +const LUCK_POTION_IDS = [ + 'luck_potion_5', + 'luck_potion_4', + 'luck_potion_3', + 'luck_potion_2', + 'luck_potion_1', +]; +const LUCK_POTION_FISH_COOLDOWN = 30 * 1000; +const DEFAULT_LUCK_POTION_DURATION = 5 * 60 * 1000; const FISH_TYPES = [ { name: 'Bass', emoji: '🐟', rarity: 'common' }, @@ -29,6 +41,23 @@ const CATCH_MESSAGES = [ "You reel in your catch with expert precision...", ]; +function getFishForLuckBonus(luckBonus = 0) { + const roll = Math.random(); + const commonThreshold = 0.5 - luckBonus; + const uncommonThreshold = 0.75 - (luckBonus * 0.6); + const rareThreshold = 0.9 - (luckBonus * 0.3); + const epicThreshold = 0.98 - (luckBonus * 0.1); + + let rarity = 'legendary'; + if (roll < commonThreshold) rarity = 'common'; + else if (roll < uncommonThreshold) rarity = 'uncommon'; + else if (roll < rareThreshold) rarity = 'rare'; + else if (roll < epicThreshold) rarity = 'epic'; + + const fishOfRarity = FISH_TYPES.filter((fish) => fish.rarity === rarity); + return fishOfRarity[Math.floor(Math.random() * fishOfRarity.length)]; +} + export default { data: new SlashCommandBuilder() .setName('fish') @@ -44,59 +73,81 @@ export default { const userData = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; + const hasFishingRod = userData.fishingRodOwned === true + || Number(userData.inventory?.["fishing_rod"] || 0) > 0; + const guildConfig = await getGuildConfig(client, guildId); + const premiumRoleId = guildConfig.premiumRoleId; + const hasPremiumRole = Boolean( + premiumRoleId && interaction.member?.roles?.cache?.has(premiumRoleId) + ); + const inventory = userData.inventory || {}; + const potionExpiries = { ...(userData.luckPotionExpiresAt || {}) }; + + for (const potionId of LUCK_POTION_IDS) { + const legacyQuantity = Number(inventory[potionId] || 0); + if (Number(potionExpiries[potionId]) <= now && legacyQuantity > 0) { + const duration = Number(getItemById(potionId)?.effect?.duration) + || DEFAULT_LUCK_POTION_DURATION; + potionExpiries[potionId] = now + (duration * legacyQuantity); + inventory[potionId] = 0; + } + } - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; + userData.luckPotionExpiresAt = potionExpiries; + const activePotionId = LUCK_POTION_IDS.find( + (potionId) => Number(potionExpiries[potionId]) > now + ) || null; + const luckBonus = activePotionId + ? Number(getItemById(activePotionId)?.effect?.fishingLuckBonus) || 0 + : 0; + const fishCooldown = activePotionId ? LUCK_POTION_FISH_COOLDOWN : FISH_COOLDOWN; + + if (now < lastFish + fishCooldown) { + const remaining = lastFish + fishCooldown - now; const hours = Math.floor(remaining / (1000 * 60 * 60)); const minutes = Math.floor( (remaining % (1000 * 60 * 60)) / (1000 * 60), ); + const seconds = Math.ceil((remaining % (1000 * 60)) / 1000); 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.`, + `You're too tired to fish right now. Rest for **${hours}h ${minutes}m ${seconds}s** 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 fishCaught = getFishForLuckBonus(luckBonus); 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%**`; + let fishingMultiplier = 1; + const bonusMessages = []; + if (hasFishingRod) { + fishingMultiplier *= FISHING_ROD_MULTIPLIER; + bonusMessages.push(`šŸŽ£ **Fishing Rod Bonus: +${Math.round((FISHING_ROD_MULTIPLIER - 1) * 100)}%**`); } + if (hasPremiumRole) { + fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; + bonusMessages.push(`✨ **Premium Fishing Bonus: +${Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100)}%**`); + } + const finalEarned = Math.floor(baseEarned * fishingMultiplier); + const multiplierMessage = bonusMessages.length ? `\n${bonusMessages.join('\n')}` : ""; const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; + const luckMessage = activePotionId + ? `\nšŸ€ **${getItemById(activePotionId).name} Active:** +${Math.round(luckBonus * 100)}% chance for higher-rarity fish.` + : ""; + const nextFishingText = activePotionId ? "30 seconds while your potion is active." : "45 minutes."; userData.wallet += finalEarned; userData.lastFish = now; + if (hasFishingRod) { + userData.fishingRodOwned = true; + } await setEconomyData(client, guildId, userId, userData); @@ -110,7 +161,7 @@ export default { const embed = createEmbed({ title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, + description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}${luckMessage}`, color: rarityColors[fishCaught.rarity] }) .addFields( @@ -125,8 +176,8 @@ export default { inline: true, } ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); + .setFooter({ text: `Next fishing trip available in ${nextFishingText}` }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index b7efcfec80..f176397bc7 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -34,25 +34,25 @@ export default { } 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"); - } + const inventoryLines = Object.entries(inventory) + .filter(([itemId, quantity]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + return Number(quantity) > 0 && item; + }) + .map(([itemId, quantity]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + return `**${item.name}:** ${quantity}x`; + }); + const potionLines = Object.entries(userData.luckPotionExpiresAt || {}) + .filter(([, expiresAt]) => Number(expiresAt) > Date.now()) + .map(([itemId, expiresAt]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + const minutesLeft = Math.ceil((Number(expiresAt) - Date.now()) / 60000); + return item ? `šŸ€ **${item.name}:** Active (${minutesLeft}m left)` : null; + }) + .filter(Boolean); + const inventoryDescription = [...inventoryLines, ...potionLines].join("\n") + || "Your inventory is currently empty."; logger.info(`[ECONOMY] Inventory retrieved`, { userId, @@ -67,4 +67,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js index 04f507ff97..b5633309d6 100644 --- a/src/commands/Economy/modules/economy_dashboard.js +++ b/src/commands/Economy/modules/economy_dashboard.js @@ -140,7 +140,6 @@ async function updateConfigFile(currencySymbol, currencyName) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guild = interaction.guild; diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.js index fb3be41dc3..66a162d45c 100644 --- a/src/commands/Economy/shop-config.js +++ b/src/commands/Economy/shop-config.js @@ -2,7 +2,6 @@ import { SlashCommandBuilder } from 'discord.js'; import shopConfigSetrole from './modules/shop_config_setrole.js'; export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('shop-config') .setDescription('Configure shop settings. (Manage Server required)') diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index 16b2b62d96..365c8491de 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -2,7 +2,6 @@ import { SlashCommandBuilder } from 'discord.js'; import shopBrowse from './modules/shop_browse.js'; export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('shop') .setDescription('Browse the economy shop.'), diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js index 3d8ee34b61..1dfbe786b7 100644 --- a/src/commands/Leveling/modules/level_dashboard.js +++ b/src/commands/Leveling/modules/level_dashboard.js @@ -142,7 +142,6 @@ async function refreshDashboard(rootInteraction, cfg, guildId) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guildId = interaction.guild.id; diff --git a/src/commands/Logging/modules/logging_channel.js b/src/commands/Logging/modules/logging_channel.js index f92e6a6d3b..f324a11fc8 100644 --- a/src/commands/Logging/modules/logging_channel.js +++ b/src/commands/Logging/modules/logging_channel.js @@ -12,7 +12,6 @@ const DESTINATION_LABELS = { }; export default { - prefixOnly: false, async execute(interaction, config, client) { try { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js index f317698ccf..008c62d419 100644 --- a/src/commands/Logging/modules/logging_dashboard.js +++ b/src/commands/Logging/modules/logging_dashboard.js @@ -176,7 +176,6 @@ export async function refreshDashboardMessage(interaction, client) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js index 1d07665382..3ac621d2cf 100644 --- a/src/commands/Music/play.js +++ b/src/commands/Music/play.js @@ -3,7 +3,6 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; export default { - slashOnly: true, category: 'Music', data: new SlashCommandBuilder() .setName('play') diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js index 66faa4eb9d..ac2d5b9c92 100644 --- a/src/commands/Music/queue.js +++ b/src/commands/Music/queue.js @@ -3,7 +3,6 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { buildQueueReply } from '../../services/music/musicActions.js'; export default { - slashOnly: true, category: 'Music', data: new SlashCommandBuilder() .setName('queue') diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js index 17f3f43f88..beb54f9534 100644 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -256,7 +256,6 @@ async function updateLivePanel(client, guild, config, guildId) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guildId = interaction.guild.id; diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js index 3da94f3dc1..29ecd5097f 100644 --- a/src/commands/Tools/embedbuilder.js +++ b/src/commands/Tools/embedbuilder.js @@ -1039,7 +1039,6 @@ async function handleJsonExport(selectInteraction, rootInteraction, state) { } export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('embedbuilder') .setDescription('Build and post a fully custom embed with live preview') diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.js index 941265dca8..8a17cbeb26 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipedata.js @@ -5,7 +5,6 @@ import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('wipedata') .setDescription('Delete all your personal data from the bot (irreversible)'), diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js index 7209da79fd..75b49296f5 100644 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -137,7 +137,6 @@ async function refreshDashboard(rootInteraction, cfg, guildId, client) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guildId = interaction.guild.id; diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js index ab32689527..9ca37c28e7 100644 --- a/src/commands/Verification/modules/verification_dashboard.js +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -226,7 +226,6 @@ async function refreshDashboard(rootInteraction, cfg, guildId, client) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guildId = interaction.guild.id; diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.js index aa823e14bb..14644ef626 100644 --- a/src/commands/Welcome/greet.js +++ b/src/commands/Welcome/greet.js @@ -5,7 +5,6 @@ import { handleInteractionError, TitanBotError, ErrorTypes, replyUserError } fro import greetDashboard from './modules/greet_dashboard.js'; export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('greet') .setDescription('Manage welcome & goodbye settings') diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js index 97581e7cd5..06e4f5f9f8 100644 --- a/src/commands/Welcome/modules/greet_dashboard.js +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -170,7 +170,6 @@ async function refreshDashboard(rootInteraction, cfg, guildId) { } export default { - prefixOnly: false, async execute(interaction, config, client) { try { const guildId = interaction.guild.id; diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..6bb31a2741 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -51,7 +51,7 @@ export const botConfig = { maintenanceMode: process.env.MAINTENANCE_MODE === "true", // Command prefix for text-based commands (e.g., "!" for "!ping"). - // Supports both slash commands and prefix commands. + // The bot runs in prefix-only mode; slash commands are disabled. prefix: process.env.PREFIX || "!", }, diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js deleted file mode 100644 index 2b57f7bd60..0000000000 --- a/src/config/commands/prefixRestrictions.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Prefix command restrictions — dashboard and advanced setup flows stay slash-only. - */ - -/** Top-level commands that cannot be invoked via prefix at all. */ -export const SLASH_ONLY_COMMANDS = new Set([ - 'configwizard', - 'help', - 'embedbuilder', - 'wipedata', - 'apply', -]); - -/** Subcommands blocked for every command when invoked via prefix. */ -export const GLOBAL_BLOCKED_SUBCOMMANDS = new Set([ - 'dashboard', - 'setup', -]); - -/** Subcommand groups blocked for every command when invoked via prefix. */ -export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set([ - 'config', -]); - -/** Per-command subcommands that stay slash-only (beyond the global block list). */ -export const COMMAND_BLOCKED_SUBCOMMANDS = { - music: new Set([ - 'shuffle', - 'loop', - 'seek', - 'remove', - 'move', - 'clear', - '247', - ]), - birthday: new Set(['setchannel']), - report: new Set(['setchannel']), -}; - -function collectSubcommandNames(commandJson) { - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); - - if (subcommandGroup) { - const names = []; - for (const group of subcommandGroup.options || []) { - names.push(...(group.options?.map((opt) => opt.name) || [])); - } - return names; - } - - return (commandJson.options?.filter((opt) => opt.type === 1) || []).map((sub) => sub.name); -} - -function isSubcommandBlocked(commandName, subcommandName) { - if (!subcommandName) { - return false; - } - - if (GLOBAL_BLOCKED_SUBCOMMANDS.has(subcommandName)) { - return true; - } - - const commandBlocked = COMMAND_BLOCKED_SUBCOMMANDS[commandName]; - return commandBlocked?.has(subcommandName) ?? false; -} - -/** - * Returns whether a prefix invocation should be rejected. - * @param {object} command - Loaded command module - * @param {string[]} args - Parsed prefix arguments (after command name) - * @param {(name: string) => string} resolveSubcommandAlias - * @returns {{ blocked: boolean, reason?: string }} - */ -export function getPrefixRestriction(command, args, resolveSubcommandAlias) { - if (!command?.data?.toJSON) { - return { blocked: false }; - } - - const commandJson = command.data.toJSON(); - const commandName = commandJson.name?.toLowerCase(); - - if (command.prefixOnly === false || command.slashOnly === true) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - if (SLASH_ONLY_COMMANDS.has(commandName)) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - const [firstArg, secondArg] = args.map((arg) => arg?.toLowerCase?.() || null); - const resolvedFirstArg = firstArg ? resolveSubcommandAlias(firstArg) : null; - const resolvedSecondArg = secondArg ? resolveSubcommandAlias(secondArg) : null; - - const subcommandGroup = commandJson.options?.find((opt) => opt.type === 2); - - const allSubcommandNames = collectSubcommandNames(commandJson); - const allSubcommandsBlocked = - allSubcommandNames.length > 0 && - allSubcommandNames.every((name) => isSubcommandBlocked(commandName, name)); - - if (allSubcommandsBlocked) { - return { blocked: true, reason: 'This command is only available as a slash command.' }; - } - - if (firstArg && GLOBAL_BLOCKED_SUBCOMMAND_GROUPS.has(firstArg)) { - return { - blocked: true, - reason: 'This configuration flow is only available as a slash command.', - }; - } - - if (resolvedFirstArg && isSubcommandBlocked(commandName, resolvedFirstArg)) { - return { - blocked: true, - reason: 'This subcommand is only available as a slash command.', - }; - } - - if (subcommandGroup && resolvedSecondArg && isSubcommandBlocked(commandName, resolvedSecondArg)) { - return { - blocked: true, - reason: 'This subcommand is only available as a slash command.', - }; - } - - return { blocked: false }; -} - -export function isPrefixRestrictedCommand(command, args, resolveSubcommandAlias) { - return getPrefixRestriction(command, args, resolveSubcommandAlias).blocked; -} diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 5f47eaeaf5..4668cdbf87 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -41,11 +41,11 @@ cooldown: 86400000, id: 'premium_role', name: 'Premium Server Role', price: 15000, - description: 'A special role granting a fancy color and a 10% daily bonus.', + description: 'A special role granting a fancy color and a 10% fishing earnings bonus.', type: 'role', roleId: null, effect: { - type: 'daily_bonus', + type: 'fishing_yield', multiplier: 1.1 } }, @@ -66,12 +66,12 @@ roleId: null, id: 'fishing_rod', name: 'šŸŽ£ Fishing Rod', price: 5000, - description: 'Used for fishing commands', + description: 'Increases fishing earnings by 10%.', type: 'tool', durability: 100, effect: { type: 'fishing_yield', - multiplier: 1.0 + multiplier: 1.1 } }, { @@ -111,6 +111,71 @@ roleId: null, uses: 3 } }, + { + id: 'luck_potion_1', + name: 'Luck Potion I', + price: 10000, + description: 'Boosts higher-rarity fish chances by 5% for 5 minutes after purchase.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + fishingLuckBonus: 0.05, + duration: 300000 + } + }, + { + id: 'luck_potion_2', + name: 'Luck Potion II', + price: 20000, + description: 'Boosts higher-rarity fish chances by 10% for 5 minutes after purchase.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + fishingLuckBonus: 0.1, + duration: 300000 + } + }, + { + id: 'luck_potion_3', + name: 'Luck Potion III', + price: 30000, + description: 'Boosts higher-rarity fish chances by 15% for 5 minutes after purchase.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + fishingLuckBonus: 0.15, + duration: 300000 + } + }, + { + id: 'luck_potion_4', + name: 'Luck Potion IV', + price: 40000, + description: 'Boosts higher-rarity fish chances by 20% for 5 minutes after purchase.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + fishingLuckBonus: 0.2, + duration: 300000 + } + }, + { + id: 'luck_potion_5', + name: 'Luck Potion V', + price: 50000, + description: 'Boosts higher-rarity fish chances by 25% for 5 minutes after purchase.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + fishingLuckBonus: 0.25, + duration: 300000 + } + }, { id: 'bank_note', name: 'šŸ“œ Bank Note', @@ -200,4 +265,4 @@ export function validatePurchase(itemId, userData) { } return { valid: true }; -} \ No newline at end of file +} diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 50a468b73e..32b3fd04c8 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,4 +1,4 @@ -import { Events, MessageFlags } from 'discord.js'; +import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/config/guildConfig.js'; import { @@ -12,29 +12,126 @@ import { handleApplicationModal } from '../commands/Community/apply.js'; import { handleInteractionError, createError, ErrorTypes, ErrorCodes } from '../utils/errorHandler.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; import { createInteractionTraceContext, runWithTraceContext } from '../utils/logger.js'; -import { validateChatInputPayloadOrThrow } from '../utils/commandInputValidation.js'; -import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; -import { isCommandEnabled } from '../services/commandAccessService.js'; -import { resolveSlashAccessKey } from '../utils/messageAdapter.js'; import { isCollectorManagedComponent } from '../utils/collectorComponents.js'; import { ResponseCoordinator } from '../utils/responseCoordinator.js'; +import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; +import { isCommandEnabled } from '../services/commandAccessService.js'; import { enforceDefaultCommandPermissions } from '../utils/permissionGuard.js'; -const COMMAND_ERROR_SUBTYPES = { - warn: 'warn_failed', - kick: 'kick_failed', - ban: 'ban_failed', - unban: 'unban_failed', - timeout: 'timeout_failed', - untimeout: 'untimeout_failed', - warnings: 'warnings_view_failed', - ticket: 'ticket_failed', - serverstats: 'serverstats_failed', - gcreate: 'giveaway_failed', - gend: 'giveaway_failed', - gdelete: 'giveaway_failed', - greroll: 'giveaway_failed', -}; +function getSlashAccessKey(interaction) { + const commandName = interaction.commandName; + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(false); + + if (subcommandGroup && subcommand) { + return `${commandName} ${subcommandGroup} ${subcommand}`; + } + return subcommand ? `${commandName} ${subcommand}` : commandName; +} + +async function executeChatInputCommand(interaction, client, traceContext) { + 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.', + { commandName: interaction.commandName, traceId: traceContext.traceId } + ); + } + + if (isMaintenanceMode() && !isBotOwner(interaction.user.id)) { + throw createError('Bot is in maintenance mode', ErrorTypes.CONFIGURATION, getBotMessage('maintenanceMode')); + } + + if (!isCommandCategoryEnabled(command.category)) { + throw createError( + `Feature disabled for category ${command.category}`, + ErrorTypes.CONFIGURATION, + getBotMessage('commandDisabled') + ); + } + + const defaultCooldownSec = Number(botConfig.commands?.defaultCooldown) || 0; + if (defaultCooldownSec > 0 && !isBotOwner(interaction.user.id)) { + const cooldownKey = `${interaction.user.id}:${interaction.commandName}`; + const expiresAt = client.cooldowns.get(cooldownKey); + if (expiresAt && Date.now() < expiresAt) { + const remainingSec = Math.ceil((expiresAt - Date.now()) / 1000); + throw createError( + `Default command cooldown active for ${interaction.commandName}`, + ErrorTypes.RATE_LIMIT, + getBotMessage('cooldownActive', { time: `${remainingSec}s` }) + ); + } + client.cooldowns.set(cooldownKey, Date.now() + defaultCooldownSec * 1000); + } + + 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.`, + { expected: true, subtype: 'command_cooldown' } + ); + } + + let guildConfig = null; + if (interaction.guildId) { + guildConfig = await getGuildConfig(client, interaction.guildId, traceContext); + const accessKey = getSlashAccessKey(interaction); + if (!(await isCommandEnabled(client, interaction.guildId, accessKey, command.category))) { + throw createError( + `Command ${accessKey} is disabled in this guild`, + ErrorTypes.CONFIGURATION, + 'This command has been disabled for this server.', + { commandName: accessKey, guildId: interaction.guildId } + ); + } + } + + const permissionAllowed = await enforceDefaultCommandPermissions(interaction, command, { + source: 'interactionCreate', + guildConfig, + }); + if (permissionAllowed) { + await command.execute(interaction, guildConfig, client); + } +} + +async function handleAutocomplete(interaction, client) { + const command = client.commands.get(interaction.commandName); + if (command?.autocomplete) { + await command.autocomplete(interaction, client); + return; + } + + const focusedOption = interaction.options.getFocused(true); + if ( + ['apply', 'app-admin'].includes(interaction.commandName) + && focusedOption.name === 'application' + ) { + try { + const { getApplicationRoles } = await import('../utils/database.js'); + const roles = await getApplicationRoles(client, interaction.guildId); + const query = String(focusedOption.value || '').toLowerCase(); + const choices = roles + .filter((role) => interaction.commandName !== 'apply' || role.enabled !== false) + .filter((role) => role.name.toLowerCase().startsWith(query)) + .slice(0, 25) + .map((role) => ({ name: role.name, value: role.name })); + await interaction.respond(choices); + } catch (error) { + logger.error('Error handling application autocomplete:', error); + await interaction.respond([]).catch(() => {}); + } + return; + } + + await interaction.respond([]).catch(() => {}); +} function withTraceContext(context = {}, traceContext = {}) { return { @@ -65,248 +162,33 @@ export default { traceId: interactionTraceContext.traceId, guildId: interaction.guildId, userId: interaction.user?.id, - command: interaction.commandName + 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) - ); - } - - if (isMaintenanceMode() && !isBotOwner(interaction.user.id)) { - throw createError( - 'Bot is in maintenance mode', - ErrorTypes.CONFIGURATION, - getBotMessage('maintenanceMode'), - withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) - ); - } - - if (!isCommandCategoryEnabled(command.category)) { - throw createError( - `Feature disabled for category ${command.category}`, - ErrorTypes.CONFIGURATION, - getBotMessage('commandDisabled'), - withTraceContext({ commandName: interaction.commandName, category: command.category }, interactionTraceContext) - ); - } - - const defaultCooldownSec = Number(botConfig.commands?.defaultCooldown) || 0; - if (defaultCooldownSec > 0 && !isBotOwner(interaction.user.id)) { - const cooldownKey = `${interaction.user.id}:${interaction.commandName}`; - const expiresAt = client.cooldowns.get(cooldownKey); - - if (expiresAt && Date.now() < expiresAt) { - const remainingSec = Math.ceil((expiresAt - Date.now()) / 1000); - throw createError( - `Default command cooldown active for ${interaction.commandName}`, - ErrorTypes.RATE_LIMIT, - getBotMessage('cooldownActive', { time: `${remainingSec}s` }), - withTraceContext({ commandName: interaction.commandName, remainingSec }, interactionTraceContext) - ); - } - - client.cooldowns.set(cooldownKey, Date.now() + defaultCooldownSec * 1000); - } - - 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); - const accessKey = resolveSlashAccessKey(interaction); - if (!(await isCommandEnabled(client, interaction.guild.id, accessKey, command.category))) { - throw createError( - `Command ${accessKey} is disabled in this guild`, - ErrorTypes.CONFIGURATION, - 'This command has been disabled for this server.', - withTraceContext({ commandName: accessKey, guildId: interaction.guild.id }, interactionTraceContext) - ); - } - } - - const permissionAllowed = await enforceDefaultCommandPermissions(interaction, command, { - source: 'interactionCreate', - guildConfig, - }); - if (!permissionAllowed) { - return; - } - - await command.execute(interaction, guildConfig, client); + await executeChatInputCommand(interaction, client, interactionTraceContext); } catch (error) { await handleInteractionError(interaction, error, withTraceContext({ type: 'command', commandName: interaction.commandName, - subtype: COMMAND_ERROR_SUBTYPES[interaction.commandName] || error?.context?.subtype, }, interactionTraceContext)); } - } else if (interaction.isAutocomplete()) { - const autocompleteCommand = client.commands.get(interaction.commandName); - if (autocompleteCommand?.autocomplete) { - try { - await autocompleteCommand.autocomplete(interaction, client); - } catch (error) { - logger.error('Error handling command autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName, - }); - await interaction.respond([]).catch(() => {}); - } - return; - } - - 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); - - 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); - - 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; - } + return; + } - const validPanels = []; - for (const panel of panels) { - if (!panel.messageId || !panel.channelId) { - continue; - } - - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) { - await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); - continue; - } - validPanels.push(panel); - } - - if (validPanels.length === 0) { - await interaction.respond([]); - return; - } - - const choices = await Promise.all( - validPanels.slice(0, 25).map(async panel => { - try { - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) return null; - - const msg = await channel.messages.fetch(panel.messageId).catch(() => null); - if (!msg) return null; - - const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; - const channelName = channel?.name ?? 'unknown'; - - return { - name: `${title} (${channelName})`.substring(0, 100), - value: panel.messageId - }; - } catch (e) { - return null; - } - }) - ); - - const validChoices = choices.filter(c => c !== null); - await interaction.respond(validChoices); - } catch (error) { - logger.error('Error handling reactroles autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } + if (interaction.isAutocomplete()) { + try { + await handleAutocomplete(interaction, client); + } catch (error) { + logger.error('Error handling command autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName, + }); + await interaction.respond([]).catch(() => {}); } - } else if (interaction.isButton()) { + return; + } + + if (interaction.isButton()) { if (interaction.customId.startsWith('shared_todo_')) { const parts = interaction.customId.split('_'); const buttonType = parts.slice(0, 3).join('_'); @@ -468,4 +350,4 @@ export default { } }); } -}; \ No newline at end of file +}; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fb1a165f31..970566c783 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -5,8 +5,7 @@ import { addXp } from '../services/leveling/xpSystem.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { parsePrefixCommand } from '../utils/prefixParser.js'; import { supportsPrefixExecution, executePrefixCommand, resolvePrefixAccessKey } from '../utils/messageAdapter.js'; -import { resolveCommandAlias, resolveSubcommandAlias } from '../config/commands/commandAliases.js'; -import { getPrefixRestriction } from '../config/commands/prefixRestrictions.js'; +import { resolveCommandAlias } from '../config/commands/commandAliases.js'; import { getGuildConfig } from '../services/config/guildConfig.js'; import { getCommandPrefix, getBotMessage, isBotOwner, isCommandCategoryEnabled, isMaintenanceMode } from '../config/bot.js'; import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; @@ -95,16 +94,7 @@ async function handlePrefixCommand(message, client) { return; } - const restriction = getPrefixRestriction(command, args, resolveSubcommandAlias); - if (!supportsPrefixExecution(command) || restriction.blocked) { - if (restriction.blocked && restriction.reason) { - const embed = createEmbed({ - title: 'Slash Command Only', - description: `${restriction.reason}\nUse \`/${resolvedCommandName}\` instead.`, - color: 'info', - }); - await message.channel.send({ embeds: [embed] }).catch(() => {}); - } + if (!supportsPrefixExecution(command)) { return; } diff --git a/src/handlers/loaders/commandLoader.js b/src/handlers/loaders/commandLoader.js index ecb7ad96d4..c3c83785cb 100644 --- a/src/handlers/loaders/commandLoader.js +++ b/src/handlers/loaders/commandLoader.js @@ -3,12 +3,9 @@ import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import { Collection } from 'discord.js'; import { logger } from '../../utils/logger.js'; -import botConfig from '../../config/bot.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const MAX_COMMANDS = 100; -const COMMAND_COUNT_WARN_THRESHOLD = 90; function getSubcommandInfo(commandData) { const subcommands = []; @@ -100,179 +97,45 @@ export async function loadCommands(client) { } } - const commandsWithSubcommands = Array.from(client.commands.values()).filter(cmd => { - const subcommands = getSubcommandInfo(cmd.data.toJSON()); - return subcommands.length > 0; - }); - - const totalSubcommands = commandsWithSubcommands.reduce((total, cmd) => { - return total + getSubcommandInfo(cmd.data.toJSON()).length; - }, 0); - - const uniqueCommands = new Set(); - for (const [name, command] of client.commands.entries()) { - if (command.data && command.data.name) { - uniqueCommands.add(command.data.name); - } - } - - logger.info(`Loaded ${uniqueCommands.size} commands`); + logger.info(`Loaded ${client.commands.size} commands`); return client.commands; } -function collectCommandPayloads(client) { +export async function registerCommands(client, options = {}) { + const { clientId = null } = options; + + if (!clientId) { + throw new Error('CLIENT_ID is required for slash command registration'); + } + + if (!client.rest) { + throw new Error('Discord REST client is not available for slash command registration'); + } + const commands = []; - let totalSubcommands = 0; const registeredNames = new Set(); for (const command of client.commands.values()) { - if (!command.data || typeof command.data.toJSON !== 'function') { - logger.warn(`Command missing data or toJSON method: ${command}`); + if (!command.data?.toJSON) { continue; } - const commandName = command.data.name; - logger.debug(`Processing command for registration: ${commandName}`); - - if (registeredNames.has(commandName)) { - logger.debug(`Skipping duplicate command: ${commandName}`); - continue; - } - - registeredNames.add(commandName); const commandJson = command.data.toJSON(); - commands.push(commandJson); - totalSubcommands += getSubcommandInfo(commandJson).length; - - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Registering command: ${commandName}`); - } - } - - return { commands, totalSubcommands }; -} - -function validateCommands(commands) { - const validationErrors = []; - - for (const cmd of commands) { - if (cmd.name && cmd.name.length > 32) { - validationErrors.push(`Command ${cmd.name} has name longer than 32 chars: "${cmd.name}" (${cmd.name.length} chars)`); - } - if (cmd.description && cmd.description.length > 110) { - validationErrors.push(`Command ${cmd.name} has description longer than 110 chars: "${cmd.description}" (${cmd.description.length} chars)`); - } - - if (!cmd.options) { + if (registeredNames.has(commandJson.name)) { continue; } - for (const option of cmd.options) { - if (option.name && option.name.length > 32) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has name longer than 32 chars: "${option.name}" (${option.name.length} chars)`); - } - if (option.description && option.description.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} has description longer than 110 chars: "${option.description}" (${option.description.length} chars)`); - } - - if (option.choices) { - for (const choice of option.choices) { - if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); - } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} option ${option.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); - } - } - } - - if (!option.options) { - continue; - } - - for (const subOption of option.options) { - if (subOption.name && subOption.name.length > 32) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has name longer than 32 chars: "${subOption.name}" (${subOption.name.length} chars)`); - } - if (subOption.description && subOption.description.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} has description longer than 110 chars: "${subOption.description}" (${subOption.description.length} chars)`); - } - - if (!subOption.choices) { - continue; - } - - for (const choice of subOption.choices) { - if (choice.name && choice.name.length > 110) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has name longer than 110 chars: "${choice.name}" (${choice.name.length} chars)`); - } - if (choice.value && choice.value.length > 100) { - validationErrors.push(`Command ${cmd.name} subcommand ${option.name} option ${subOption.name} choice ${choice.name} has value longer than 100 chars: "${choice.value}" (${choice.value.length} chars)`); - } - } - } - } - } - - if (validationErrors.length > 0) { - logger.error('Command validation failed. Errors:'); - validationErrors.forEach((error) => logger.error(` - ${error}`)); - throw new Error(`Command validation failed with ${validationErrors.length} errors`); - } -} - -function prepareCommandsForRegistration(commands) { - if (commands.length >= COMMAND_COUNT_WARN_THRESHOLD) { - logger.warn(`Command count (${commands.length}) is near Discord's ${MAX_COMMANDS} global command limit`); - } - - if (commands.length <= MAX_COMMANDS) { - return commands; - } - - logger.warn(`Command count (${commands.length}) exceeds Discord limit (${MAX_COMMANDS}), truncating...`); - const truncated = commands.slice(0, MAX_COMMANDS); - logger.info(`Truncated to ${truncated.length} commands for registration`); - return truncated; -} - -async function registerGlobalCommands(client, clientId, commands, totalSubcommands) { - if (!clientId) { - throw new Error('CLIENT_ID is required for slash command registration'); - } - - if (!client.rest) { - throw new Error('Discord REST client is not available for slash command registration'); + registeredNames.add(commandJson.name); + commands.push(commandJson); } - logger.info(`Preparing to register ${totalSubcommands + commands.length} commands globally`); - logger.info('Validating commands before registration...'); - validateCommands(commands); - logger.info('Command validation passed'); - - const commandsToRegister = prepareCommandsForRegistration(commands); - - if (botConfig.commands?.deleteCommands) { - logger.info('Clearing existing global commands before registration...'); - await client.rest.put(`/applications/${clientId}/commands`, { body: [] }); + if (commands.length > 100) { + throw new Error(`Cannot register ${commands.length} global slash commands; Discord allows at most 100`); } - logger.info(`Registering ${commandsToRegister.length} global commands...`); - await client.rest.put(`/applications/${clientId}/commands`, { body: commandsToRegister }); - logger.info(`Successfully registered ${commandsToRegister.length} global commands`); - logger.info('Global commands may take up to an hour to appear in all servers on first deploy'); -} - -export async function registerCommands(client, options = {}) { - const { clientId = null } = options; - - try { - const { commands, totalSubcommands } = collectCommandPayloads(client); - await registerGlobalCommands(client, clientId, commands, totalSubcommands); - } catch (error) { - logger.error('Error registering commands:', error); - throw error; - } + logger.info(`Registering ${commands.length} global slash commands...`); + await client.rest.put(`/applications/${clientId}/commands`, { body: commands }); + logger.info('Successfully registered global slash commands'); } export async function reloadCommand(client, commandName) { @@ -297,4 +160,4 @@ export async function reloadCommand(client, commandName) { logger.error(`Error reloading command "${commandName}":`, error); return { success: false, message: `Error reloading command: ${error.message}` }; } -} \ No newline at end of file +} diff --git a/src/utils/commandInputValidation.js b/src/utils/commandInputValidation.js deleted file mode 100644 index 3dd278a065..0000000000 --- a/src/utils/commandInputValidation.js +++ /dev/null @@ -1,52 +0,0 @@ -// commandInputValidation.js - -import { z } from 'zod'; -import { createError, ErrorTypes } from './errorHandler.js'; - -const OptionValueSchema = z.union([ - z.string().max(2000), - z.number().finite(), - z.boolean(), - z.null() -]); - -const CommandOptionSchema = z.lazy(() => - z.object({ - name: z.string().min(1).max(32), - type: z.number().int().min(1).max(20), - value: OptionValueSchema.optional(), - options: z.array(CommandOptionSchema).max(25).optional() - }) -); - -const CommandInputSchema = z.object({ - commandName: z.string().min(1).max(32), - options: z.array(CommandOptionSchema).max(25) -}); - -export function validateChatInputPayloadOrThrow(interaction, context = {}) { - const payload = { - commandName: interaction?.commandName, - options: Array.isArray(interaction?.options?.data) ? interaction.options.data : [] - }; - - const parsed = CommandInputSchema.safeParse(payload); - if (parsed.success) { - return parsed.data; - } - - throw createError( - 'Invalid command input payload', - ErrorTypes.VALIDATION, - 'One or more command inputs are invalid. Please review your options and try again.', - { - ...context, - errorCode: 'VALIDATION_FAILED', - issues: parsed.error.issues.map((issue) => ({ - path: issue.path.join('.'), - message: issue.message, - code: issue.code - })) - } - ); -} \ No newline at end of file diff --git a/src/utils/economy.js b/src/utils/economy.js index c50e9ab1f3..535687b1e3 100644 --- a/src/utils/economy.js +++ b/src/utils/economy.js @@ -356,8 +356,12 @@ export function getShopInventory() { name: 'Fishing Rod', emoji: 'šŸŽ£', price: 500, - description: 'Catch fish to sell for profit!', - type: 'tool' + description: 'Increases fishing earnings by 10%.', + type: 'tool', + effect: { + type: 'fishing_yield', + multiplier: 1.1 + } }, { id: 'hunting_rifle', @@ -396,4 +400,4 @@ export function getShopInventory() { use: 'gamble' } ]; -} \ No newline at end of file +} diff --git a/src/utils/messageAdapter.js b/src/utils/messageAdapter.js index 2d69d689b4..e2b88c76fd 100644 --- a/src/utils/messageAdapter.js +++ b/src/utils/messageAdapter.js @@ -5,7 +5,6 @@ import { createEmbed } from './embeds.js'; import { handleInteractionError } from './errorHandler.js'; import { logger } from './logger.js'; import { InteractionHelper } from './interactionHelper.js'; -import { SLASH_ONLY_COMMANDS } from '../config/commands/prefixRestrictions.js'; import { getCommandPrefix } from '../config/bot.js'; import { ResponseCoordinator, buildPrefixUsage } from './responseCoordinator.js'; import { enforceDefaultCommandPermissions } from './permissionGuard.js'; @@ -16,21 +15,6 @@ function getCommandJson(commandData) { return commandData?.toJSON ? commandData.toJSON() : commandData; } -export function resolveSlashAccessKey(interaction) { - const subcommandGroup = interaction.options.getSubcommandGroup(false); - const subcommand = interaction.options.getSubcommand(false); - - if (subcommandGroup && subcommand) { - return `${interaction.commandName} ${subcommandGroup} ${subcommand}`; - } - - if (subcommand) { - return `${interaction.commandName} ${subcommand}`; - } - - return interaction.commandName; -} - export function resolvePrefixAccessKey(commandData, args) { const options = mapArgumentsToOptions(args, commandData); const subcommand = options.getSubcommand(); @@ -172,20 +156,7 @@ export function createMockInteraction(message, commandData, args) { } export function supportsPrefixExecution(command) { - if (command.prefixOnly === false || command.slashOnly === true) { - return false; - } - - const commandName = command.data?.name?.toLowerCase(); - if (commandName && SLASH_ONLY_COMMANDS.has(commandName)) { - return false; - } - - if (command.prefixExecute) { - return true; - } - - return !!command.execute; + return typeof command?.execute === 'function' || typeof command?.prefixExecute === 'function'; } export async function executePrefixCommand(command, message, args, client, prefixOverride = null, guildConfig = null) {