diff --git a/.env.example b/.env.example index c2eae4ad28..41e134a97b 100644 --- a/.env.example +++ b/.env.example @@ -51,20 +51,5 @@ BACKUP_DIR=./backups BACKUP_RETENTION_DAYS=14 POSTGRES_RESTORE_URL= -# Music (Lavalink + Riffy) — requires Lavalink v4 nodes -# Default: loads public v4 SSL nodes from lavalink/nodes.json -# Source: https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/ -# LAVALINK_NODES_FILE=lavalink/nodes.json -# Optional: override nodes as JSON array (takes priority over nodes file) -# LAVALINK_NODES=[{"host":"lavalink","port":2333,"password":"youshallnotpass","secure":false,"name":"Main"}] -# Self-hosted single node fallback (used only if nodes file/env array are absent): -# LAVALINK_HOST=localhost -# LAVALINK_PORT=2333 -# LAVALINK_PASSWORD=youshallnotpass -# LAVALINK_SECURE=false -# LAVALINK_NAME=Main -LAVALINK_SEARCH_PLATFORM=ytmsearch -LAVALINK_REST_VERSION=v4 - # Optional Feature/API Keys TMDB_API_KEY= diff --git a/README.md b/README.md index 700ac20739..84c728bb4f 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,6 @@ TitanBot offers a complete suite of tools for Discord server management and comm - **Auto Roles** - Assign roles on join - **Custom Embeds** - Personalized messages -### Music -- **24/7 Mode** - Play music 24/7 -- **Interative Button System** - Manage music through buttons -- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music - @@ -125,23 +120,7 @@ TitanBot is fully containerized for easy deployment. curl http://localhost:3000/health ``` -This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. Music uses public Lavalink v4 nodes from `lavalink/nodes.json` by default. - -### Music - -Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy](https://github.com/riffy-rb/riffy), similar to [Musicify](https://github.com/codebymitch/Musicify). - -1. By default, the bot loads multiple public v4 SSL nodes from [`lavalink/nodes.json`](lavalink/nodes.json) (sourced from [lavalink.darrennathanael.com](https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/)). Edit that file to add or remove nodes. -2. To self-host Lavalink instead, run `docker compose --profile local-lavalink up -d` and set single-node env vars in `.env`: - ```env - LAVALINK_HOST=lavalink - LAVALINK_PORT=2333 - LAVALINK_PASSWORD=youshallnotpass - LAVALINK_SECURE=false - ``` - Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. -3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. -4. Use `/play ` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. +This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. ### Using GitHub Container Registry diff --git a/docker-compose.yml b/docker-compose.yml index 9de8fec548..26180fd36b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: - POSTGRES_SSL=false - AUTO_MIGRATE=true - LOG_LEVEL=${LOG_LEVEL:-warn} - - LAVALINK_NODES_FILE=lavalink/nodes.json - PORT=3000 - WEB_HOST=0.0.0.0 ports: @@ -37,19 +36,6 @@ services: networks: - titan-network - lavalink: - profiles: - - local-lavalink - image: ghcr.io/lavalink-devs/lavalink:4 - container_name: titanbot-lavalink - restart: unless-stopped - environment: - - LAVALINK_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} - volumes: - - ./lavalink/application.yml:/opt/Lavalink/application.yml - networks: - - titan-network - db: image: postgres:15-alpine container_name: titanbot-db diff --git a/lavalink/application.yml b/lavalink/application.yml deleted file mode 100644 index 4c39a6fd7d..0000000000 --- a/lavalink/application.yml +++ /dev/null @@ -1,27 +0,0 @@ -server: - port: 2333 - address: 0.0.0.0 - -lavalink: - plugins: - - dependency: "dev.lavalink.youtube:youtube-plugin:1.13.5" - snapshot: false - server: - password: "${LAVALINK_PASSWORD:youshallnotpass}" - sources: - youtube: false - bandcamp: true - soundcloud: true - http: true - local: false - -plugins: - youtube: - enabled: true - allowSearch: true - allowDirectVideoIds: true - allowDirectPlaylistIds: true - clients: - - MUSIC - - ANDROID_TESTSUITE - - WEB diff --git a/lavalink/nodes.json b/lavalink/nodes.json deleted file mode 100644 index 78e107cec4..0000000000 --- a/lavalink/nodes.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "host": "lavalinkv4.serenetia.com", - "port": 443, - "password": "https://seretia.link/discord", - "secure": true, - "name": "Serenetia" - }, - { - "host": "lavalink.jirayu.net", - "port": 443, - "password": "youshallnotpass", - "secure": true, - "name": "Jirayu" - }, - { - "host": "lava-v4.millohost.my.id", - "port": 443, - "password": "https://discord.gg/mjS5J2K3ep", - "secure": true, - "name": "MilloHost" - }, - { - "host": "lavalink-v4.triniumhost.com", - "port": 443, - "password": "free", - "secure": true, - "name": "TriniumHost" - } -] diff --git a/package-lock.json b/package-lock.json index b11b7cdec6..f615f5e931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "riffy": "1.0.12", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" @@ -1543,16 +1542,6 @@ "node": ">= 6" } }, - "node_modules/riffy": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/riffy/-/riffy-1.0.12.tgz", - "integrity": "sha512-rFVIERWq0FeKINDOTKobUamrvX9m4xdP8NTJfA8thYbLrkOfK6vgZ6UlTjHeBippjFq6xlPv7OGVT2INl6Y0hQ==", - "dependencies": { - "jsdom": "^26.0.0", - "undici": ">=6.11.1", - "ws": "^8.16.0" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3106,16 +3095,6 @@ "util-deprecate": "^1.0.1" } }, - "riffy": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/riffy/-/riffy-1.0.12.tgz", - "integrity": "sha512-rFVIERWq0FeKINDOTKobUamrvX9m4xdP8NTJfA8thYbLrkOfK6vgZ6UlTjHeBippjFq6xlPv7OGVT2INl6Y0hQ==", - "requires": { - "jsdom": "^26.0.0", - "undici": "^6.27.0", - "ws": "^8.16.0" - } - }, "router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", diff --git a/package.json b/package.json index 2430aec17b..0590b6544e 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "pg": "^8.11.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^3.25.76", - "riffy": "1.0.12" + "zod": "^3.25.76" }, "engines": { "node": ">=20.10.0" diff --git a/src/app.js b/src/app.js index 693cd228ea..e1ea97a949 100644 --- a/src/app.js +++ b/src/app.js @@ -13,8 +13,6 @@ import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } 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'; import pkg from '../package.json' with { type: 'json' }; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; @@ -84,8 +82,6 @@ class TitanBot extends Client { await this.loadHandlers(); startupLog('Handlers loaded'); - initializeMusic(this); - startupLog('Logging into Discord...'); await this.login(this.config.bot.token); startupLog('Discord login successful'); @@ -342,10 +338,6 @@ class TitanBot extends Client { cron.getTasks().forEach(task => task.stop()); logger.info('✅ Cron jobs stopped'); - logger.info('Stopping music players...'); - await shutdownMusic(this); - logger.info('✅ Music players stopped'); - if (this.webServer) { logger.info('Closing web server...'); await new Promise((resolve) => this.webServer.close(resolve)); @@ -406,10 +398,6 @@ try { logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); return; } - if (reason?.message?.includes('Queue is empty')) { - return; - } - // A stray rejection is a bug to fix, not a reason to take the bot down. // Log loudly with full context; the central task handler categorizes it. handleTaskError('unhandled_rejection', reason instanceof Error ? reason : new Error(String(reason)), { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..4a2a90c3ee 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -25,7 +25,6 @@ const CATEGORY_ICONS = { Core: "ℹ️", Moderation: "🛡️", Economy: "💰", - Music: "🎵", Fun: "🎮", Leveling: "📊", Utility: "🔧", diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 1a90387315..5fa66141c8 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -7,6 +7,17 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +const ADMIN_POTION_ID = 'admin_potion'; +// Potion ini langsung aktif saat dibeli. Admin potion TIDAK termasuk di sini: +// admin potion disimpan ke inventory dulu supaya bisa dipakai (/use) atau dikirim (/give). +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() @@ -136,11 +147,22 @@ 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 potionExpiries = userData.luckPotionExpiresAt || {}; + const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[itemId] = currentExpiry + (duration * quantity); + userData.luckPotionExpiresAt = potionExpiries; + const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); + successDescription += `\n\n🧪 **Active for ${activeMinutes} minutes. Timer started at purchase.**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; if (item.type === "tool") { successDescription += `\n\n**🛠️ ${item.name} added to your inventory!**`; + } else if (itemId === ADMIN_POTION_ID) { + successDescription += `\n\n👑 **Added to your inventory!** Activate it with \`/use item_id:${ADMIN_POTION_ID}\` or gift it with \`/give\`.`; } } @@ -157,4 +179,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); }, { 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/fish.js b/src/commands/Economy/fish.js index 5288f048bb..ba972374da 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -3,30 +3,140 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getItemById } from '../../config/shop/items.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; -const FISH_COOLDOWN = 45 * 60 * 1000; +const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; +const ADMIN_POTION_ID = 'admin_potion'; +const PREMIUM_FISHING_MULTIPLIER = getItemById('premium_role')?.effect?.multiplier ?? 1.1; -const FISH_TYPES = [ +// Helper: find the highest-tier fishing rod the user owns +function getBestFishingRod(inventory) { + for (let tier = 5; tier >= 1; tier--) { + const id = `fishing_rod_${tier}`; + const legacyTierFiveQuantity = tier === 5 + ? Number(inventory.fishibg_rod_5 || 0) + : 0; + if (Number(inventory[id] || 0) > 0 || legacyTierFiveQuantity > 0) { + const item = getItemById(id); + if (item) return item; + } + } + return null; +} + +// Helper: find the highest-tier luck potion with an active timer +function getBestActiveLuckPotion(potionExpiries, now) { + for (let tier = 5; tier >= 1; tier--) { + const id = `luck_potion_${tier}`; + if (Number(potionExpiries[id]) > now) { + const item = getItemById(id); + if (item) return item; + } + } + return null; +} + +export const FISH_TYPES = [ + // Common { 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: 'Salmon', emoji: '🐠', rarity: 'common' }, + { name: 'forel', emoji: '🐟', rarity: 'common' }, + { name: 'Carp', emoji: '🐟', rarity: 'common' }, + { name: 'Catfish', emoji: '🐟', rarity: 'common' }, + { name: 'Perch', emoji: '🐟', rarity: 'common' }, + { name: 'Sardine', emoji: '🐟', rarity: 'common' }, + { name: 'Mackerel', emoji: '🐟', rarity: 'common' }, + + // Uncommon + { name: 'Tuna', emoji: '🐡', rarity: 'uncommon' }, + { name: 'Swordfish', emoji: '🐟', rarity: 'uncommon' }, + { name: 'Octopus', emoji: '🐙', rarity: 'uncommon' }, + { name: 'Barracuda', emoji: '🐟', rarity: 'uncommon' }, + { name: 'Pufferfish', emoji: '🐡', rarity: 'uncommon' }, + { name: 'Snapper', emoji: '🐠', rarity: 'uncommon' }, + { name: 'Mahi Mahi', emoji: '🐠', rarity: 'uncommon' }, + + // Rare { name: 'Lobster', emoji: '🦞', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: '🐋', rarity: 'legendary' }, + { name: 'Shark', emoji: '🦈', rarity: 'rare' }, + { name: 'Manta Ray', emoji: '🐟', rarity: 'rare' }, + { name: 'Stingray', emoji: '🐟', rarity: 'rare' }, + { name: 'Anglerfish', emoji: '🐡', rarity: 'rare' }, + { name: 'Moray Eel', emoji: '🐍', rarity: 'rare' }, + { name: 'Hammerhead Shark', emoji: '🦈', rarity: 'rare' }, + + // Epic + { name: 'Golden Koi', emoji: '🐠', rarity: 'epic' }, + { name: 'Blue Marlin', emoji: '🐟', rarity: 'epic' }, + { name: 'Crystal Fish', emoji: '💎', rarity: 'epic' }, + { name: 'Moonfish', emoji: '🌙', rarity: 'epic' }, + { name: 'Electric Eel', emoji: '⚡', rarity: 'epic' }, + { name: 'Ruby Grouper', emoji: '🔴', rarity: 'epic' }, + { name: 'Sapphire Tuna', emoji: '🔵', rarity: 'epic' }, + + // Legendary + { name: 'Golden Shark', emoji: '🦈', rarity: 'legendary' }, + { name: 'Sea Dragon', emoji: '🐉', rarity: 'legendary' }, + { name: 'Golden Whale', emoji: '🐋', rarity: 'legendary' }, + { name: 'Storm Serpent', emoji: '🌩️', rarity: 'legendary' }, + { name: 'Aurora Whale', emoji: '🌌', rarity: 'legendary' }, + { name: 'Thunder Marlin', emoji: '⚡', rarity: 'legendary' }, + { name: 'Emerald Leviathan', emoji: '💚', rarity: 'legendary' }, + + // Mythic + { name: 'Leviathan', emoji: '🐉', rarity: 'mythic' }, + { name: 'Megalodon', emoji: '🦈', rarity: 'mythic' }, + { name: 'Phoenix Koi', emoji: '🔥', rarity: 'mythic' }, + { name: 'Celestial Turtle', emoji: '🐢', rarity: 'mythic' }, + { name: 'Abyssal Dragon', emoji: '🐉', rarity: 'mythic' }, + { name: 'Star Kraken', emoji: '🐙', rarity: 'mythic' }, + { name: 'Obsidian Whale', emoji: '🐋', rarity: 'mythic' }, + + // Secret + { name: 'Void Whale', emoji: '🌌', rarity: 'secret' }, + { name: 'Ancient Kraken', emoji: '🐙', rarity: 'secret' }, + { name: 'Abyss Serpent', emoji: '🐍', rarity: 'secret' }, + { name: 'Shadow Leviathan', emoji: '🌑', rarity: 'secret' }, + { name: 'Eclipse Ray', emoji: '🌘', rarity: 'secret' }, + { name: 'Genesis Eel', emoji: '⚡', rarity: 'secret' }, + { name: 'Nebula Shark', emoji: '🦈', rarity: 'secret' }, + + // Divine (Above Secret) + { name: 'Cosmic Leviathan', emoji: '🌠', rarity: 'divine' }, + { name: 'God of Ocean', emoji: '🔱', rarity: 'divine' }, + { name: 'Eternal Whale', emoji: '🌌', rarity: 'divine' }, + { name: 'Ocean Titan', emoji: '🌊', rarity: 'divine' }, + { name: 'Holy Megalodon', emoji: '✨', rarity: 'divine' }, + { name: 'Astral Dragon', emoji: '🐉', rarity: 'divine' }, + { name: 'Solar Koi', emoji: '☀️', rarity: 'divine' }, + + // Transcendent (Above Divine) + { name: 'Chronos Whale', emoji: '⏳', rarity: 'transcendent' }, + { name: 'Omnipotent Kraken', emoji: '👑', rarity: 'transcendent' }, + { name: 'Primordial Serpent', emoji: '🐍', rarity: 'transcendent' }, + { name: 'Infinity Koi', emoji: '♾️', rarity: 'transcendent' }, + { name: 'Genesis Leviathan', emoji: '🌟', rarity: 'transcendent' }, + { name: 'Celestia Dragon', emoji: '🐲', rarity: 'transcendent' }, + + // Celestial (Above Transcendent) — Kitsune, peluang 2x lebih susah dari transcendent + { name: 'Kitsune', emoji: '🦊', rarity: 'celestial' } ]; 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...", + "Kamu melemparkan kailmu ke dalam air yang sangat jernih...", + "Kamu menunggu dengan sabar saat pelampung pancingmu mengapung...", + "Setelah beberapa menit menunggu, kamu merasakan tarikan pada kailmu...", + "Air mulai beriak saat sesuatu memakan umpanmu...", + "Kamu menarik hasil tangkapanmu dengan sangat ahli...", + "Pelampungmu tenggelam! Sepertinya ada ikan yang menyambar...", + "Air di sekitarmu mulai bergejolak...", + "Tarikan semakin kuat, sepertinya ikan ini cukup besar...", + "Kailmu bergerak liar, tangkapanmu berusaha melarikan diri...", + "Setelah perjuangan panjang, akhirnya kamu berhasil mendapatkan ikan...", ]; export default { @@ -44,7 +154,29 @@ export default { const userData = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; + const inventory = userData.inventory || {}; + const potionExpiries = { ...(userData.luckPotionExpiresAt || {}) }; + + const bestRod = getBestFishingRod(inventory); + for (let tier = 1; tier <= 5; tier++) { + const potionId = `luck_potion_${tier}`; + const legacyQuantity = Number(inventory[potionId] || 0); + if (legacyQuantity > 0) { + const currentExpiry = Math.max(now, Number(potionExpiries[potionId]) || 0); + const duration = Number(getItemById(potionId)?.effect?.duration) + || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[potionId] = currentExpiry + (duration * legacyQuantity); + delete inventory[potionId]; + } + } + const bestPotion = getBestActiveLuckPotion(potionExpiries, now); + userData.luckPotionExpiresAt = potionExpiries; + + const guildConfig = await getGuildConfig(client, guildId); + const premiumRoleId = guildConfig.premiumRoleId; + const hasPremiumRole = Boolean( + premiumRoleId && interaction.member?.roles?.cache?.has(premiumRoleId) + ); if (now < lastFish + FISH_COOLDOWN) { const remaining = lastFish + FISH_COOLDOWN - now; @@ -61,42 +193,123 @@ export default { ); } - 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'); + let rand = Math.random(); + + // Potion admin punya prioritas dan efek khusus; kalau tidak ada, pakai potion luck biasa. + const adminActive = Number(potionExpiries[ADMIN_POTION_ID]) > now; + const activePotion = adminActive ? getItemById(ADMIN_POTION_ID) : bestPotion; + const adminChances = activePotion?.effect?.admin ? activePotion.effect.chances : null; + + let luckMessage = ""; + if (activePotion) { + const minutesLeft = Math.ceil( + (Number(potionExpiries[activePotion.id]) - now) / 60000 + ); + if (adminChances) { + luckMessage = `\n👑 **${activePotion.name} Active: Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; + } else { + const luckMultiplier = activePotion.effect.multiplier || 1; + // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" + // tier atas. Hasil tetap dibatasi 0.9998 agar potion tidak bisa + // menembus celestial (Kitsune). + rand = 1 - Math.pow(1 - rand, luckMultiplier); + if (rand > 0.9998) rand = 0.9998; + luckMessage = `\n🧪 **${activePotion.name} Active: x${luckMultiplier} luck (${minutesLeft}m left)**`; + } } - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; + let selectedRarity; + + // Potion admin: peluang tetap divine/transcendent/celestial; sisanya (40%) normal. + if (adminChances) { + const roll = Math.random(); + let acc = 0; + if (roll < (acc += adminChances.divine)) { + selectedRarity = 'divine'; + } else if (roll < (acc += adminChances.transcendent)) { + selectedRarity = 'transcendent'; + } else if (roll < (acc += adminChances.celestial)) { + selectedRarity = 'celestial'; + } + } + + if (!selectedRarity) { + if (rand < 0.45) { + selectedRarity = 'common'; + } else if (rand < 0.70) { + selectedRarity = 'uncommon'; + } else if (rand < 0.85) { + selectedRarity = 'rare'; + } else if (rand < 0.94) { + selectedRarity = 'epic'; + } else if (rand < 0.98) { + selectedRarity = 'legendary'; + } else if (rand < 0.995) { + selectedRarity = 'mythic'; + } else if (rand < 0.9995) { + selectedRarity = 'secret'; + } else if (rand < 0.9997) { + selectedRarity = 'divine'; + } else if (rand < 0.9999) { + selectedRarity = 'transcendent'; + } else { + selectedRarity = 'celestial'; + } + } + + const availableFish = FISH_TYPES.filter( + f => f.rarity === selectedRarity + ); + + const fishCaught = availableFish[ + Math.floor(Math.random() * availableFish.length) + ]; + const fishPrices = { + common: [200, 450], + uncommon: [450, 750], + rare: [1000, 1250], + epic: [1750, 3000], + legendary: [3750, 7500], + mythic: [15000, 25000], + secret: [30000, 50000], + divine: [62500, 175000], + transcendent: [250000, 500000], + celestial: [10000000, 20000000] + }; - let finalEarned = baseEarned; - let multiplierMessage = ""; + const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n🎣 **Fishing Rod Bonus: +50%**`; + const baseEarned = Math.floor( + Math.random() * (maxPrice - minPrice + 1) + ) + minPrice; + + let fishingMultiplier = 1; + const bonusMessages = []; + if (bestRod) { + const rodMultiplier = bestRod.effect.multiplier || 1; + fishingMultiplier *= rodMultiplier; + const boostPct = Math.round((rodMultiplier - 1) * 100); + bonusMessages.push(`🎣 **${bestRod.name} Bonus: +${boostPct}%**`); + } + if (hasPremiumRole) { + fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; + const boostPct = Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100); + bonusMessages.push(`✨ **Premium Role Bonus: +${boostPct}%**`); } + 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)]; userData.wallet += finalEarned; userData.lastFish = now; + userData.fishCollection = userData.fishCollection || {}; + userData.fishCollection[fishCaught.name] = + (Number(userData.fishCollection[fishCaught.name]) || 0) + 1; + userData.inventory = inventory; + userData.luckPotionExpiresAt = potionExpiries; await setEconomyData(client, guildId, userId, userData); @@ -105,12 +318,17 @@ export default { uncommon: '#2ECC71', rare: '#3498DB', epic: '#9B59B6', - legendary: '#F1C40F' + legendary: '#F1C40F', + mythic: '#E91E63', + secret: '#FF0000', + divine: '#00FFFF', + transcendent: '#FF00FF', + celestial: '#FF69B4' }; 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( @@ -124,9 +342,8 @@ export default { value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), inline: true, } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); + ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/fishdex.js b/src/commands/Economy/fishdex.js new file mode 100644 index 0000000000..03030a328f --- /dev/null +++ b/src/commands/Economy/fishdex.js @@ -0,0 +1,162 @@ +import { + ActionRowBuilder, + ComponentType, + EmbedBuilder, + MessageFlags, + SlashCommandBuilder, + StringSelectMenuBuilder, +} from 'discord.js'; +import { FISH_TYPES } from './fish.js'; +import { getEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; + +const RARITIES = [...new Set(FISH_TYPES.map(fish => fish.rarity))]; +const RARITY_COLORS = { + common: 0x95A5A6, + uncommon: 0x2ECC71, + rare: 0x3498DB, + epic: 0x9B59B6, + legendary: 0xF1C40F, + mythic: 0xE91E63, + secret: 0xFF0000, + divine: 0x00FFFF, + transcendent: 0xFF00FF, + celestial: 0xFF69B4, +}; + +const IMAGE_HINTS = { + common: 'common freshwater fish, natural underwater photography', + uncommon: 'colorful tropical fish, natural underwater photography', + rare: 'rare deep sea fish, detailed underwater art', + epic: 'magical rare fish with vivid colors, fantasy art', + legendary: 'legendary sea creature with a glowing aura, epic fantasy art', + mythic: 'mythical giant sea creature, magical glow, epic fantasy art', + secret: 'mysterious shadowy abyssal sea creature, dark fantasy art', + divine: 'divine celestial sea creature, holy golden light, epic fantasy art', + transcendent: 'cosmic sea creature among galaxies and starlight, epic fantasy art', + celestial: 'cute pink nine-tailed fox fish, glowing magical aura, fantasy art', +}; + +function createImageUrl(fish) { + const prompt = `${fish.name}, ${IMAGE_HINTS[fish.rarity]}, single subject, centered, highly detailed`; + let seed = 0; + for (let i = 0; i < fish.name.length; i++) { + seed = (seed * 31 + fish.name.charCodeAt(i)) >>> 0; + } + return `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=256&height=256&nologo=true&seed=${seed}`; +} + +function makeFishEmbeds(rarity, targetUser, fishCollection) { + const pageFish = FISH_TYPES.filter(fish => fish.rarity === rarity); + const caughtSpecies = FISH_TYPES.filter( + fish => Number(fishCollection[fish.name]) > 0 + ).length; + + return pageFish.map((fish, index) => { + const count = Number(fishCollection[fish.name]) || 0; + const embed = new EmbedBuilder() + .setColor(RARITY_COLORS[fish.rarity] ?? 0x5865F2) + .setTitle(`${fish.emoji} ${fish.name}`); + + if (count > 0) { + embed + .setDescription(`Tertangkap **${count}x**`) + .setThumbnail(createImageUrl(fish)); + } else { + embed.setDescription('Belum tertangkap'); + } + + if (index === pageFish.length - 1) { + embed.setFooter({ + text: `${targetUser.username}: ${caughtSpecies}/${FISH_TYPES.length} spesies | ${pageFish.length} ikan ${rarity}`, + iconURL: targetUser.displayAvatarURL(), + }); + } + return embed; + }); +} + +function makeComponents(selectedRarity, customIdPrefix, disabled = false) { + return [ + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`${customIdPrefix}_rarity`) + .setPlaceholder('Pilih rarity ikan') + .setDisabled(disabled) + .addOptions(RARITIES.map(rarity => ({ + label: rarity.charAt(0).toUpperCase() + rarity.slice(1), + value: rarity, + default: rarity === selectedRarity, + }))), + ), + ]; +} + +export default { + data: new SlashCommandBuilder() + .setName('fishdex') + .setDescription('Lihat index ikan tangkapanmu atau pengguna lain') + .addUserOption(option => + option + .setName('user') + .setDescription('Pengguna yang index ikannya ingin dilihat') + .setRequired(false) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const targetUser = interaction.options.getUser('user') || interaction.user; + if (targetUser.bot) { + throw createError( + 'Cannot view bot fishdex', + ErrorTypes.VALIDATION, + 'Bot tidak memiliki index tangkapan ikan.' + ); + } + + const userData = await getEconomyData(client, interaction.guildId, targetUser.id); + const fishCollection = userData.fishCollection || {}; + const customIdPrefix = `fishdex_${interaction.id}`; + let selectedRarity = RARITIES[0]; + + await interaction.reply({ + embeds: makeFishEmbeds(selectedRarity, targetUser, fishCollection), + components: makeComponents(selectedRarity, customIdPrefix), + }); + const message = await interaction.fetchReply(); + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 300000, + }); + + collector.on('collect', async selectInteraction => { + if (selectInteraction.user.id !== interaction.user.id) { + await selectInteraction.reply({ + content: 'Hanya orang yang membuka fishdex ini yang bisa memilih rarity.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await selectInteraction.deferUpdate(); + selectedRarity = selectInteraction.values[0]; + + await message.edit({ + embeds: makeFishEmbeds(selectedRarity, targetUser, fishCollection), + components: makeComponents(selectedRarity, customIdPrefix), + }); + }); + + collector.on('end', async () => { + try { + await message.edit({ + components: makeComponents(selectedRarity, customIdPrefix, true), + }); + } catch (error) { + logger.debug('fishdex: could not disable pagination buttons', { + error: error.message, + }); + } + }); + }, { command: 'fishdex' }), +}; diff --git a/src/commands/Economy/give.js b/src/commands/Economy/give.js new file mode 100644 index 0000000000..179d80379d --- /dev/null +++ b/src/commands/Economy/give.js @@ -0,0 +1,153 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, successEmbed } 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 { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('give') + .setDescription('Give an item from your inventory to another user') + .addUserOption(option => + option + .setName('user') + .setDescription('User to give the item to') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('item_id') + .setDescription('ID of the item to give (e.g. admin_potion)') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('quantity') + .setDescription('Quantity to give (default: 1)') + .setRequired(false) + .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 itemId = interaction.options.getString("item_id").toLowerCase(); + const quantity = interaction.options.getInteger("quantity") || 1; + const guildId = interaction.guildId; + + if (receiver.bot) { + throw createError( + "Cannot give to bot", + ErrorTypes.VALIDATION, + "You cannot give an item to a bot.", + { receiverId: receiver.id } + ); + } + + if (receiver.id === senderId) { + throw createError( + "Cannot give to self", + ErrorTypes.VALIDATION, + "You cannot give an item to yourself.", + { senderId } + ); + } + + const item = getItemById(itemId); + if (!item) { + throw createError( + `Item ${itemId} not found`, + ErrorTypes.VALIDATION, + `The item ID \`${itemId}\` does not exist.`, + { itemId } + ); + } + + const [senderData, receiverData] = await Promise.all([ + getEconomyData(client, guildId, senderId), + getEconomyData(client, guildId, receiver.id) + ]); + + const senderHeld = Number(senderData.inventory?.[itemId]) || 0; + if (senderHeld < quantity) { + throw createError( + "Not enough items", + ErrorTypes.VALIDATION, + `You only have **${senderHeld}x ${item.name}** in your inventory.`, + { itemId, senderHeld, quantity } + ); + } + + // Batasi kepemilikan penerima seperti aturan pembelian. + const receiverHeld = Number(receiverData.inventory?.[itemId]) || 0; + if (item.type === 'tool' && itemId !== 'bank_note' && receiverHeld > 0) { + throw createError( + "Receiver already owns item", + ErrorTypes.VALIDATION, + `**${receiver.username}** already owns a **${item.name}**.`, + { itemId, receiverId: receiver.id } + ); + } + if (item.type === 'consumable' && item.maxQuantity && receiverHeld + quantity > item.maxQuantity) { + throw createError( + "Receiver inventory limit", + ErrorTypes.VALIDATION, + `**${receiver.username}** can only hold a maximum of **${item.maxQuantity}x ${item.name}**.`, + { itemId, receiverId: receiver.id, receiverHeld, quantity, maxQuantity: item.maxQuantity } + ); + } + + if (senderHeld - quantity > 0) { + senderData.inventory[itemId] = senderHeld - quantity; + } else { + delete senderData.inventory[itemId]; + } + + receiverData.inventory[itemId] = receiverHeld + quantity; + + await Promise.all([ + setEconomyData(client, guildId, senderId, senderData), + setEconomyData(client, guildId, receiver.id, receiverData) + ]); + + const embed = successEmbed( + "🎁 Item Gifted", + `You gave **${quantity}x ${item.name}** to **${receiver.username}**!` + ).addFields( + { + name: "Item", + value: `${item.name} x${quantity}`, + inline: true, + }, + { + name: "Remaining", + value: `${Number(senderData.inventory[itemId]) || 0}x`, + inline: true, + }, + ); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + + logger.info(`[ECONOMY] Item gifted`, { + senderId, + receiverId: receiver.id, + itemId, + quantity, + }); + + try { + const receiverEmbed = createEmbed({ + title: "🎁 You received an item!", + description: `${interaction.user.username} gave you **${quantity}x ${item.name}**.` + }); + await receiver.send({ embeds: [receiverEmbed] }); + } catch (e) { + logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); + } + }, { command: 'give' }) +}; diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index b7efcfec80..f5bd537a29 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -8,6 +8,13 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +function getShopItem(itemId) { + return SHOP_ITEMS.find(item => item.id === itemId) + || (itemId === 'fishibg_rod_5' + ? SHOP_ITEMS.find(item => item.id === 'fishing_rod_5') + : null); +} + export default { data: new SlashCommandBuilder() .setName('inventory') @@ -34,25 +41,26 @@ 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 = getShopItem(itemId); + return Number(quantity) > 0 && item; + }) + .map(([itemId, quantity]) => { + const item = getShopItem(itemId); + return `**${item.name}:** ${quantity}x`; + }); + const potionLines = Object.entries(userData.luckPotionExpiresAt || {}) + .filter(([, expiresAt]) => Number(expiresAt) > Date.now()) + .map(([itemId, expiresAt]) => { + const item = getShopItem(itemId); + if (!item) return null; + const minutesLeft = Math.ceil((Number(expiresAt) - Date.now()) / 60000); + return `🧪 **${item.name}:** Active (${minutesLeft}m left)`; + }) + .filter(Boolean); + const inventoryDescription = [...inventoryLines, ...potionLines].join("\n") + || "Your inventory is currently empty."; logger.info(`[ECONOMY] Inventory retrieved`, { userId, @@ -67,4 +75,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/use.js b/src/commands/Economy/use.js new file mode 100644 index 0000000000..a94beac6f9 --- /dev/null +++ b/src/commands/Economy/use.js @@ -0,0 +1,88 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { successEmbed } 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'; + +const ADMIN_POTION_ID = 'admin_potion'; +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; + +export default { + data: new SlashCommandBuilder() + .setName('use') + .setDescription('Use a consumable item from your inventory (e.g. Admin Potion)') + .addStringOption(option => + option + .setName('item_id') + .setDescription('ID of the item to use (e.g. admin_potion)') + .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 itemId = interaction.options.getString("item_id").toLowerCase(); + + const item = getItemById(itemId); + if (!item) { + throw createError( + `Item ${itemId} not found`, + ErrorTypes.VALIDATION, + `The item ID \`${itemId}\` does not exist.`, + { itemId } + ); + } + + const userData = await getEconomyData(client, guildId, userId); + const held = Number(userData.inventory?.[itemId]) || 0; + + if (held <= 0) { + throw createError( + "Item not in inventory", + ErrorTypes.VALIDATION, + `You don't have **${item.name}** in your inventory.`, + { itemId } + ); + } + + if (item.effect?.type !== 'fishing_luck') { + throw createError( + "Item not usable", + ErrorTypes.VALIDATION, + `**${item.name}** cannot be used right now.`, + { itemId } + ); + } + + const now = Date.now(); + const potionExpiries = userData.luckPotionExpiresAt || {}; + + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); + potionExpiries[itemId] = currentExpiry + duration; + userData.luckPotionExpiresAt = potionExpiries; + + const remaining = held - 1; + if (remaining > 0) { + userData.inventory[itemId] = remaining; + } else { + delete userData.inventory[itemId]; + } + + await setEconomyData(client, guildId, userId, userData); + + const activeSeconds = Math.ceil((potionExpiries[itemId] - now) / 1000); + let description = `You used **${item.name}**! It is now active for **${activeSeconds}s**.`; + if (itemId === ADMIN_POTION_ID) { + description += `\n\n👑 **Luck: Divine 30% / Transcendent 20% / Kitsune 10%**`; + } + + const embed = successEmbed("✨ Item Used", description); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); + }, { command: 'use' }) +}; diff --git a/src/commands/Music/join.js b/src/commands/Music/join.js deleted file mode 100644 index 10e6174103..0000000000 --- a/src/commands/Music/join.js +++ /dev/null @@ -1,21 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Join your voice channel without starting playback'), - - async execute(interaction, config, client) { - const deferred = await deferMusicCommand(interaction); - if (!deferred) { - return; - } - - const embed = await joinVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - }, -}; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js deleted file mode 100644 index fa8d7cfef6..0000000000 --- a/src/commands/Music/music.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - skipTrack, - stopPlayback, - pausePlayback, - resumePlayback, - shuffleQueue, - setLoopMode, - setVolume, - seekTrack, - removeFromQueue, - moveInQueue, - clearQueue, - setTwentyFourSeven, - leaveVoiceChannel, - replyMusicSuccess, -} from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('music') - .setDescription('Manage playback, queue, and voice session settings') - .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), - ) - .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), - ) - .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), - ) - .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), - ) - .addSubcommand((sub) => - sub - .setName('loop') - .setDescription('Set loop mode') - .addStringOption((opt) => - opt - .setName('mode') - .setDescription('Loop mode') - .setRequired(true) - .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, - ), - ), - ) - .addSubcommand((sub) => - sub - .setName('volume') - .setDescription('Set playback volume') - .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), - ), - ) - .addSubcommand((sub) => - sub - .setName('seek') - .setDescription('Seek to a position in the current track') - .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), - ), - ) - .addSubcommand((sub) => - sub - .setName('remove') - .setDescription('Remove a track from the queue') - .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub - .setName('move') - .setDescription('Move a track in the queue') - .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), - ) - .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), - ) - .addSubcommand((sub) => - sub - .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') - .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), - ), - ), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'pause': { - const embed = await pausePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'resume': { - const embed = await resumePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'skip': { - const embed = await skipTrack(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'stop': { - const embed = await stopPlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'shuffle': { - const embed = await shuffleQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'seek': { - const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'remove': { - const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'move': { - const embed = await moveInQueue( - client, - interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), - ); - await replyMusicSuccess(interaction, embed); - break; - } - case 'clear': { - const embed = await clearQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'leave': { - const embed = await leaveVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); - await replyMusicSuccess(interaction, embed); - break; - } - default: - await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', - }); - } - }, -}; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.js deleted file mode 100644 index e50c685122..0000000000 --- a/src/commands/Music/nowplaying.js +++ /dev/null @@ -1,17 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildNowPlayingReply } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('nowplaying') - .setDescription('Show the currently playing track'), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); - await InteractionHelper.safeEditReply(interaction, payload); - }, -}; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js deleted file mode 100644 index 1d07665382..0000000000 --- a/src/commands/Music/play.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song or add it to the queue') - .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), - ), - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const result = await playQuery(client, interaction, interaction.options.getString('query')); - await replyMusicSuccess(interaction, result.embed); - }, -}; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js deleted file mode 100644 index 66faa4eb9d..0000000000 --- a/src/commands/Music/queue.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildQueueReply } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('queue') - .setDescription('Show the current music queue') - .addIntegerOption((opt) => - opt.setName('page').setDescription('Page number').setMinValue(1), - ), - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); - await InteractionHelper.safeEditReply(interaction, { - embeds: payload.embeds, - components: payload.components, - }); - }, -}; diff --git a/src/config/application.js b/src/config/application.js index 159b9eb295..43956ffea1 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -74,7 +74,6 @@ const appConfig = { features: { ...botConfig.features, - music: botConfig.features?.music ?? true, }, env: process.env.NODE_ENV || "development", diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..9e066a8f42 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "hari hari mau eek", // this is what people actually see type: 4, // Custom }, ], @@ -97,7 +97,7 @@ export const botConfig = { secondary: "#2F3136", // Standard status colors for success/error/warning/info messages. - success: "#57F287", + success: "#ff88d7", error: "#ED4245", warning: "#FEE75C", info: "#3498DB", @@ -141,7 +141,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "MannNayla", // Footer icon URL (null = no icon). icon: null, }, @@ -473,7 +473,6 @@ export const botConfig = { utility: true, community: true, fun: true, - music: true, }, }; @@ -539,7 +538,6 @@ const COMMAND_CATEGORY_FEATURE_MAP = { leveling: "leveling", logging: "logging", moderation: "moderation", - music: "music", reaction_roles: "reactionRoles", search: "search", serverstats: "counter", @@ -648,4 +646,4 @@ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } -export default botConfig; \ No newline at end of file +export default botConfig; diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.js index b23023a143..cda08046fa 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.js @@ -17,7 +17,6 @@ export const commandAliases = { 'rob': 'rob', 'crime': 'crime', 'pay': 'pay', - 'give': 'pay', 'send': 'pay', 'ping': 'ping', @@ -101,9 +100,6 @@ export const commandAliases = { 'jtc': 'jointocreate', 'jointocreate': 'jointocreate', - - 'np': 'nowplaying', - 'now': 'nowplaying', }; export const subcommandAliases = { diff --git a/src/config/commands/commandCategories.js b/src/config/commands/commandCategories.js index 3d3ec1b5ee..2783f0ed48 100644 --- a/src/config/commands/commandCategories.js +++ b/src/config/commands/commandCategories.js @@ -13,7 +13,6 @@ export const CATEGORY_ICONS = { Leveling: '📊', Logging: '📝', Moderation: '🛡️', - Music: '🎵', Reaction_roles: '🎭', Search: '🔍', ServerStats: '📈', diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js index 2b57f7bd60..498bbeff8f 100644 --- a/src/config/commands/prefixRestrictions.js +++ b/src/config/commands/prefixRestrictions.js @@ -24,15 +24,6 @@ export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set([ /** 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']), }; diff --git a/src/config/music/lavalink.js b/src/config/music/lavalink.js deleted file mode 100644 index e464557a9e..0000000000 --- a/src/config/music/lavalink.js +++ /dev/null @@ -1,85 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); - -function parseBoolean(value, defaultValue = false) { - if (value === undefined || value === null || value === '') { - return defaultValue; - } - return ['true', '1', 'yes'].includes(String(value).toLowerCase()); -} - -function parseNodesFromEnv() { - const raw = process.env.LAVALINK_NODES?.trim(); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } -} - -function parseNodesPayload(parsed) { - if (Array.isArray(parsed)) { - return parsed; - } - if (Array.isArray(parsed?.nodes)) { - return parsed.nodes; - } - return null; -} - -function loadNodesFromFile() { - const nodesFile = process.env.LAVALINK_NODES_FILE?.trim() - || path.join(projectRoot, 'lavalink', 'nodes.json'); - - if (!existsSync(nodesFile)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(nodesFile, 'utf8')); - return parseNodesPayload(parsed); - } catch { - return null; - } -} - -export function getLavalinkNodes() { - const fromJson = parseNodesFromEnv(); - if (fromJson?.length) { - return fromJson; - } - - const fromFile = loadNodesFromFile(); - if (fromFile?.length) { - return fromFile; - } - - const host = process.env.LAVALINK_HOST || 'localhost'; - const port = Number(process.env.LAVALINK_PORT || 2333); - const password = process.env.LAVALINK_PASSWORD || 'youshallnotpass'; - const secure = parseBoolean(process.env.LAVALINK_SECURE, false); - - return [{ - host, - port, - password, - secure, - name: process.env.LAVALINK_NAME || 'Main', - }]; -} - -export const lavalinkConfig = { - nodes: getLavalinkNodes(), - defaultSearchPlatform: process.env.LAVALINK_SEARCH_PLATFORM || 'ytmsearch', - restVersion: process.env.LAVALINK_REST_VERSION || 'v4', -}; - -export default lavalinkConfig; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 5f47eaeaf5..5ecece77b2 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -2,7 +2,7 @@ export const shopItems = [ { id: 'extra_work', name: 'Extra Work Shift', - price: 5000, + price: 300000, description: 'Allows 1 extra use of the `/work` command.', type: 'consumable', maxQuantity: 5, @@ -16,7 +16,7 @@ cooldown: 86400000, { id: 'bank_upgrade_1', name: 'Bank Upgrade I', - price: 15000, + price: 150000, description: 'Increases bank capacity and allows more funds to be deposited.', type: 'upgrade', maxLevel: 5, @@ -40,19 +40,19 @@ cooldown: 86400000, { id: 'premium_role', name: 'Premium Server Role', - price: 15000, - description: 'A special role granting a fancy color and a 10% daily bonus.', + price: 1500000, + description: 'A special role granting a fancy color and a 10% fishing earnings bonus.', type: 'role', roleId: null, effect: { - type: 'daily_bonus', - multiplier: 1.1 + type: 'fishing_yield', + multiplier: 4 } }, { id: 'lucky_clover', name: 'Lucky Clover', - price: 10000, + price: 100000, description: 'Increases the chance of winning a higher payout on `/gamble` once.', type: 'consumable', maxQuantity: 10, @@ -62,22 +62,162 @@ roleId: null, uses: 1 } }, + // ===== Fishing Rods (Tiers I - V) ===== { - id: 'fishing_rod', - name: '🎣 Fishing Rod', + id: 'fishing_rod_1', + name: '🎣 Fishing Rod I', price: 5000, - description: 'Used for fishing commands', + description: 'A basic fishing rod. Boosts fishing earnings by 10%.', type: 'tool', durability: 100, effect: { type: 'fishing_yield', - multiplier: 1.0 + multiplier: 1.1, + tier: 1 + } + }, + { + id: 'fishing_rod_2', + name: '🎣 Fishing Rod II', + price: 25000, + description: 'A sturdy fishing rod. Boosts fishing earnings by 25%.', + type: 'tool', + durability: 150, + effect: { + type: 'fishing_yield', + multiplier: 1.25, + tier: 2 + } + }, + { + id: 'fishing_rod_3', + name: '🎣 Fishing Rod III', + price: 75000, + description: 'A reinforced fishing rod. Boosts fishing earnings by 50%.', + type: 'tool', + durability: 200, + effect: { + type: 'fishing_yield', + multiplier: 1.5, + tier: 3 + } + }, + { + id: 'fishing_rod_4', + name: '🎣 Fishing Rod IV', + price: 200000, + description: 'A premium fishing rod. Boosts fishing earnings by 75%.', + type: 'tool', + durability: 250, + effect: { + type: 'fishing_yield', + multiplier: 1.75, + tier: 4 + } + }, + { + id: 'fishing_rod_5', + name: '🎣 Fishing Rod V', + price: 500000, + description: 'A legendary fishing rod. Boosts fishing earnings by 100%.', + type: 'tool', + durability: 300, + effect: { + type: 'fishing_yield', + multiplier: 2.0, + tier: 5 + } + }, + + // ===== Luck Potions (Tiers I - V) ===== + { + id: 'luck_potion_1', + name: '🧪 Luck Potion I', + price: 1000, + description: 'Boosts your fishing luck slightly for 5 minutes. Higher chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.15, + duration: 300000 + } + }, + { + id: 'luck_potion_2', + name: '🧪 Luck Potion II', + price: 7000, + description: 'Boosts your fishing luck for 5 minutes. Noticeably higher chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.3, + duration: 300000 + } + }, + { + id: 'luck_potion_3', + name: '🧪 Luck Potion III', + price: 50000, + description: 'Boosts your fishing luck for 5 minutes. Good chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.5, + duration: 300000 + } + }, + { + id: 'luck_potion_4', + name: '🧪 Luck Potion IV', + price: 100000, + description: 'Boosts your fishing luck for 5 minutes. Great chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.8, + duration: 300000 + } + }, + { + id: 'luck_potion_5', + name: '🧪 Luck Potion V', + price: 150000, + description: 'Maximizes your fishing luck for 5 minutes. Best chance of legendary+ fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 2.2, + duration: 300000 + } + }, + + // ===== Admin Potion (disimpan di inventory, dipakai dengan /use, bisa dikirim dengan /give) ===== + { + id: 'admin_potion', + name: '👑 Admin Potion', + price: 100, + description: 'Admin potion. Activate with `/use` for 5 minutes of great luck: Divine 30%,Transcendent 20%,Kitsune 10%. Can be gifted with `/give`.', + type: 'consumable', + effect: { + type: 'fishing_luck', + admin: true, + chances: { + divine: 0.5, + transcendent: 0.4, + celestial: 0.3 + }, + duration: 300000 } }, { id: 'pickaxe', name: '⛏️ Pickaxe', - price: 7500, + price: 75000, description: 'Used for mining commands', type: 'tool', durability: 100, @@ -89,7 +229,7 @@ roleId: null, { id: 'laptop', name: '💻 Laptop', - price: 15000, + price: 500000, description: 'Increases work earnings', type: 'tool', durability: 200, @@ -101,10 +241,10 @@ roleId: null, { id: 'lucky_charm', name: '🍀 Lucky Charm', - price: 10000, + price: 100000, description: 'Increases luck for gambling. Has 3 uses before being consumed.', type: 'consumable', - maxQuantity: 10, + maxQuantity: 100, effect: { type: 'gamble_boost', multiplier: 1.3, @@ -114,7 +254,7 @@ roleId: null, { id: 'bank_note', name: '📜 Bank Note', - price: 25000, + price: 250000, description: 'Increases bank capacity by 10,000. Can be purchased multiple times.', type: 'tool', durability: null, @@ -126,7 +266,7 @@ roleId: null, { id: 'personal_safe', name: '🔒 Personal Safe', - price: 30000, + price: 300000, description: 'Protects your money from theft. Prevents others from robbing you.', type: 'tool', durability: null, @@ -200,4 +340,4 @@ export function validatePurchase(itemId, userData) { } return { valid: true }; -} \ No newline at end of file +} diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fb1a165f31..e0b5850a9d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -55,12 +55,6 @@ async function handlePrefixCommand(message, client) { } let { commandName, args } = parsed; - const musicPrefixShortcut = commandName.toLowerCase(); - const MUSIC_PREFIX_SHORTCUTS = new Set(['leave', 'pause', 'resume', 'skip', 'stop', 'volume']); - if (MUSIC_PREFIX_SHORTCUTS.has(musicPrefixShortcut)) { - commandName = 'music'; - args = [musicPrefixShortcut, ...args]; - } logger.info(`Prefix command detected: ${commandName}, args: ${args.join(', ')}`); diff --git a/src/events/ready.js b/src/events/ready.js index 2be8e5e424..4e94c77969 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -4,7 +4,6 @@ import config from "../config/application.js"; import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; import { reconcileTicketPanels, reconcileVerificationPanels, reconcileReactionRolePanelHealth } from "../services/panelHealthService.js"; import { reconcileLevelRoles } from "../services/leveling/levelRoleSyncService.js"; -import { initRiffyAfterReady } from "../services/music/riffySetup.js"; export default { name: Events.ClientReady, @@ -18,10 +17,6 @@ export default { startupLog(`Serving ${client.guilds.cache.size} guild(s)`); startupLog(`Loaded ${client.commands.size} commands`); - if (client.config?.features?.music) { - initRiffyAfterReady(client); - } - const reconciliationSummary = await reconcileReactionRoleMessages(client); startupLog( `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index c76c1e123f..a2eabe7573 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -8,7 +8,6 @@ import { } from '../utils/database.js'; import { sanitizeInput } from '../utils/validation.js'; import { logger } from '../utils/logger.js'; -import { handleMusicVoiceState } from '../services/music/musicVoiceState.js'; const channelCreationCooldown = new Map(); const VOICE_CREATE_COOLDOWN_MS = 2000; @@ -284,12 +283,6 @@ userLimit: userLimit === 0 ? undefined : userLimit, logger.error(`Failed to transfer ownership of channel ${channel.id}:`, error); } } - - if (client.config?.features?.music) { - handleMusicVoiceState(client, oldState, newState).catch((error) => { - logger.error('Music voice state handler error:', error); - }); - } } }; diff --git a/src/handlers/help/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.js index 960826e7de..5f342e523f 100644 --- a/src/handlers/help/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.js @@ -22,7 +22,6 @@ const CATEGORY_ICONS = { Core: "ℹ️", Moderation: "🛡️", Economy: "💰", - Music: "🎵", Fun: "🎮", Leveling: "📊", Utility: "🔧", diff --git a/src/handlers/musicButtons.js b/src/handlers/musicButtons.js deleted file mode 100644 index 13b8acd9c6..0000000000 --- a/src/handlers/musicButtons.js +++ /dev/null @@ -1,161 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { handleInteractionError } from '../utils/errorHandler.js'; -import { getGuildMusicData } from '../services/music/playerStore.js'; -import { - getPlayer, - buildQueueReply, - destroyPlayerSession, - setLoopMode, - applyPause, - applyResume, -} from '../services/music/musicActions.js'; -import { canControlMusic, VOICE_CHANNEL_DENIAL } from '../services/music/permissions.js'; -import { refreshPlayerMessage } from '../services/music/playerHandler.js'; -import { MUSIC_BUTTON_IDS } from '../services/music/musicEmbeds.js'; -import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; - -async function handleMusicButton(interaction, client) { - const player = getPlayer(client, interaction.guild.id); - const guildData = getGuildMusicData(interaction.guild.id); - const customId = interaction.customId; - - if (customId === MUSIC_BUTTON_IDS.QUEUE) { - if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); - } - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - guildData.queuePages.set(interaction.user.id, 0); - const payload = buildQueueReply(client, interaction.guild.id, 0); - return interaction.reply({ - embeds: payload.embeds, - components: payload.components, - flags: MessageFlags.Ephemeral, - }); - } - - const queuePaginationIds = [ - MUSIC_BUTTON_IDS.QUEUE_FIRST, - MUSIC_BUTTON_IDS.QUEUE_PREV, - MUSIC_BUTTON_IDS.QUEUE_NEXT, - MUSIC_BUTTON_IDS.QUEUE_LAST, - ]; - - if (queuePaginationIds.includes(customId)) { - if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); - } - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - - await interaction.deferUpdate(); - const payload = buildQueueReply(client, interaction.guild.id, guildData.queuePages.get(interaction.user.id) || 0); - const totalPages = payload.totalPages; - let page = payload.page; - - switch (customId) { - case MUSIC_BUTTON_IDS.QUEUE_FIRST: - page = 0; - break; - case MUSIC_BUTTON_IDS.QUEUE_PREV: - page = Math.max(0, page - 1); - break; - case MUSIC_BUTTON_IDS.QUEUE_NEXT: - page = Math.min(totalPages - 1, page + 1); - break; - case MUSIC_BUTTON_IDS.QUEUE_LAST: - page = totalPages - 1; - break; - default: - break; - } - - guildData.queuePages.set(interaction.user.id, page); - const updated = buildQueueReply(client, interaction.guild.id, page); - return interaction.editReply({ - embeds: updated.embeds, - components: updated.components, - }); - } - - if (!player) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/play` first.' }); - } - - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - - await interaction.deferUpdate(); - - try { - switch (customId) { - case MUSIC_BUTTON_IDS.PAUSE: - await applyPause(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.RESUME: - await applyResume(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.SKIP: - // Under track-loop, stop() would replay the same track. Clear it so the - // skip advances; trackStart re-applies the stored loop to the next track. - if (player.loop === 'track') { - player.setLoop('none'); - } - player.stop(); - break; - case MUSIC_BUTTON_IDS.STOP: - await destroyPlayerSession(client, interaction.guild.id, player, guildData); - break; - case MUSIC_BUTTON_IDS.SHUFFLE: - if (player.queue.length > 0) { - player.queue.shuffle(); - guildData.shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); - } - break; - case MUSIC_BUTTON_IDS.LOOP: { - const guildDataLoop = getGuildMusicData(interaction.guild.id); - const next = guildDataLoop.loop === 'none' ? 'track' : guildDataLoop.loop === 'track' ? 'queue' : 'none'; - await setLoopMode(client, interaction, next); - break; - } - case MUSIC_BUTTON_IDS.VOL_DOWN: - guildData.volume = Math.max(0, guildData.volume - 10); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.VOL_UP: - guildData.volume = Math.min(100, guildData.volume + 10); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - break; - default: - break; - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'button', - customId: interaction.customId, - handler: 'music', - }); - } -} - -export const musicButtonHandler = { - async execute(interaction, client) { - try { - if (!client.riffy) { - return replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Music is unavailable — Lavalink is not configured.' }); - } - await handleMusicButton(interaction, client); - } catch (error) { - await handleInteractionError(interaction, error, { handler: 'musicButton' }); - } - }, -}; - -export default musicButtonHandler; diff --git a/src/interactions/buttons/music/music.js b/src/interactions/buttons/music/music.js deleted file mode 100644 index 3a69f9f3fb..0000000000 --- a/src/interactions/buttons/music/music.js +++ /dev/null @@ -1,23 +0,0 @@ -import musicButtonHandler from '../../../handlers/musicButtons.js'; -import { MUSIC_BUTTON_IDS } from '../../../services/music/musicEmbeds.js'; - -const buttonIds = [ - MUSIC_BUTTON_IDS.PAUSE, - MUSIC_BUTTON_IDS.RESUME, - MUSIC_BUTTON_IDS.SKIP, - MUSIC_BUTTON_IDS.STOP, - MUSIC_BUTTON_IDS.SHUFFLE, - MUSIC_BUTTON_IDS.LOOP, - MUSIC_BUTTON_IDS.VOL_DOWN, - MUSIC_BUTTON_IDS.VOL_UP, - MUSIC_BUTTON_IDS.QUEUE, - MUSIC_BUTTON_IDS.QUEUE_FIRST, - MUSIC_BUTTON_IDS.QUEUE_PREV, - MUSIC_BUTTON_IDS.QUEUE_NEXT, - MUSIC_BUTTON_IDS.QUEUE_LAST, -]; - -export default buttonIds.map((name) => ({ - name, - execute: musicButtonHandler.execute, -})); diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js deleted file mode 100644 index be46fb85d8..0000000000 --- a/src/services/music/musicActions.js +++ /dev/null @@ -1,612 +0,0 @@ -import { once } from 'node:events'; -import { MessageFlags, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; -import { canControlMusic, requireVoiceChannel, VOICE_CHANNEL_DENIAL } from './permissions.js'; -import { - buildNowPlayingEmbed, - buildQueueEmbed, - buildQueuePaginationRow, - getQueuePageSize, -} from './musicEmbeds.js'; -import { refreshPlayerMessage } from './playerHandler.js'; - -const YOUTUBE_URL_PATTERN = /(?:youtube\.com|youtu\.be)/i; -const PLAYER_CONNECT_TIMEOUT_MS = 12_000; - -function getConnectedLavalinkNodes(client) { - if (!client.riffy?.nodeMap) { - return []; - } - - return [...client.riffy.nodeMap.values()].filter((node) => node.connected); -} - -export function assertLavalinkNodeAvailable(client) { - if (!getConnectedLavalinkNodes(client).length) { - throw new TitanBotError( - 'Lavalink unavailable', - ErrorTypes.CONFIGURATION, - 'Music is temporarily unavailable — no Lavalink nodes are connected. Try again shortly or configure your own Lavalink server.', - ); - } -} - -function assertBotVoicePermissions(channel) { - if (!channel) { - throw new TitanBotError( - 'Voice channel unavailable', - ErrorTypes.CONFIGURATION, - 'Could not access that voice channel.', - ); - } - - if (!botHasPermission(channel, [PermissionFlagsBits.Connect, PermissionFlagsBits.Speak])) { - throw new TitanBotError( - 'Missing voice permissions', - ErrorTypes.PERMISSION, - 'I need **Connect** and **Speak** permissions in your voice channel.', - ); - } -} - -async function waitForPlayerConnection(player) { - if (player.connected) { - return; - } - - try { - await player.connection.resolve(); - } catch { - // Fall through to event-based wait below. - } - - if (player.connected) { - return; - } - - try { - await once(player, 'connectionRestored', { - signal: AbortSignal.timeout(PLAYER_CONNECT_TIMEOUT_MS), - }); - } catch { - // Timed out waiting for Lavalink to confirm the voice session. - } - - if (!player.connected) { - throw new TitanBotError( - 'Voice connection failed', - ErrorTypes.CONFIGURATION, - 'Could not connect to the voice channel. Ensure Lavalink is online, the bot has Connect and Speak permissions, then try again.', - ); - } -} - -async function startPlayback(player) { - await waitForPlayerConnection(player); - await player.play(); -} - -export function getPlayer(client, guildId) { - return client.riffy?.players?.get(guildId) || null; -} - -export function assertRiffyAvailable(client) { - if (!client.riffy) { - throw new TitanBotError( - 'Lavalink not configured', - ErrorTypes.CONFIGURATION, - 'Music is unavailable — Lavalink is not configured.', - ); - } -} - -export function assertInVoice(member) { - if (!requireVoiceChannel(member)) { - throw new TitanBotError( - 'Not in voice channel', - ErrorTypes.USER_INPUT, - 'You need to be in a voice channel.', - ); - } -} - -export function assertCanControl(member, player) { - if (!canControlMusic(member, player)) { - throw new TitanBotError( - 'Wrong voice channel', - ErrorTypes.PERMISSION, - VOICE_CHANNEL_DENIAL, - ); - } -} - -export async function ensurePlayer(client, interaction) { - assertRiffyAvailable(client); - assertLavalinkNodeAvailable(client); - assertInVoice(interaction.member); - - const guildId = interaction.guild.id; - const guildData = getGuildMusicData(guildId); - let player = getPlayer(client, guildId); - - if (!player) { - player = client.riffy.createConnection({ - guildId, - voiceChannel: interaction.member.voice.channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - guildData.playerChannelId = interaction.channel.id; - } - - player.setVolume(guildData.volume); - return { player, guildData }; -} - -function isDuplicateTrack(player, track) { - const uri = track?.info?.uri; - if (!uri) { - return false; - } - if (player.current?.info?.uri === uri) { - return true; - } - return player.queue.some((existing) => existing.info?.uri === uri); -} - -export async function joinVoiceChannel(client, interaction) { - assertRiffyAvailable(client); - assertInVoice(interaction.member); - - const guildId = interaction.guild.id; - const guildData = getGuildMusicData(guildId); - const channel = interaction.member.voice.channel; - assertBotVoicePermissions(channel); - let player = getPlayer(client, guildId); - - if (player && player.voiceChannel !== channel.id) { - try { - player.destroy(); - } catch { - // player may already be gone - } - player = null; - } - - if (!player) { - player = client.riffy.createConnection({ - guildId, - voiceChannel: channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - guildData.playerChannelId = interaction.channel.id; - } - - player.setVolume(guildData.volume); - - return successEmbed( - 'Joined Voice Channel', - `Connected to **${channel.name}**. Use /play to start music, or /music for playback controls.`, - ); -} - -export async function playQuery(client, interaction, query) { - if (YOUTUBE_URL_PATTERN.test(query)) { - throw new TitanBotError( - 'YouTube URL blocked', - ErrorTypes.USER_INPUT, - 'YouTube links are not supported. Try a song name instead.', - ); - } - - const { player, guildData } = await ensurePlayer(client, interaction); - - const result = await client.riffy.resolve({ - query, - requester: interaction.user, - }); - - const { loadType, tracks, playlistInfo } = result; - - if (loadType === 'playlist' || loadType === 'PLAYLIST_LOADED') { - let added = 0; - let skipped = 0; - - for (const track of tracks) { - track.info.requester = interaction.user; - if (isDuplicateTrack(player, track)) { - skipped += 1; - continue; - } - player.queue.add(track); - added += 1; - } - - if (!player.playing && !player.paused) { - await startPlayback(player); - } - - return { - embed: successEmbed( - 'Playlist Added', - `**${playlistInfo?.name || 'Playlist'}**\nAdded ${added} of ${tracks.length} track(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ''}`, - ), - }; - } - - if ( - loadType === 'search' - || loadType === 'track' - || loadType === 'SEARCH_RESULT' - || loadType === 'TRACK_LOADED' - ) { - const track = tracks?.[0]; - if (!track) { - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); - } - - if (isDuplicateTrack(player, track)) { - throw new TitanBotError( - 'Duplicate track', - ErrorTypes.USER_INPUT, - `**${track.info.title}** is already in the queue or playing.`, - ); - } - - track.info.requester = interaction.user; - - const willPlayNow = !player.playing && !player.paused; - player.queue.add(track); - const queuePosition = player.queue.length; - - if (willPlayNow) { - await startPlayback(player); - } - - return { - embed: successEmbed( - willPlayNow ? 'Now Playing' : 'Track Added', - willPlayNow - ? `**${track.info.title}**\n${track.info.author}` - : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue`, - ), - }; - } - - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); -} - -export async function skipTrack(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - const title = player.current.info?.title || 'Unknown'; - // Under track-loop, stop() would replay the same track. Clear it so the skip - // advances; trackStart re-applies the stored loop mode to the next track. - if (player.loop === 'track') { - player.setLoop('none'); - } - player.stop(); - return successEmbed('Skipped', `Skipped **${title}**.`); -} - -export async function stopPlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - const queueLength = player.queue?.length || 0; - - if (queueLength >= 5 && guildData.stopConfirmPending !== interaction.user.id) { - guildData.stopConfirmPending = interaction.user.id; - setTimeout(() => { - if (guildData.stopConfirmPending === interaction.user.id) { - guildData.stopConfirmPending = null; - } - }, 15000); - return successEmbed( - 'Confirm Stop', - `There are **${queueLength}** tracks in the queue. Run **/music stop** again within 15 seconds to confirm.`, - ); - } - - guildData.stopConfirmPending = null; - await destroyPlayerSession(client, interaction.guild.id, player, guildData); - return successEmbed('Stopped', 'Playback stopped and the queue was cleared.'); -} - -export async function applyPause(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current || player.paused) { - return false; - } - - player.pause(true); - await refreshPlayerMessage(client, guildId); - return true; -} - -export async function applyResume(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current || !player.paused) { - return false; - } - - player.pause(false); - await refreshPlayerMessage(client, guildId); - return true; -} - -export async function pausePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - if (player.paused) { - throw new TitanBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); - } - - await applyPause(client, interaction.guild.id); - return successEmbed('Paused', 'Playback paused.'); -} - -export async function resumePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - if (!player.paused) { - throw new TitanBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); - } - - await applyResume(client, interaction.guild.id); - return successEmbed('Resumed', 'Playback resumed.'); -} - -export async function shuffleQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - player.queue.shuffle(); - getGuildMusicData(interaction.guild.id).shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Shuffled', 'The queue has been shuffled.'); -} - -export async function setLoopMode(client, interaction, mode) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - guildData.loop = mode; - player.setLoop(mode); - - const labels = { none: 'Off', track: 'Track', queue: 'Queue' }; - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Loop Updated', `Loop mode set to **${labels[mode] || mode}**.`); -} - -export async function toggleLoop(client, interaction) { - const guildData = getGuildMusicData(interaction.guild.id); - const next = guildData.loop === 'none' ? 'track' : guildData.loop === 'track' ? 'queue' : 'none'; - return setLoopMode(client, interaction, next); -} - -export async function setVolume(client, interaction, volume) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - guildData.volume = Math.max(0, Math.min(100, volume)); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Volume Updated', `Volume set to **${guildData.volume}%**.`); -} - -export async function adjustVolume(client, interaction, delta) { - const guildData = getGuildMusicData(interaction.guild.id); - return setVolume(client, interaction, guildData.volume + delta); -} - -export async function seekTrack(client, interaction, seconds) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - const info = player.current.info || {}; - if (info.isStream || info.isSeekable === false) { - throw new TitanBotError( - 'Not seekable', - ErrorTypes.USER_INPUT, - 'This track cannot be seeked (it may be a live stream).', - ); - } - - const position = Math.max(0, seconds * 1000); - if (info.length && position > info.length) { - throw new TitanBotError( - 'Seek out of range', - ErrorTypes.USER_INPUT, - `You can only seek up to ${Math.floor(info.length / 1000)}s for this track.`, - ); - } - - player.seek(position); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Seeked', `Seeked to **${seconds}s**.`); -} - -export async function removeFromQueue(client, interaction, index) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - - const queueIndex = index - 1; - if (queueIndex < 0 || queueIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, `Invalid queue position. Queue has ${player.queue.length} track(s).`); - } - - const removed = player.queue[queueIndex]; - player.queue.remove(queueIndex); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Removed', `Removed **${removed.info?.title || 'track'}** from the queue.`); -} - -export async function moveInQueue(client, interaction, from, to) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - - const fromIndex = from - 1; - const toIndex = to - 1; - if (fromIndex < 0 || fromIndex >= player.queue.length || toIndex < 0 || toIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, 'Invalid queue positions.'); - } - - const track = player.queue[fromIndex]; - player.queue.remove(fromIndex); - player.queue.splice(toIndex, 0, track); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Moved', `Moved **${track.info?.title || 'track'}** to position #${to}.`); -} - -export async function clearQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); - } - assertCanControl(interaction.member, player); - player.queue.clear(); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Queue Cleared', 'All queued tracks were removed.'); -} - -export async function setTwentyFourSeven(client, interaction, enabled) { - const guildData = getGuildMusicData(interaction.guild.id); - guildData.twentyFourSeven = enabled; - return successEmbed( - '24/7 Mode', - enabled - ? '24/7 mode enabled. The bot will stay in the voice channel when the queue ends.' - : '24/7 mode disabled. The bot will leave after 30 seconds of idle time.', - ); -} - -export function buildNowPlayingReply(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - const guildData = getGuildMusicData(guildId); - return { - embeds: [buildNowPlayingEmbed(player.current, player, guildData)], - }; -} - -export function buildQueueReply(client, guildId, page = 0) { - const player = getPlayer(client, guildId); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - - const totalPages = Math.max(1, Math.ceil((player.queue?.length || 0) / getQueuePageSize())); - const safePage = Math.min(Math.max(page, 0), totalPages - 1); - - return { - embeds: [buildQueueEmbed(player.queue, player.current, safePage)], - components: totalPages > 1 ? [buildQueuePaginationRow(safePage, totalPages)] : [], - page: safePage, - totalPages, - }; -} - -export async function destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect = false } = {}) { - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - - guildData.previousTracks = []; - guildData.stopConfirmPending = null; - guildData.autoPaused = false; - guildData.queuePages?.clear(); - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // message already deleted - } - } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - - if (player) { - player.queue.clear(); - player.stop(); - if (forceDisconnect || !guildData.twentyFourSeven) { - player.destroy(); - } - } -} - -export async function leaveVoiceChannel(client, interaction) { - assertRiffyAvailable(client); - - const guildId = interaction.guild.id; - const player = getPlayer(client, guildId); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); - } - assertCanControl(interaction.member, player); - - const channel = interaction.guild.channels.cache.get(player.voiceChannel); - const channelName = channel?.name || 'voice channel'; - const guildData = getGuildMusicData(guildId); - - await destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect: true }); - - return successEmbed('Left Voice Channel', `Disconnected from **${channelName}**.`); -} - -export async function replyMusicSuccess(interaction, embed) { - const options = { embeds: [embed] }; - if (!interaction._isPrefixCommand) { - options.flags = MessageFlags.Ephemeral; - } - await InteractionHelper.safeReply(interaction, options); -} diff --git a/src/services/music/musicEmbeds.js b/src/services/music/musicEmbeds.js deleted file mode 100644 index e57a74abe0..0000000000 --- a/src/services/music/musicEmbeds.js +++ /dev/null @@ -1,173 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getPaginationRow } from '../../utils/components.js'; - -const QUEUE_PAGE_SIZE = 10; - -export const MUSIC_BUTTON_IDS = { - PAUSE: 'music_pause', - RESUME: 'music_resume', - SKIP: 'music_skip', - STOP: 'music_stop', - SHUFFLE: 'music_shuffle', - LOOP: 'music_loop', - VOL_DOWN: 'music_vol_down', - VOL_UP: 'music_vol_up', - QUEUE: 'music_queue', - QUEUE_FIRST: 'music_queue_first', - QUEUE_PREV: 'music_queue_prev', - QUEUE_NEXT: 'music_queue_next', - QUEUE_LAST: 'music_queue_last', -}; - -export function formatDuration(ms) { - if (!ms || Number.isNaN(ms)) { - return 'Live'; - } - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; - } - return `${minutes}:${String(seconds).padStart(2, '0')}`; -} - -function getTrackArtwork(track) { - return track?.info?.artworkUrl || track?.info?.thumbnail || null; -} - -function getLoopLabel(loop) { - switch (loop) { - case 'track': - return 'Track'; - case 'queue': - return 'Queue'; - default: - return 'Off'; - } -} - -export function buildNowPlayingEmbed(track, player, guildData) { - const requester = track?.info?.requester; - const requesterLabel = requester - ? (requester.username || requester.tag || 'Unknown') - : 'Unknown'; - - const position = formatDuration(player?.position || 0); - const duration = formatDuration(track?.info?.length || 0); - - return createEmbed({ - title: 'Now Playing', - description: track?.info?.title || 'Unknown track', - color: 'primary', - fields: [ - { name: 'Artist', value: track?.info?.author || 'Unknown', inline: true }, - { name: 'Requester', value: requesterLabel, inline: true }, - { name: 'Progress', value: `${position} / ${duration}`, inline: true }, - { name: 'Volume', value: `${guildData?.volume ?? 75}%`, inline: true }, - { name: 'Loop', value: getLoopLabel(guildData?.loop), inline: true }, - { name: 'Queue', value: `${player?.queue?.length || 0} track(s)`, inline: true }, - ], - thumbnail: getTrackArtwork(track), - footer: player?.paused ? 'Paused' : 'Playing', - }); -} - -export function buildQueueEmbed(queue, currentTrack, page = 0) { - const totalTracks = queue?.length || 0; - const totalPages = Math.max(1, Math.ceil(totalTracks / QUEUE_PAGE_SIZE)); - const safePage = Math.min(Math.max(page, 0), totalPages - 1); - const start = safePage * QUEUE_PAGE_SIZE; - const slice = queue?.slice(start, start + QUEUE_PAGE_SIZE) || []; - - let description = ''; - if (currentTrack) { - description += `**Now Playing**\n${currentTrack.info?.title || 'Unknown'} — ${currentTrack.info?.author || 'Unknown'}\n\n`; - } - - if (slice.length === 0) { - description += 'The queue is empty.'; - } else { - description += slice - .map((track, index) => { - const num = start + index + 1; - return `${num}. ${track.info?.title || 'Unknown'} — ${track.info?.author || 'Unknown'}`; - }) - .join('\n'); - } - - return createEmbed({ - title: 'Music Queue', - description: description.substring(0, 4096), - color: 'info', - footer: `Page ${safePage + 1} of ${totalPages} • ${totalTracks} queued`, - }); -} - -export function buildPlayerButtonRows(player, guildData) { - const paused = player?.paused; - const row1 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.PAUSE) - .setLabel('Pause') - .setStyle(ButtonStyle.Primary) - .setEmoji('⏸️') - .setDisabled(Boolean(paused)), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.RESUME) - .setLabel('Resume') - .setStyle(ButtonStyle.Success) - .setEmoji('▶️') - .setDisabled(!paused), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.SKIP) - .setLabel('Skip') - .setStyle(ButtonStyle.Secondary) - .setEmoji('⏭️'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.STOP) - .setLabel('Stop') - .setStyle(ButtonStyle.Danger) - .setEmoji('⏹️'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.SHUFFLE) - .setLabel('Shuffle') - .setStyle(guildData?.shuffle ? ButtonStyle.Success : ButtonStyle.Secondary) - .setEmoji('🔀'), - ); - - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.LOOP) - .setLabel('Loop') - .setStyle(guildData?.loop !== 'none' ? ButtonStyle.Success : ButtonStyle.Secondary) - .setEmoji('🔁'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.VOL_DOWN) - .setLabel('Vol -') - .setStyle(ButtonStyle.Secondary) - .setEmoji('🔉'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.VOL_UP) - .setLabel('Vol +') - .setStyle(ButtonStyle.Secondary) - .setEmoji('🔊'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.QUEUE) - .setLabel('Queue') - .setStyle(ButtonStyle.Secondary) - .setEmoji('📋'), - ); - - return [row1, row2]; -} - -export function buildQueuePaginationRow(page, totalPages) { - return getPaginationRow('music_queue', page + 1, totalPages); -} - -export function getQueuePageSize() { - return QUEUE_PAGE_SIZE; -} diff --git a/src/services/music/musicVoiceState.js b/src/services/music/musicVoiceState.js deleted file mode 100644 index f6ba98d518..0000000000 --- a/src/services/music/musicVoiceState.js +++ /dev/null @@ -1,51 +0,0 @@ -import { successEmbed } from '../../utils/embeds.js'; -import { getGuildMusicData } from './playerStore.js'; -import { applyPause, applyResume, getPlayer } from './musicActions.js'; - -export async function handleMusicVoiceState(client, oldState, newState) { - if (!client.riffy) { - return; - } - - const guildId = newState.guild?.id || oldState.guild?.id; - if (!guildId) { - return; - } - - const player = getPlayer(client, guildId); - if (!player?.voiceChannel) { - return; - } - - const voiceChannel = client.channels.cache.get(player.voiceChannel); - if (!voiceChannel) { - return; - } - - const guildData = getGuildMusicData(guildId); - const humansInChannel = voiceChannel.members.filter((member) => !member.user.bot); - const hasUsers = humansInChannel.size > 0; - - if (!hasUsers && !player.paused && player.playing) { - guildData.autoPaused = true; - await applyPause(client, guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send({ embeds: [successEmbed('Paused', 'Voice channel is empty. Music paused until someone joins.')] }).catch(() => null); - } - } - return; - } - - if (hasUsers && guildData.autoPaused && player.paused) { - await applyResume(client, guildId); - guildData.autoPaused = false; - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send({ embeds: [successEmbed('Resumed', 'Someone joined the voice channel. Playback resumed.')] }).catch(() => null); - } - } - } -} diff --git a/src/services/music/permissions.js b/src/services/music/permissions.js deleted file mode 100644 index 2c0a09d908..0000000000 --- a/src/services/music/permissions.js +++ /dev/null @@ -1,14 +0,0 @@ -export const VOICE_CHANNEL_DENIAL = - 'You need to be in the same voice channel as the bot to use music controls.'; - -export function canControlMusic(member, player) { - const memberChannel = member?.voice?.channel; - if (!memberChannel || !player?.voiceChannel) { - return false; - } - return memberChannel.id === player.voiceChannel; -} - -export function requireVoiceChannel(member) { - return Boolean(member?.voice?.channel); -} diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js deleted file mode 100644 index b16290fbc3..0000000000 --- a/src/services/music/playerHandler.js +++ /dev/null @@ -1,254 +0,0 @@ -// Player event handlers for Riffy. Adapted from Musicify playerHandler (Apache-2.0). - -import { logger } from '../../utils/logger.js'; -import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; -import { - buildNowPlayingEmbed, - buildPlayerButtonRows, -} from './musicEmbeds.js'; - -const UPDATE_INTERVAL_MS = 15 * 1000; -const IDLE_DISCONNECT_MS = 30 * 1000; - -async function editOrSendPlayerMessage(client, guildData, channelId, embed, components) { - const channel = client.channels.cache.get(channelId); - if (!channel) { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - return; - } - - const payload = { embeds: [embed], components }; - - if (guildData.playerMessageId) { - try { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.edit(payload); - return; - } catch { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - clearUpdateInterval(guildData); - } - } - - try { - const newMsg = await channel.send(payload); - guildData.playerMessageId = newMsg.id; - guildData.playerChannelId = channel.id; - } catch (error) { - logger.error('Failed to send music player message:', error); - } -} - -export async function refreshPlayerMessage(client, guildId) { - try { - const player = client.riffy?.players?.get(guildId); - if (!player?.current) { - return; - } - - const guildData = getGuildMusicData(guildId); - const embed = buildNowPlayingEmbed(player.current, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - } catch (error) { - logger.error('Failed to refresh music player message:', error); - } -} - -function startUpdateInterval(client, guildId) { - const guildData = getGuildMusicData(guildId); - clearUpdateInterval(guildData); - guildData.updateInterval = setInterval(() => { - refreshPlayerMessage(client, guildId); - }, UPDATE_INTERVAL_MS); -} - -export function setupPlayerHandler(client) { - if (!client.riffy) { - logger.warn('Riffy not initialized; music player handlers not attached.'); - return; - } - - // Lavalink nodes often flap (reconnect -> error -> reconnect). Throttle all - // per-node messages to one line per interval, log the first connect only, - // and skip reconnect noise entirely since it is meaningless during flapping. - const nodeLogState = new Map(); - const NODE_LOG_INTERVAL_MS = 5 * 60 * 1000; - - const shouldLogNodeEvent = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - const now = Date.now(); - if (now - prev.lastLogAt < NODE_LOG_INTERVAL_MS) { - return false; - } - nodeLogState.set(nodeName, { ...prev, lastLogAt: now }); - return true; - }; - - const markNodeConnected = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - nodeLogState.set(nodeName, { ...prev, hasConnected: true }); - }; - - client.riffy.on('nodeConnect', (node) => { - const prev = nodeLogState.get(node.name) ?? { lastLogAt: 0, hasConnected: false }; - if (prev.hasConnected) { - return; - } - markNodeConnected(node.name); - logger.info(`Lavalink node "${node.name}" connected.`); - }); - - client.riffy.on('nodeReconnect', () => { - // Intentionally silent — reconnect spam is not actionable during flapping. - }); - - client.riffy.on('nodeError', (node, error) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" error: ${error?.message || error}`); - }); - - client.riffy.on('nodeDisconnect', (node) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" disconnected.`); - }); - - client.riffy.on('trackStart', async (player, track) => { - try { - const guildData = getGuildMusicData(player.guildId); - - // Keep the Lavalink player's loop mode aligned with the stored preference. - // Skip temporarily clears track-loop so it can advance; restore it here. - if (guildData.loop && player.loop !== guildData.loop) { - player.setLoop(guildData.loop); - } - - if (player.previous) { - guildData.previousTracks.push(player.previous); - if (guildData.previousTracks.length > 20) { - guildData.previousTracks.shift(); - } - } - - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - - const embed = buildNowPlayingEmbed(track, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - startUpdateInterval(client, player.guildId); - } catch (error) { - logger.error('Music trackStart error:', error); - } - }); - - client.riffy.on('queueEnd', async (player) => { - try { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.autoplay) { - player.autoplay(player); - return; - } - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } - - if (!guildData.twentyFourSeven) { - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - } - guildData.idleTimeout = setTimeout(() => { - try { - const currentPlayer = client.riffy.players.get(player.guildId); - if (currentPlayer && !currentPlayer.playing && !currentPlayer.paused && !currentPlayer.current) { - currentPlayer.destroy(); - } - } catch { - // player already destroyed - } - guildData.idleTimeout = null; - }, IDLE_DISCONNECT_MS); - } - } catch (error) { - logger.error('Music queueEnd error:', error); - } - }); - - client.riffy.on('playerDisconnect', async (player) => { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - guildData.previousTracks = []; - guildData.autoPaused = false; - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - }); - - client.riffy.on('trackError', async (player, track, payload) => { - logger.error(`Track error in ${player.guildId} for "${track?.info?.title}":`, payload?.error || payload); - const guildData = getGuildMusicData(player.guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send(`Failed to play **${track?.info?.title || 'track'}**. Skipping...`).catch(() => null); - } - } - }); - - client.riffy.on('trackStuck', async (player, track, payload) => { - logger.warn(`Track stuck in ${player.guildId} for "${track?.info?.title}" (${payload?.thresholdMs}ms)`); - }); -} - -export async function shutdownMusic(client) { - if (!client.riffy?.players) { - return; - } - - for (const player of client.riffy.players.values()) { - try { - player.destroy(); - } catch (error) { - logger.debug('Error destroying music player during shutdown:', error.message); - } - } -} diff --git a/src/services/music/playerStore.js b/src/services/music/playerStore.js deleted file mode 100644 index 009ad3e686..0000000000 --- a/src/services/music/playerStore.js +++ /dev/null @@ -1,46 +0,0 @@ -// Per-guild music session state (in-memory). Adapted from Musicify playerStore (Apache-2.0). - -export class GuildMusicData { - constructor() { - this.playerMessageId = null; - this.playerChannelId = null; - this.autoplay = false; - this.loop = 'none'; - this.volume = 75; - this.shuffle = false; - this.previousTracks = []; - this.twentyFourSeven = false; - this.queuePages = new Map(); - this.updateInterval = null; - this.idleTimeout = null; - this.autoPaused = false; - this.stopConfirmPending = null; - } -} - -export function clearUpdateInterval(guildData) { - if (guildData.updateInterval) { - clearInterval(guildData.updateInterval); - guildData.updateInterval = null; - } -} - -const guildStore = new Map(); - -export function getGuildMusicData(guildId) { - if (!guildStore.has(guildId)) { - guildStore.set(guildId, new GuildMusicData()); - } - return guildStore.get(guildId); -} - -export function deleteGuildMusicData(guildId) { - const guildData = guildStore.get(guildId); - if (guildData) { - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - } - } - guildStore.delete(guildId); -} diff --git a/src/services/music/prefixSupport.js b/src/services/music/prefixSupport.js deleted file mode 100644 index ade295648a..0000000000 --- a/src/services/music/prefixSupport.js +++ /dev/null @@ -1,10 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export function getMusicDeferOptions(interaction) { - return interaction._isPrefixCommand ? {} : { flags: MessageFlags.Ephemeral }; -} - -export async function deferMusicCommand(interaction) { - return InteractionHelper.safeDefer(interaction, getMusicDeferOptions(interaction)); -} diff --git a/src/services/music/riffySetup.js b/src/services/music/riffySetup.js deleted file mode 100644 index 70fde72d50..0000000000 --- a/src/services/music/riffySetup.js +++ /dev/null @@ -1,66 +0,0 @@ -import { createRequire } from 'module'; -import { GatewayDispatchEvents } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import lavalinkConfig from '../../config/music/lavalink.js'; -import { setupPlayerHandler } from './playerHandler.js'; - -const require = createRequire(import.meta.url); -const { Riffy } = require('riffy'); - -export function initializeMusic(client) { - if (!lavalinkConfig.nodes?.length) { - logger.error('No Lavalink nodes configured. Add lavalink/nodes.json, set LAVALINK_NODES, or set LAVALINK_HOST in your environment.'); - return; - } - - client.riffy = new Riffy(client, lavalinkConfig.nodes, { - send: (payload) => { - const guildId = payload.d?.guild_id; - if (!guildId) { - return; - } - - const guild = client.guilds.cache.get(guildId); - if (guild?.shard) { - guild.shard.send(payload); - return; - } - - const shardCount = client.ws.shards.size || 1; - const shardId = Number((BigInt(guildId) >> 22n) % BigInt(shardCount)); - client.ws.shards.get(shardId)?.send(payload); - }, - defaultSearchPlatform: lavalinkConfig.defaultSearchPlatform, - restVersion: lavalinkConfig.restVersion, - bypassChecks: { - nodeFetchInfo: true, - }, - }); - - setupPlayerHandler(client); - - client.on('raw', (packet) => { - if ( - ![ - GatewayDispatchEvents.VoiceStateUpdate, - GatewayDispatchEvents.VoiceServerUpdate, - ].includes(packet.t) - ) { - return; - } - client.riffy.updateVoiceState(packet); - }); - - client.riffy.on('playerError', (player, error) => { - logger.error(`Music player error in guild ${player.guildId}:`, error); - }); - - logger.info(`Music initialized with ${lavalinkConfig.nodes.length} Lavalink node(s).`); -} - -export function initRiffyAfterReady(client) { - if (client.riffy && client.user?.id) { - client.riffy.init(client.user.id); - logger.info('Riffy voice connection manager initialized.'); - } -} diff --git a/src/utils/constants.js b/src/utils/constants.js index 18382d6225..bd1b1f10a0 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -18,6 +18,7 @@ export const DEFAULT_ECONOMY_DATA = { lastDeposit: 0, lastWithdraw: 0, inventory: {}, + fishCollection: {}, upgrades: {}, cooldowns: {} }; @@ -94,4 +95,4 @@ export default { DEFAULTS, ERROR_DEFAULTS, TIME -}; \ No newline at end of file +}; diff --git a/src/utils/schemas.js b/src/utils/schemas.js index 1ecc1af4bf..9778ded3f4 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.js @@ -96,6 +96,7 @@ export const EconomyDataSchema = z xp: z.number().int().nonnegative().default(0), level: z.number().int().nonnegative().default(1), inventory: z.record(z.any()).default({}), + fishCollection: z.record(z.number().int().nonnegative()).default({}), cooldowns: z.record(z.number().int().nonnegative()).default({}) }) .passthrough();