diff --git a/src/commands/prefix/sfx.js b/src/commands/prefix/sfx.js index ba052aa..98ba686 100644 --- a/src/commands/prefix/sfx.js +++ b/src/commands/prefix/sfx.js @@ -50,13 +50,6 @@ module.exports = { } const sfxName = args[0]; - - // Log the SFX command - if (sfxName) { - console.log( - `SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}` - ); - } // If no SFX specified, show the list if (!sfxName) { @@ -92,42 +85,12 @@ module.exports = { return; } - // Check if SFX exists - if (!sfxManager.hasSFX(sfxName)) { - return message.reply('This sound effect does not exist!'); - } - // Check if user is in a voice channel if (!message.member.voice.channel) { return message.reply('You need to be in a voice channel to use this command!'); } - try { - // Join the voice channel - await voiceService.join(message.member.voice.channel); - - // Get the SFX file path - const sfxPath = sfxManager.getSFXPath(sfxName); - - // Play the sound effect - await voiceService.play( - message.guild.id, - sfxPath, - { - volume: guildConfig.sfxVolume || 0.5 - } - ); - - // Leave the voice channel after playing - setTimeout(() => { - voiceService.leave(message.guild.id); - }, 500); - - console.log(`โœ… Successfully played SFX '${sfxName}'`); - - } catch (error) { - console.error(`โŒ Error playing SFX '${sfxName}':`, error); - await message.reply("I couldn't play that sound effect. Make sure I have permission to join your voice channel!"); - } + // Use the reusable SFX playing method for messages + await sfxManager.playSFXMessage(message, sfxName, guildConfig); } }; \ No newline at end of file diff --git a/src/commands/slash/index.js b/src/commands/slash/index.js index 21dd55a..337fcb3 100644 --- a/src/commands/slash/index.js +++ b/src/commands/slash/index.js @@ -1,5 +1,6 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); +const { MessageFlags } = require("discord.js"); class SlashCommandHandler { constructor() { @@ -11,12 +12,13 @@ class SlashCommandHandler { * Load all slash command modules */ loadCommands() { - const commandFiles = fs.readdirSync(__dirname) - .filter(file => file.endsWith('.js') && file !== 'index.js'); + const commandFiles = fs + .readdirSync(__dirname) + .filter((file) => file.endsWith(".js") && file !== "index.js"); for (const file of commandFiles) { const command = require(path.join(__dirname, file)); - + if (command.data?.name) { this.commands.set(command.data.name, command); } @@ -30,18 +32,18 @@ class SlashCommandHandler { * @returns {Array} */ getSlashCommandDefinitions() { - return Array.from(this.commands.values()).map(cmd => cmd.data.toJSON()); + return Array.from(this.commands.values()).map((cmd) => cmd.data.toJSON()); } /** * Execute a slash command - * @param {string} commandName - * @param {CommandInteraction} interaction - * @param {Object} guildConfig + * @param {string} commandName + * @param {CommandInteraction} interaction + * @param {Object} guildConfig */ async execute(commandName, interaction, guildConfig) { const command = this.commands.get(commandName); - + if (!command) { return; } @@ -56,12 +58,12 @@ class SlashCommandHandler { /** * Handle autocomplete interactions - * @param {AutocompleteInteraction} interaction - * @param {Object} guildConfig + * @param {AutocompleteInteraction} interaction + * @param {Object} guildConfig */ async handleAutocomplete(interaction, guildConfig) { const command = this.commands.get(interaction.commandName); - + if (!command || !command.autocomplete) { return; } @@ -69,9 +71,48 @@ class SlashCommandHandler { try { await command.autocomplete(interaction, guildConfig); } catch (error) { - console.error(`Error handling autocomplete for ${interaction.commandName}:`, error); + console.error( + `Error handling autocomplete for ${interaction.commandName}:`, + error + ); + } + } + + /** + * Handle button interactions for soundboard + * @param {ButtonInteraction} interaction + * @param {Object} guildConfig + */ + async handleButton(interaction, guildConfig) { + if (interaction.customId.startsWith("soundboard_")) { + const soundboardCommand = this.commands.get("soundboard"); + + if (!soundboardCommand) return; + + try { + if (interaction.customId.startsWith("soundboard_category_")) { + await soundboardCommand.handleCategorySelection( + interaction, + guildConfig + ); + } else if (interaction.customId.startsWith("soundboard_play_")) { + await soundboardCommand.handleSoundPlay(interaction, guildConfig); + } else if (interaction.customId === "soundboard_back") { + // Re-execute the main soundboard command + await soundboardCommand.execute(interaction, guildConfig); + } + } catch (error) { + console.error(`Error handling soundboard button interaction:`, error); + + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: "There was an error with the soundboard!", + flags: [MessageFlags.Ephemeral], + }); + } + } } } } -module.exports = new SlashCommandHandler(); \ No newline at end of file +module.exports = new SlashCommandHandler(); diff --git a/src/commands/slash/sfx.js b/src/commands/slash/sfx.js index 512fdc6..f0ee0d2 100644 --- a/src/commands/slash/sfx.js +++ b/src/commands/slash/sfx.js @@ -26,64 +26,17 @@ module.exports = { } const sfxName = interaction.options.getString('sound'); - - // Log the slash command SFX request - console.log( - `/sfx '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}` - ); - - // Check if SFX exists - if (!sfxManager.hasSFX(sfxName)) { - return interaction.reply({ - content: 'This sound effect does not exist!', - flags: [MessageFlags.Ephemeral] - }); - } // Check if user is in a voice channel - const member = interaction.member; - if (!member.voice.channel) { + if (!interaction.member.voice.channel) { return interaction.reply({ content: 'You need to be in a voice channel to use this command!', flags: [MessageFlags.Ephemeral] }); } - // Defer the reply as joining voice might take a moment - await interaction.deferReply(); - - try { - // Join the voice channel - await voiceService.join(member.voice.channel); - - // Get the SFX file path - const sfxPath = sfxManager.getSFXPath(sfxName); - - // Play the sound effect - await voiceService.play( - interaction.guild.id, - sfxPath, - { - volume: guildConfig.sfxVolume || 0.5 - } - ); - - // Update the reply - await interaction.editReply(`Playing sound effect: **${sfxName}**`); - - // Leave the voice channel after playing - setTimeout(() => { - voiceService.leave(interaction.guild.id); - }, 500); - - console.log(`โœ… Successfully played /sfx '${sfxName}'`); - - } catch (error) { - console.error(`โŒ Error playing /sfx '${sfxName}':`, error); - await interaction.editReply({ - content: "I couldn't play that sound effect. Make sure I have permission to join your voice channel!" - }); - } + // Use the reusable SFX playing method + await sfxManager.playSFXInteraction(interaction, sfxName, guildConfig, 'slash'); }, async autocomplete(interaction, guildConfig) { diff --git a/src/commands/slash/soundboard.js b/src/commands/slash/soundboard.js new file mode 100644 index 0000000..b68e228 --- /dev/null +++ b/src/commands/slash/soundboard.js @@ -0,0 +1,258 @@ +const { + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, +} = require("discord.js"); +const fs = require("fs"); +const path = require("path"); +const sfxManager = require("../../services/sfxManager"); +const voiceService = require("../../services/voiceService"); + +// Parse categories from README.md +function getSFXCategories() { + try { + const sfxReadmePath = path.join( + __dirname, + "..", + "..", + "..", + "sfx", + "README.md" + ); + + if (!fs.existsSync(sfxReadmePath)) { + return null; + } + + const content = fs.readFileSync(sfxReadmePath, "utf-8"); + const categories = {}; + + // Parse categories and their sounds + const lines = content.split("\n"); + let currentCategory = null; + + for (const line of lines) { + const headerMatch = line.match(/^\*\*([^*]+)\*\*$/); + if (headerMatch) { + currentCategory = headerMatch[1]; + categories[currentCategory] = []; + } else if (currentCategory && line.trim() && !line.startsWith("```")) { + // Parse comma-separated sounds from the line + const sounds = line + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + categories[currentCategory].push(...sounds); + } + } + + return categories; + } catch (error) { + console.error("Error parsing SFX categories:", error); + return null; + } +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("soundboard") + .setDescription("Interactive soundboard with categorized buttons"), + + async execute(interaction, guildConfig) { + // Check if SFX is allowed in this channel + if (guildConfig.allowedSfxChannels) { + const allowedChannels = new RegExp(guildConfig.allowedSfxChannels); + if (!allowedChannels.test(interaction.channel.name)) { + return interaction.reply({ + content: "Sound effects are not allowed in this channel!", + flags: [MessageFlags.Ephemeral], + }); + } + } + + // Check if user is in a voice channel + if (!interaction.member.voice.channel) { + return interaction.reply({ + content: "You need to be in a voice channel to use the soundboard!", + flags: [MessageFlags.Ephemeral], + }); + } + + const categories = getSFXCategories(); + + if (!categories) { + return interaction.reply({ + content: + "Soundboard not available - SFX categories could not be loaded.", + flags: [MessageFlags.Ephemeral], + }); + } + + // Create category selection buttons (max 5 buttons per row, 5 rows max = 25 buttons total) + const categoryNames = Object.keys(categories); + const rows = []; + let buttonCount = 0; + + for (let i = 0; i < categoryNames.length && rows.length < 5; i += 5) { + const row = new ActionRowBuilder(); + const categoriesInRow = categoryNames.slice(i, i + 5); + + for (const category of categoriesInRow) { + if (buttonCount >= 25) break; // Discord limit is 25 components total + + const button = new ButtonBuilder() + .setCustomId( + `soundboard_category_${category.toLowerCase().replace(/\s+/g, "_")}` + ) + .setLabel(category.length > 80 ? category.substring(0, 77) + '...' : category) + .setStyle(ButtonStyle.Primary) + .setEmoji("๐ŸŽต"); + + row.addComponents(button); + buttonCount++; + } + + if (row.components.length > 0) { + rows.push(row); + } + } + + const embed = new EmbedBuilder() + .setTitle("๐ŸŽ›๏ธ Interactive Soundboard") + .setDescription("Choose a category to browse sound effects:") + .setColor(0x21c629) + .addFields([ + { + name: "Available Categories", + value: categoryNames + .map((cat) => `๐ŸŽต **${cat}** (${categories[cat].length} sounds)`) + .join("\n"), + inline: false, + }, + ]) + .setFooter({ text: "Click a category button to browse sounds" }); + + await interaction.reply({ + embeds: [embed], + components: rows, + }); + }, + + async handleCategorySelection(interaction, guildConfig) { + const customId = interaction.customId; + let categoryKey, page = 0; + + if (customId.includes('_page_')) { + // Handle pagination: soundboard_category_general_page_1 + const parts = customId.replace("soundboard_category_", "").split('_page_'); + categoryKey = parts[0].replace(/_/g, " ").toUpperCase(); + page = parseInt(parts[1]) || 0; + } else { + // Handle initial category selection + categoryKey = customId + .replace("soundboard_category_", "") + .replace(/_/g, " ") + .toUpperCase(); + } + + const categories = getSFXCategories(); + + if (!categories || !categories[categoryKey]) { + return interaction.reply({ + content: "Category not found!", + flags: [MessageFlags.Ephemeral], + }); + } + + const allSounds = categories[categoryKey].filter(sound => sfxManager.hasSFX(sound)); + const soundsPerPage = 16; // 4 sounds per row ร— 4 rows = 16 sounds per page + const totalPages = Math.ceil(allSounds.length / soundsPerPage); + const startIndex = page * soundsPerPage; + const sounds = allSounds.slice(startIndex, startIndex + soundsPerPage); + + const rows = []; + let buttonCount = 0; + + // Create sound buttons (4 per row, 4 rows for sounds + 1 for navigation = 16 sound buttons max) + for (let i = 0; i < sounds.length && rows.length < 4; i += 4) { + const row = new ActionRowBuilder(); + const soundsInRow = sounds.slice(i, i + 4); + + for (const sound of soundsInRow) { + if (buttonCount >= 16) break; // Leave room for navigation row + + const button = new ButtonBuilder() + .setCustomId(`soundboard_play_${sound}`) + .setLabel(sound.length > 80 ? sound.substring(0, 77) + '...' : sound) + .setStyle(ButtonStyle.Secondary) + .setEmoji("โ–ถ๏ธ"); + + row.addComponents(button); + buttonCount++; + } + + if (row.components.length > 0) { + rows.push(row); + } + } + + // Add navigation row with back button and pagination if needed + const navRow = new ActionRowBuilder(); + + // Add previous page button if not on first page + if (page > 0) { + const prevButton = new ButtonBuilder() + .setCustomId(`soundboard_category_${categoryKey.toLowerCase().replace(/\s+/g, "_")}_page_${page - 1}`) + .setLabel("ยซ Previous") + .setStyle(ButtonStyle.Secondary) + .setEmoji("โ—€๏ธ"); + navRow.addComponents(prevButton); + } + + // Add back to categories button + const backButton = new ButtonBuilder() + .setCustomId("soundboard_back") + .setLabel("Back to Categories") + .setStyle(ButtonStyle.Primary) + .setEmoji("๐Ÿ”™"); + navRow.addComponents(backButton); + + // Add next page button if there are more pages + if (page < totalPages - 1) { + const nextButton = new ButtonBuilder() + .setCustomId(`soundboard_category_${categoryKey.toLowerCase().replace(/\s+/g, "_")}_page_${page + 1}`) + .setLabel("Next ยป") + .setStyle(ButtonStyle.Secondary) + .setEmoji("โ–ถ๏ธ"); + navRow.addComponents(nextButton); + } + + rows.push(navRow); + + // Show pagination info + const paginationNote = totalPages > 1 ? `\n\n*Page ${page + 1} of ${totalPages} (${allSounds.length} total sounds)*` : ''; + + const embed = new EmbedBuilder() + .setTitle(`๐ŸŽต ${categoryKey} Soundboard`) + .setDescription( + `Choose a sound effect to play:${paginationNote}` + ) + .setColor(0x21c629) + .setFooter({ text: "Click a sound button to play it" }); + + await interaction.update({ + embeds: [embed], + components: rows, + }); + }, + + async handleSoundPlay(interaction, guildConfig) { + const soundName = interaction.customId.replace("soundboard_play_", ""); + + // Use the reusable SFX playing method + await sfxManager.playSFXInteraction(interaction, soundName, guildConfig, 'soundboard'); + }, +}; diff --git a/src/index.js b/src/index.js index 6ce3112..2001b7e 100644 --- a/src/index.js +++ b/src/index.js @@ -176,9 +176,9 @@ client.on(Events.MessageCreate, async (message) => { } }); -// Interaction handler for slash commands +// Interaction handler for slash commands and components client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isChatInputCommand() && !interaction.isAutocomplete()) + if (!interaction.isChatInputCommand() && !interaction.isAutocomplete() && !interaction.isButton()) return; // Get guild configuration from database/file @@ -194,6 +194,8 @@ client.on(Events.InteractionCreate, async (interaction) => { interaction, guildConfig ); + } else if (interaction.isButton()) { + await slashCommands.handleButton(interaction, guildConfig); } } catch (error) { console.error("Error handling interaction:", error); diff --git a/src/services/sfxManager.js b/src/services/sfxManager.js index 0b233ad..06017c2 100644 --- a/src/services/sfxManager.js +++ b/src/services/sfxManager.js @@ -1,5 +1,7 @@ const fs = require('fs'); const path = require('path'); +const { MessageFlags } = require('discord.js'); +const voiceService = require('./voiceService'); class SFXManager { constructor() { @@ -130,6 +132,118 @@ class SFXManager { return results; } + + /** + * Play a sound effect via interaction (slash commands and soundboard) + * @param {Object} interaction - Discord interaction object + * @param {string} sfxName - Name of the sound effect to play + * @param {Object} guildConfig - Guild configuration + * @param {string} commandType - Type of command ('slash' or 'soundboard') + * @returns {Promise} + */ + async playSFXInteraction(interaction, sfxName, guildConfig, commandType = 'slash') { + // Log the request + const logPrefix = commandType === 'soundboard' ? 'Soundboard' : '/sfx'; + console.log( + `${logPrefix} '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}` + ); + + // Check if SFX exists + if (!this.hasSFX(sfxName)) { + await interaction.reply({ + content: `โŒ This sound effect does not exist!`, + flags: [MessageFlags.Ephemeral] + }); + return; + } + + try { + // Immediately reply with playing status + await interaction.reply({ + content: `๐Ÿ”Š Playing: **${sfxName}**`, + flags: [MessageFlags.Ephemeral] + }); + + // Join the voice channel + await voiceService.join(interaction.member.voice.channel); + + // Get the SFX file path and play + const sfxPath = this.getSFXPath(sfxName); + await voiceService.play(interaction.guild.id, sfxPath, { + volume: guildConfig.sfxVolume || 0.5, + }); + + // Update the interaction to show completion + try { + await interaction.editReply({ + content: `โœ… Finished playing: **${sfxName}**` + }); + } catch (editError) { + console.error('Error updating interaction with completion message:', editError); + } + + // Leave the voice channel after playing + setTimeout(() => { + voiceService.leave(interaction.guild.id); + }, 500); + + console.log(`โœ… Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`); + + } catch (error) { + console.error(`โŒ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`, error); + + // Update the reply with error message + try { + await interaction.editReply({ + content: "โŒ Couldn't play that sound effect. Make sure I have permission to join your voice channel!" + }); + } catch (editError) { + console.error('Error updating interaction with error message:', editError); + } + } + } + + /** + * Play a sound effect via message (prefix commands) + * @param {Object} message - Discord message object + * @param {string} sfxName - Name of the sound effect to play + * @param {Object} guildConfig - Guild configuration + * @returns {Promise} + */ + async playSFXMessage(message, sfxName, guildConfig) { + // Log the request + console.log( + `SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}` + ); + + // Check if SFX exists + if (!this.hasSFX(sfxName)) { + await message.reply('โŒ This sound effect does not exist!'); + return; + } + + try { + // Join the voice channel + await voiceService.join(message.member.voice.channel); + + // Get the SFX file path and play + const sfxPath = this.getSFXPath(sfxName); + await voiceService.play(message.guild.id, sfxPath, { + volume: guildConfig.sfxVolume || 0.5, + }); + + // Leave the voice channel after playing + setTimeout(() => { + voiceService.leave(message.guild.id); + }, 500); + + console.log(`โœ… Successfully played SFX '${sfxName}'`); + + } catch (error) { + console.error(`โŒ Error playing SFX '${sfxName}':`, error); + await message.reply("โŒ Couldn't play that sound effect. Make sure I have permission to join your voice channel!"); + } + } } module.exports = new SFXManager(); \ No newline at end of file