diff --git a/package.json b/package.json index 1de3ac8..c848a94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "musicify", - "version": "1.0.0", + "version": "1.2.0", "description": "Musicify - A multi-guild Discord music bot with ChatPlay", "main": "src/index.js", "scripts": { diff --git a/src/commands/about.js b/src/commands/about.js index 614340b..8557197 100644 --- a/src/commands/about.js +++ b/src/commands/about.js @@ -35,26 +35,23 @@ module.exports = { container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**What is Musicify?**\n" + - "-# A ChatPlay-focused Discord music bot that delivers high-quality\n" + - "-# music streaming directly to your voice channels.\n\n" + - "**Powered By**\n" + - "-# [discord.js](https://discord.js.org/) · [Riffy](https://riffy.js.org/) · [Musicard](https://www.npmjs.com/package/musicard)\n\n" + - "**Features**\n" + - "-# • **Rich now-playing cards** with progress bars\n" + - "-# • **10+ audio filter** presets\n" + - "-# • **Smart queue management** with pagination\n" + - "-# • **ChatPlay** — instant song requests\n" + - "-# • **Interactive button-based** controls" - ) - ); - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "-# Musicify is [open source](https://github.com/codebymitch/Musicify). Built by a passionate team of developers." - ) + // Package and dependencies section with ButtonAccessory + container.addSectionComponents( + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Powered By**\n" + + "-# • [discord.js](https://discord.js.org/)\n" + + "-# • [Riffy](https://riffy.js.org/)\n" + + "-# • [Musicard](https://www.npmjs.com/package/musicard)" + ) + ) + .addButtonAccessory( + new ButtonBuilder() + .setLabel("Source Code") + .setURL("https://github.com/codebymitch/Musicify") + .setStyle(ButtonStyle.Link) + ) ); container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); diff --git a/src/commands/bot-stats.js b/src/commands/bot-stats.js index 7f91207..119ccb0 100644 --- a/src/commands/bot-stats.js +++ b/src/commands/bot-stats.js @@ -6,6 +6,8 @@ const { SeparatorBuilder, SectionBuilder, ThumbnailBuilder, + ButtonBuilder, + ButtonStyle, } = require("discord.js"); module.exports = { @@ -18,6 +20,7 @@ module.exports = { const container = new ContainerBuilder(); + // --- Header with bot avatar --- const header = new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent("### <:Musicify_Logo:1504329028356673536> Statistics") @@ -32,6 +35,7 @@ module.exports = { container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + // --- Calculate stats --- const uptimeSeconds = process.uptime(); const startTime = new Date(Date.now() - uptimeSeconds * 1000); const startTimestamp = Math.floor(startTime.getTime() / 1000); @@ -47,49 +51,75 @@ module.exports = { const activePlayers = client.riffy.players?.size || 0; const totalNodes = client.riffy.nodes?.length || client.riffy.nodes?.size || 0; - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**Bot ID**\n" + - `-# \`${client.user.id}\`\n` + - "**Uptime**\n" + - `-# ()\n` + - `-# *Times shown in your local timezone*\n` + - "**Ping**\n" + - `-# ${client.ws.ping}ms\n` + - "**Runtime**\n" + - `-# [Node.js ${process.version}](https://nodejs.org/) · [discord.js v${require("discord.js").version}](https://discord.js.org/)` + // --- General section with Support button accessory --- + const generalSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Bot ID**\n" + + `-# \`${client.user.id}\`\n` + + "**Uptime**\n" + + `-# ()\n` + + `-# *Times shown in your local timezone*\n` + + "**Ping**\n" + + `-# ${client.ws.ping}ms\n` + + "**Runtime**\n" + + `-# [Node.js ${process.version}](https://nodejs.org/) · [discord.js v${require("discord.js").version}](https://discord.js.org/)` + ) ) - ); + .setButtonAccessory( + new ButtonBuilder() + .setLabel("Support") + .setURL("https://discord.gg/MRjEUhDCpZ") + .setStyle(ButtonStyle.Link) + ); + + container.addSectionComponents(generalSection); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**Guilds**\n" + - `-# ${totalGuilds.toLocaleString()}\n` + - "**Users**\n" + - `-# ${totalUsers.toLocaleString()}\n` + - "**Channels**\n" + - `-# ${totalChannels.toLocaleString()}\n` + - "**Active Players**\n" + - `-# ${activePlayers}\n` + - "**Lavalink Nodes**\n" + - `-# ${totalNodes}` + // --- Server stats section with Vote button accessory --- + const serverSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Guilds**\n" + + `-# ${totalGuilds.toLocaleString()}\n` + + "**Users**\n" + + `-# ${totalUsers.toLocaleString()}\n` + + "**Channels**\n" + + `-# ${totalChannels.toLocaleString()}` + ) ) - ); + .setButtonAccessory( + new ButtonBuilder() + .setLabel("⭐ Vote") + .setURL("https://top.gg/bot/1502977716196999309/vote") + .setStyle(ButtonStyle.Link) + ); + + container.addSectionComponents(serverSection); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**Heap Used**\n" + - `-# ${memUsed} MB\n` + - "**Heap Total**\n" + - `-# ${memTotal} MB\n` + - "**RSS**\n" + - `-# ${memRSS} MB` + // --- Performance section with Suggest button accessory --- + const perfSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Active Players**\n" + + `-# ${activePlayers}\n` + + "**Lavalink Nodes**\n" + + `-# ${totalNodes}\n` + + "**Memory**\n" + + `-# Heap: ${memUsed} / ${memTotal} MB · RSS: ${memRSS} MB` + ) ) - ); + .setButtonAccessory( + new ButtonBuilder() + .setLabel("💡 Suggest") + .setURL("https://discord.com/channels/1503210009251545152/1503721044291092480") + .setStyle(ButtonStyle.Link) + ); + + container.addSectionComponents(perfSection); await interaction.editReply({ components: [container], diff --git a/src/commands/chatplay.js b/src/commands/chatplay.js deleted file mode 100644 index f13464f..0000000 --- a/src/commands/chatplay.js +++ /dev/null @@ -1,129 +0,0 @@ -const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder, SeparatorBuilder } = require("discord.js"); -const { getGuildData } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); -const { setGuildSetting, deleteGuildSetting } = require("../utils/database"); - -module.exports = { - data: new SlashCommandBuilder() - .setName("chatplay") - .setDescription("Manage ChatPlay mode") - .addSubcommand((sub) => - sub.setName("setup").setDescription("Set up ChatPlay in this channel (sends persistent message)") - ) - .addSubcommand((sub) => - sub.setName("enable").setDescription("Enable ChatPlay in a previously set up channel") - ) - .addSubcommand((sub) => - sub.setName("disable").setDescription("Disable ChatPlay (keeps the message but stops listening)") - ), - - async execute(interaction, client) { - const sub = interaction.options.getSubcommand(); - const guildId = interaction.guild.id; - const guildData = getGuildData(guildId); - - if (sub === "setup") { - // Clean up old ChatPlay message if it exists - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - try { - const oldChannel = client.channels.cache.get(guildData.chatPlayChannelId); - if (oldChannel) { - const oldMsg = await oldChannel.messages.fetch(guildData.chatPlayMessageId); - await oldMsg.delete(); - } - } catch (err) { - // ignore - } - } - - const container = createChatPlayIdleContainer(); - const chatMsg = await interaction.channel.send({ - components: [container], - flags: MessageFlags.IsComponentsV2, - }); - - // Set slowmode to prevent spam (5 seconds) - try { - await interaction.channel.setRateLimitPerUser(5, "ChatPlay setup - prevents spam"); - } catch (err) { - // Bot may lack Manage Channel permission - } - - guildData.chatPlayChannelId = interaction.channel.id; - guildData.chatPlayMessageId = chatMsg.id; - guildData.chatPlayEnabled = true; - guildData.playerChannelId = interaction.channel.id; - - setGuildSetting(guildId, "chatPlayChannelId", interaction.channel.id); - setGuildSetting(guildId, "chatPlayMessageId", chatMsg.id); - setGuildSetting(guildId, "chatPlayEnabled", true); - - const reply = new ContainerBuilder(); - reply.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ✅ ChatPlay Setup Complete\n\n" + - "**Channel**\n" + - `-# <#${interaction.channel.id}>\n\n` + - "**How to use**\n" + - "-# Just type a song name in this channel to play it!" - ) - ); - await interaction.reply({ - components: [reply], - flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, - }); - - } else if (sub === "enable") { - if (!guildData.chatPlayChannelId) { - return interaction.reply({ - content: "❌ ChatPlay hasn't been set up yet. Use `/chatplay setup` first.", - flags: MessageFlags.Ephemeral, - }); - } - - guildData.chatPlayEnabled = true; - setGuildSetting(guildId, "chatPlayEnabled", true); - - const reply = new ContainerBuilder(); - reply.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ChatPlay Enabled" + - "**Status**" + - "-# Listening for song requests." + - "**Channel**" + - `-# <#${guildData.chatPlayChannelId}>` - ) - ); - await interaction.reply({ - components: [reply], - flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, - }); - - } else if (sub === "disable") { - if (!guildData.chatPlayChannelId) { - return interaction.reply({ - content: "❌ ChatPlay is not set up in any channel.", - flags: MessageFlags.Ephemeral, - }); - } - - guildData.chatPlayEnabled = false; - setGuildSetting(guildId, "chatPlayEnabled", false); - - const reply = new ContainerBuilder(); - reply.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ⏸ ChatPlay Disabled\n\n" + - "**Status**\n" + - "-# Paused — the player message is kept.\n\n" + - "**Resume**\n" + - "-# Use `/chatplay enable` to start listening again." - ) - ); - await interaction.reply({ - components: [reply], - flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, - }); - } - }, -}; diff --git a/src/commands/clear.js b/src/commands/clear.js index 25fbb92..df1ca55 100644 --- a/src/commands/clear.js +++ b/src/commands/clear.js @@ -15,9 +15,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/filter.js b/src/commands/filter.js index bd71a2f..d8ef584 100644 --- a/src/commands/filter.js +++ b/src/commands/filter.js @@ -39,8 +39,9 @@ module.exports = { if (!player || !player.current) { return interaction.reply({ content: "❌ Nothing is playing right now.", flags: MessageFlags.Ephemeral }); } - if (!interaction.member.voice?.channel) { - return interaction.reply({ content: "❌ You need to be in a voice channel!", flags: MessageFlags.Ephemeral }); + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { + return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); } const preset = interaction.options.getString("preset"); diff --git a/src/commands/help.js b/src/commands/help.js index 3e7ae64..1adb275 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -76,10 +76,13 @@ function buildDropdown(activePage = "home") { async function buildHelpPage(client, page = "home") { const container = new ContainerBuilder(); - // --- Header with bot avatar --- - const section = new SectionBuilder() + // --- Header: Command Browser style (Flamey-inspired) --- + const header = new SectionBuilder() .addTextDisplayComponents( - new TextDisplayBuilder().setContent("# <:Musicify_Logo:1504329028356673536> Musicify") + new TextDisplayBuilder().setContent( + "## <:Musicify_Logo:1504329028356673536> Command Browser\n" + + "-# Select a category to view commands." + ) ) .setThumbnailAccessory( new ThumbnailBuilder().setURL( @@ -87,9 +90,9 @@ async function buildHelpPage(client, page = "home") { ) ); - container.addSectionComponents(section); + container.addSectionComponents(header); - // --- Category Dropdown (at the top, like the Command Browser) --- + // --- Category Dropdown --- container.addActionRowComponents(buildDropdown(page)); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); @@ -187,34 +190,70 @@ function addHomePage(container, getCmd) { } function addMusicPage(container, getCmd) { + // --- Section header with Suggest button accessory --- + const musicHeader = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### 🎶 All Commands\n" + + "-# 17 commands available" + ) + ) + .setButtonAccessory( + new ButtonBuilder() + .setLabel("💡 Suggest") + .setURL("https://discord.com/channels/1503210009251545152/1503721044291092480") + .setStyle(ButtonStyle.Link) + ); + + container.addSectionComponents(musicHeader); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // --- Commands 1-10 --- container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - "### 🎶 Command Browser\n" + - "-# 17 commands available" + `**1.** ${getCmd("play")}\n` + + `-# Play a song or add it to the queue\n\n` + + `**2.** ${getCmd("skip")}\n` + + `-# Skip the current track\n\n` + + `**3.** ${getCmd("stop")}\n` + + `-# Stop playback, clear queue & disconnect\n\n` + + `**4.** ${getCmd("nowplaying")}\n` + + `-# Show the currently playing track\n\n` + + `**5.** ${getCmd("seek")}\n` + + `-# Seek to a position in the track\n\n` + + `**6.** ${getCmd("queue")}\n` + + `-# View the current queue\n\n` + + `**7.** ${getCmd("remove")}\n` + + `-# Remove a track from the queue\n\n` + + `**8.** ${getCmd("move")}\n` + + `-# Move a track's position\n\n` + + `**9.** ${getCmd("shuffle")}\n` + + `-# Shuffle all tracks in the queue\n\n` + + `**10.** ${getCmd("loop")}\n` + + `-# Set loop mode for track or queue` ) ); - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + // --- Commands 11-17 --- container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - `**1.** ${getCmd("play")} — Play a song or add it to the queue\n\n` + - `**2.** ${getCmd("skip")} — Skip the current track\n\n` + - `**3.** ${getCmd("stop")} — Stop playback, clear queue & disconnect\n\n` + - `**4.** ${getCmd("nowplaying")} — Show the currently playing track\n\n` + - `**5.** ${getCmd("seek")} — Seek to a position\n\n` + - `**6.** ${getCmd("queue")} — View the current queue\n\n` + - `**7.** ${getCmd("remove")} — Remove a track from the queue\n\n` + - `**8.** ${getCmd("move")} — Move a track's position\n\n` + - `**9.** ${getCmd("shuffle")} — Shuffle all tracks in the queue\n\n` + - `**10.** ${getCmd("loop")} — Set loop mode for track or queue\n\n` + - `**11.** ${getCmd("volume")} — Set the playback volume\n\n` + - `**12.** ${getCmd("247")} — Toggle 24/7 mode\n\n` + - `**13.** ${getCmd("filter")} — Apply an audio filter preset\n\n` + - `**14.** ${getCmd("chatplay", "enable")} — Resume listening for song requests\n\n` + - `**15.** ${getCmd("chatplay", "disable")} — Pause listening (keeps message)\n\n` + - `**16.** ${getCmd("chatplay", "setup")} — Send the persistent player message\n\n` + - `**17.** ${getCmd("about")} — Learn more about Musicify` + `**11.** ${getCmd("volume")}\n` + + `-# Set the playback volume\n\n` + + `**12.** ${getCmd("247")}\n` + + `-# Toggle 24/7 mode\n\n` + + `**13.** ${getCmd("filter")}\n` + + `-# Apply an audio filter preset\n\n` + + `**14.** ${getCmd("chatplay", "enable")}\n` + + `-# Resume listening for song requests\n\n` + + `**15.** ${getCmd("chatplay", "disable")}\n` + + `-# Pause listening (keeps message)\n\n` + + `**16.** ${getCmd("chatplay", "setup")}\n` + + `-# Send the persistent player message\n\n` + + `**17.** ${getCmd("about")}\n` + + `-# Learn more about Musicify` ) ); } diff --git a/src/commands/loop.js b/src/commands/loop.js index 9fd6370..8ca49bc 100644 --- a/src/commands/loop.js +++ b/src/commands/loop.js @@ -26,9 +26,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/move.js b/src/commands/move.js index a76ea6e..7944910 100644 --- a/src/commands/move.js +++ b/src/commands/move.js @@ -28,9 +28,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/onboard.js b/src/commands/onboard.js new file mode 100644 index 0000000..3c6ec69 --- /dev/null +++ b/src/commands/onboard.js @@ -0,0 +1,148 @@ +const { + SlashCommandBuilder, + MessageFlags, + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SectionBuilder, + ThumbnailBuilder, + ActionRowBuilder, + ChannelSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + PermissionFlagsBits, +} = require("discord.js"); +const { getGuildData } = require("../utils/playerStore"); +const { createChatPlayIdleContainer } = require("../utils/components"); +const { setGuildSetting, deleteGuildSetting } = require("../utils/database"); + +/** + * Build the interactive onboard setup panel + */ +function buildOnboardPanel(client, guildData) { + const container = new ContainerBuilder(); + + const isSetUp = !!guildData.chatPlayChannelId; + + // --- Header --- + const header = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "## 🎵 Musicify Onboard\n" + + (isSetUp + ? "-# Your music setup is ready!" + : "-# Configure your server's music experience") + ) + ) + .setThumbnailAccessory( + new ThumbnailBuilder().setURL( + client.user.displayAvatarURL({ size: 128 }) + ) + ); + container.addSectionComponents(header); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // --- ChatPlay Channel Status --- + const chatPlayStatus = guildData.chatPlayChannelId + ? `✅ <#${guildData.chatPlayChannelId}>` + : "Not set — select a text channel below"; + const chatPlayActiveStatus = guildData.chatPlayEnabled ? " · 🟢 Active" : " · 🔴 Paused"; + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**ChatPlay Channel**\n" + + `-# ${chatPlayStatus}${guildData.chatPlayChannelId ? chatPlayActiveStatus : ""}` + ) + ); + + // Text channel select menu + const textSelect = new ChannelSelectMenuBuilder() + .setCustomId("onboard_text_select") + .setPlaceholder("💬 Select a text channel for ChatPlay") + .setChannelTypes(ChannelType.GuildText) + .setMinValues(1) + .setMaxValues(1); + + container.addActionRowComponents(new ActionRowBuilder().addComponents(textSelect)); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // --- Voice Channel Status --- + const vcStatus = guildData.defaultVoiceChannel + ? `✅ <#${guildData.defaultVoiceChannel}>` + : "Not set — select a voice channel below"; + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Voice Channel**\n" + + `-# ${vcStatus}` + ) + ); + + // Voice channel select menu + const voiceSelect = new ChannelSelectMenuBuilder() + .setCustomId("onboard_voice_select") + .setPlaceholder("🔊 Select a voice channel") + .setChannelTypes(ChannelType.GuildVoice, ChannelType.GuildStageVoice) + .setMinValues(1) + .setMaxValues(1); + + container.addActionRowComponents(new ActionRowBuilder().addComponents(voiceSelect)); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // --- 24/7 Stay + Actions --- + const stayEmoji = guildData.twentyFourSeven ? "✅" : "❌"; + const stayLabel = guildData.twentyFourSeven ? "Enabled" : "Disabled"; + const stayDesc = guildData.twentyFourSeven + ? "Bot stays in VC even when queue ends" + : "Bot leaves VC when queue ends"; + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `**24/7 Stay**: ${stayEmoji} ${stayLabel}\n` + + `-# ${stayDesc}` + ) + ); + + // Action buttons + const stayButton = new ButtonBuilder() + .setCustomId("onboard_247") + .setLabel(guildData.twentyFourSeven ? "Disable 24/7" : "Enable 24/7") + .setEmoji(guildData.twentyFourSeven ? "⏹" : "🔁") + .setStyle(guildData.twentyFourSeven ? ButtonStyle.Secondary : ButtonStyle.Primary); + + const resetButton = new ButtonBuilder() + .setCustomId("onboard_reset") + .setLabel("Reset Setup") + .setEmoji("🗑️") + .setStyle(ButtonStyle.Danger); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents(stayButton, resetButton) + ); + + return container; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("onboard") + .setDescription("Set up ChatPlay and configure your server's music experience") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + async execute(interaction, client) { + const guildData = getGuildData(interaction.guild.id); + const container = buildOnboardPanel(client, guildData); + + await interaction.reply({ + components: [container], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); + }, + + // Exported for use in buttonHandler + buildOnboardPanel, +}; diff --git a/src/commands/play.js b/src/commands/play.js index 81aa713..2d0b2c4 100644 --- a/src/commands/play.js +++ b/src/commands/play.js @@ -26,12 +26,20 @@ module.exports = { }); } + // Create or get player + let player = client.riffy.players.get(interaction.guild.id); + if (player && member.voice.channel.id !== player.voiceChannel) { + return interaction.reply({ + content: "❌ You need to be in the same voice channel as the bot!", + flags: MessageFlags.Ephemeral, + }); + } + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const guildData = getGuildData(interaction.guild.id); // Create or get player - let player = client.riffy.players.get(interaction.guild.id); if (!player) { player = client.riffy.createConnection({ guildId: interaction.guild.id, diff --git a/src/commands/remove.js b/src/commands/remove.js index 0364660..966c29b 100644 --- a/src/commands/remove.js +++ b/src/commands/remove.js @@ -21,9 +21,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/seek.js b/src/commands/seek.js index f9bf03f..2f9e662 100644 --- a/src/commands/seek.js +++ b/src/commands/seek.js @@ -21,9 +21,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/shuffle.js b/src/commands/shuffle.js index 316dc6d..bbd35d2 100644 --- a/src/commands/shuffle.js +++ b/src/commands/shuffle.js @@ -14,9 +14,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/skip.js b/src/commands/skip.js index 4b33987..1262320 100644 --- a/src/commands/skip.js +++ b/src/commands/skip.js @@ -14,9 +14,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/commands/stop.js b/src/commands/stop.js index 507f377..2639dc5 100644 --- a/src/commands/stop.js +++ b/src/commands/stop.js @@ -1,6 +1,7 @@ const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); -const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); +const { getGuildData, resetPlayerState } = require("../utils/playerStore"); +const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); +const { cleanupPlayerUI } = require("../utils/cleanup"); module.exports = { data: new SlashCommandBuilder() @@ -16,46 +17,29 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } - // Clean up guild state const guildData = getGuildData(interaction.guild.id); - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - guildData.suggestions = []; - guildData.previousTracks = []; + + // Set stopping flag to prevent queueEnd race condition + guildData.isStopping = true; + + // Clean up UI using shared utility + await cleanupPlayerUI(client, guildData); + + // Reset player state (timers, suggestions, history, etc.) + resetPlayerState(guildData); player.queue.clear(); player.stop(); if (guildData.twentyFourSeven) { - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - try { - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [createChatPlayIdleContainer()], - attachments: [], - flags: MessageFlags.IsComponentsV2, - }); - } - } catch (err) { - // message deleted - } - } else { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } - + guildData.isStopping = false; const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( @@ -70,9 +54,8 @@ module.exports = { }); } - guildData.playerMessageId = null; - guildData.playerChannelId = null; player.destroy(); + guildData.isStopping = false; const container = new ContainerBuilder(); container.addTextDisplayComponents( diff --git a/src/commands/volume.js b/src/commands/volume.js index 1a38efd..44016d0 100644 --- a/src/commands/volume.js +++ b/src/commands/volume.js @@ -23,9 +23,10 @@ module.exports = { }); } - if (!interaction.member.voice?.channel) { + const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); + if (!canControlMusic(interaction.member, player)) { return interaction.reply({ - content: "❌ You need to be in a voice channel!", + content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral, }); } diff --git a/src/events/ready.js b/src/events/ready.js index 00b00f3..d9c17ca 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -108,19 +108,44 @@ module.exports = { if (settings.twentyFourSeven) { guildData.twentyFourSeven = true; } + + // Restore default voice channel + if (settings.defaultVoiceChannel) { + guildData.defaultVoiceChannel = settings.defaultVoiceChannel; + } } - if (restoredCount > 0) { - console.log(`[Musicify] Restored ChatPlay for ${restoredCount} guild(s) from database.`); + if (restoredCount > 0 || Object.values(db).some(s => s.defaultVoiceChannel)) { + console.log(`[Musicify] Restored ChatPlay / Onboard settings from database.`); if (invalidMessages > 0) { console.log(`[Musicify] Recreated ${invalidMessages} invalid ChatPlay message(s).`); } // Update existing ChatPlay messages if bot was restarted during playback + // and auto-join predefined voice channels if set setTimeout(async () => { for (const [guildId, settings] of Object.entries(db)) { + const guildData = getGuildData(guildId); + + // Auto-join default voice channel if configured + if (guildData.defaultVoiceChannel) { + try { + const player = client.riffy.players.get(guildId); + if (!player) { + client.riffy.createConnection({ + guildId: guildId, + voiceChannel: guildData.defaultVoiceChannel, + textChannel: guildData.chatPlayChannelId || settings.chatPlayChannelId || guild.channels.cache.filter(c => c.type === 0).first()?.id, + deaf: true, + }); + console.log(`[Musicify] Auto-joined default VC <#${guildData.defaultVoiceChannel}> in guild ${guildId}`); + } + } catch (err) { + console.error(`[Musicify] Failed to auto-join VC in guild ${guildId}:`, err.message); + } + } + if (settings.chatPlayChannelId && settings.chatPlayEnabled) { - const guildData = getGuildData(guildId); const player = client.riffy.players.get(guildId); // If player exists and is playing, update the ChatPlay message @@ -156,7 +181,7 @@ module.exports = { } } } - }, 2000); // Wait 2 seconds for Lavalink to be fully connected + }, 3000); // Wait 3 seconds for Lavalink to be fully connected } } catch (err) { console.error("[Musicify] Failed to restore from database:", err.message); diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index de20c7e..4608647 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -1,6 +1,5 @@ -const { getGuildData, deleteGuildData } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); -const { MessageFlags } = require("discord.js"); +const { getGuildData, resetPlayerState } = require("../utils/playerStore"); +const { cleanupPlayerUI } = require("../utils/cleanup"); module.exports = { name: "voiceStateUpdate", @@ -9,7 +8,9 @@ module.exports = { if (oldState.id === client.user.id && !newState.channelId) { const player = client.riffy.players.get(oldState.guild.id); if (player) { - await resetChatPlayIfActive(client, oldState.guild.id); + const guildData = getGuildData(oldState.guild.id); + await cleanupPlayerUI(client, guildData); + resetPlayerState(guildData); player.destroy(); } return; @@ -45,7 +46,8 @@ module.exports = { oldState.guild.id ); if (player) { - await resetChatPlayIfActive(client, oldState.guild.id); + await cleanupPlayerUI(client, currentGuildData); + resetPlayerState(currentGuildData); player.destroy(); } } @@ -56,35 +58,3 @@ module.exports = { } }, }; - -async function resetChatPlayIfActive(client, guildId) { - const guildData = getGuildData(guildId); - // Reset ChatPlay to idle - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - try { - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [createChatPlayIdleContainer()], - attachments: [], - flags: MessageFlags.IsComponentsV2, - }); - } - } catch (err) { - // message may have been deleted - } - } - // Delete regular /play player message - else 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 (err) { - // message already deleted - } - } -} diff --git a/src/handlers/buttonHandler.js b/src/handlers/buttonHandler.js index f05fd18..b8baad6 100644 --- a/src/handlers/buttonHandler.js +++ b/src/handlers/buttonHandler.js @@ -1,11 +1,38 @@ -const { MessageFlags, AttachmentBuilder } = require("discord.js"); -const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); +const { MessageFlags, AttachmentBuilder, ChannelType } = require("discord.js"); +const { getGuildData, clearUpdateInterval, resetPlayerState } = require("../utils/playerStore"); const { createNowPlayingContainer, createChatPlayNowPlayingContainer, createQueueContainer, createChatPlayIdleContainer } = require("../utils/components"); const { generateMusicCard } = require("../utils/musicard"); const { addNodeDetails } = require("../utils/nodeDetails"); const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); +const { cleanupPlayerUI } = require("../utils/cleanup"); +const { setGuildSetting, deleteGuildSetting } = require("../utils/database"); const config = require("../../config"); +/** + * Cache for application command IDs — populated once, reused everywhere. + */ +let cachedCommands = null; +let commandCacheExpiry = 0; + +async function getCachedCommands(client) { + const now = Date.now(); + // Re-fetch every 30 minutes + if (cachedCommands && now < commandCacheExpiry) return cachedCommands; + try { + cachedCommands = await client.application.commands.fetch(); + commandCacheExpiry = now + 30 * 60 * 1000; + } catch (e) { + cachedCommands = null; + } + return cachedCommands; +} + +function getCmd(commands, name, subcommand = null) { + const cmd = commands?.find(c => c.name === name); + if (!cmd) return subcommand ? `\`/${name} ${subcommand}\`` : `\`/${name}\``; + return subcommand ? `` : ``; +} + /** * Handle all button and select menu interactions from the player container */ @@ -15,19 +42,9 @@ async function handleButtonInteraction(client, interaction) { return; } - // Fetch commands for IDs - let commands; - try { - commands = await client.application.commands.fetch(); - } catch (e) { - commands = null; - } - - const getCmd = (name, subcommand = null) => { - const cmd = commands?.find(c => c.name === name); - if (!cmd) return subcommand ? `\`/${name} ${subcommand}\`` : `\`/${name}\``; - return subcommand ? `` : ``; - }; + const commands = await getCachedCommands(client); + const cmd = (name, sub) => getCmd(commands, name, sub); + const guildId = interaction.guild.id; let player = client.riffy.players.get(guildId); const guildData = getGuildData(guildId); @@ -43,7 +60,6 @@ async function handleButtonInteraction(client, interaction) { textChannel: guildData.chatPlayChannelId, deaf: true, }); - // Restore volume player.setVolume(guildData.volume); console.log(`[Musicify] Recreated player for guild ${guildId} after restart`); } catch (err) { @@ -52,18 +68,203 @@ async function handleButtonInteraction(client, interaction) { } } - // Handle node stats dropdown - show dedicated node details view + // ─── Onboard: Text Channel Select ───────────────────────────── + if (interaction.isChannelSelectMenu() && interaction.customId === "onboard_text_select") { + await interaction.deferUpdate(); + + const selectedChannelId = interaction.values[0]; + + // Delete old ChatPlay message if it exists + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + try { + const oldChannel = client.channels.cache.get(guildData.chatPlayChannelId); + if (oldChannel) { + const oldMsg = await oldChannel.messages.fetch(guildData.chatPlayMessageId); + await oldMsg.delete(); + } + } catch (err) { /* ignore */ } + } + + // Send new ChatPlay idle message in selected channel + const selectedChannel = client.channels.cache.get(selectedChannelId); + if (selectedChannel) { + try { + const idleContainer = createChatPlayIdleContainer(); + const chatMsg = await selectedChannel.send({ + components: [idleContainer], + flags: MessageFlags.IsComponentsV2, + }); + + // Set slowmode + try { + await selectedChannel.setRateLimitPerUser(5, "ChatPlay setup - prevents spam"); + } catch (err) { /* may lack permission */ } + + guildData.chatPlayChannelId = selectedChannelId; + guildData.chatPlayMessageId = chatMsg.id; + guildData.chatPlayEnabled = true; + guildData.playerChannelId = selectedChannelId; + + setGuildSetting(guildId, "chatPlayChannelId", selectedChannelId); + setGuildSetting(guildId, "chatPlayMessageId", chatMsg.id); + setGuildSetting(guildId, "chatPlayEnabled", true); + } catch (err) { + console.error("[Musicify] Onboard text setup error:", err.message); + } + } + + // Refresh the panel + const { buildOnboardPanel } = require("../commands/onboard"); + try { + await interaction.editReply({ + components: [buildOnboardPanel(client, guildData)], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { /* ignore */ } + return; + } + + // ─── Onboard: Voice Channel Select ──────────────────────────── + if (interaction.isChannelSelectMenu() && interaction.customId === "onboard_voice_select") { + await interaction.deferUpdate(); + + const selectedVcId = interaction.values[0]; + guildData.defaultVoiceChannel = selectedVcId; + setGuildSetting(guildId, "defaultVoiceChannel", selectedVcId); + + // If 24/7 is enabled, join the VC immediately + if (guildData.twentyFourSeven) { + try { + const textChannel = guildData.chatPlayChannelId || interaction.channel.id; + let existingPlayer = client.riffy.players.get(guildId); + if (!existingPlayer) { + client.riffy.createConnection({ + guildId: guildId, + voiceChannel: selectedVcId, + textChannel: textChannel, + deaf: true, + }); + } + } catch (err) { + console.error("[Musicify] Failed to join VC on onboard:", err.message); + } + } + + // Refresh the panel + const { buildOnboardPanel } = require("../commands/onboard"); + try { + await interaction.editReply({ + components: [buildOnboardPanel(client, guildData)], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { /* ignore */ } + return; + } + + // ─── Onboard: 24/7 Toggle ───────────────────────────────────── + if (interaction.isButton() && interaction.customId === "onboard_247") { + await interaction.deferUpdate(); + + const newState = !guildData.twentyFourSeven; + guildData.twentyFourSeven = newState; + setGuildSetting(guildId, "twentyFourSeven", newState); + + if (newState && guildData.defaultVoiceChannel) { + // Join the VC + try { + let existingPlayer = client.riffy.players.get(guildId); + if (!existingPlayer) { + const textChannel = guildData.chatPlayChannelId || interaction.channel.id; + client.riffy.createConnection({ + guildId: guildId, + voiceChannel: guildData.defaultVoiceChannel, + textChannel: textChannel, + deaf: true, + }); + } + } catch (err) { + console.error("[Musicify] Failed to join VC on 24/7 enable:", err.message); + } + } else if (!newState) { + // If not playing anything, disconnect + const existingPlayer = client.riffy.players.get(guildId); + if (existingPlayer && !existingPlayer.playing && !existingPlayer.paused) { + existingPlayer.destroy(); + } + } + + // Refresh the panel + const { buildOnboardPanel } = require("../commands/onboard"); + try { + await interaction.editReply({ + components: [buildOnboardPanel(client, guildData)], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { /* ignore */ } + return; + } + + // ─── Onboard: Reset ─────────────────────────────────────────── + if (interaction.isButton() && interaction.customId === "onboard_reset") { + await interaction.deferUpdate(); + + // Delete ChatPlay message + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (channel) { + const msg = await channel.messages.fetch(guildData.chatPlayMessageId); + await msg.delete(); + } + } catch (err) { /* ignore */ } + + // Remove slowmode + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (channel) await channel.setRateLimitPerUser(0, "ChatPlay removed"); + } catch (err) { /* ignore */ } + } + + // Clear state + guildData.chatPlayChannelId = null; + guildData.chatPlayMessageId = null; + guildData.chatPlayEnabled = false; + guildData.defaultVoiceChannel = null; + guildData.twentyFourSeven = false; + + // Clear database + deleteGuildSetting(guildId, "chatPlayChannelId"); + deleteGuildSetting(guildId, "chatPlayMessageId"); + deleteGuildSetting(guildId, "chatPlayEnabled"); + deleteGuildSetting(guildId, "defaultVoiceChannel"); + deleteGuildSetting(guildId, "twentyFourSeven"); + + // Disconnect if not playing + const existingPlayer = client.riffy.players.get(guildId); + if (existingPlayer && !existingPlayer.playing && !existingPlayer.paused) { + existingPlayer.destroy(); + } + + // Refresh the panel + const { buildOnboardPanel } = require("../commands/onboard"); + try { + await interaction.editReply({ + components: [buildOnboardPanel(client, guildData)], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { /* ignore */ } + return; + } + + // ─── Node Stats Dropdown ────────────────────────────────────── if (interaction.isStringSelectMenu() && interaction.customId === "node_stats_select") { await interaction.deferUpdate(); - const selectedValue = interaction.values[0]; // "node_0", "node_1", etc + const selectedValue = interaction.values[0]; const nodeIndex = parseInt(selectedValue.replace("node_", ""), 10); - - // Get configured node from config (source of truth) const configNode = config.nodes[nodeIndex]; if (!configNode) return; - // Find connected node if available const nodes = client.riffy.nodeMap; const nodeList = Array.isArray(nodes) ? nodes @@ -77,13 +278,9 @@ async function handleButtonInteraction(client, interaction) { const statusEmoji = connected ? "🟢" : "🔴"; const statusText = connected ? "Connected" : "Disconnected"; - const container = new ContainerBuilder(); - - // Use generic name in header const displayName = nodeIndex === 0 ? "Main Node" : `Node ${nodeIndex}`; - // Node header container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `## ${statusEmoji} ${displayName}\n` + @@ -95,9 +292,7 @@ async function handleButtonInteraction(client, interaction) { if (!connected || !connectedNode?.stats) { container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "-# *Node is offline — no stats available.*" - ) + new TextDisplayBuilder().setContent("-# *Node is offline — no stats available.*") ); } else { const stats = connectedNode.stats; @@ -118,7 +313,6 @@ async function handleButtonInteraction(client, interaction) { container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); - // Back button const backButton = new ButtonBuilder() .setCustomId("status_back") .setEmoji("⬅️") @@ -137,107 +331,12 @@ async function handleButtonInteraction(client, interaction) { return; } - // Handle status back button - return to main status view + // ─── Status Back Button ─────────────────────────────────────── if (interaction.isButton() && interaction.customId === "status_back") { await interaction.deferUpdate(); - const { ContainerBuilder, TextDisplayBuilder, SeparatorBuilder, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); - const { formatIncidents } = require("../utils/incidents"); - - const nodes = client.riffy.nodeMap; - const nodeList = Array.isArray(nodes) - ? nodes - : nodes instanceof Map - ? [...nodes.values()] - : Object.values(nodes || {}); - - const connectedNodes = nodeList.filter(n => n.connected || n.isConnected).length; - const totalNodes = config.nodes.length; - const botPing = client.ws?.ping ?? 0; - const uptimeSeconds = process.uptime(); - const startTime = new Date(Date.now() - uptimeSeconds * 1000); - - let statusEmoji = "🟢"; - let statusText = "All systems operational"; - if (connectedNodes === 0 || botPing > 300) { - statusEmoji = "🔴"; - statusText = "Major system issues detected"; - } else if (connectedNodes < totalNodes || botPing > 100) { - statusEmoji = "🟡"; - statusText = "Some systems experiencing issues"; - } - - const container = new ContainerBuilder(); - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(`## ${statusEmoji} ${statusText}`) - ); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `**Recent Incidents**\n` + - formatIncidents(4) - ) - ); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); - - const supportButton = new ButtonBuilder() - .setLabel("Known Outages") - .setURL("https://discord.gg/MRjEUhDCpZ") - .setStyle(ButtonStyle.Link); - - const voteButton = new ButtonBuilder() - .setLabel("⭐ Vote") - .setURL("https://top.gg/bot/1502977716196999309/vote") - .setStyle(ButtonStyle.Link); - - container.addActionRowComponents(new ActionRowBuilder().addComponents(supportButton, voteButton)); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); - - const startTimestamp = Math.floor(startTime.getTime() / 1000); - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `**Uptime**\n` + - `-# 🕒 ()\n` + - `-# *Times shown in your local timezone*` - ) - ); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### Lavalink Node Stats\n" + - `-# ${connectedNodes}/${totalNodes} nodes available` - ) - ); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId("node_stats_select") - .setPlaceholder("📡 Select a node") - .setMinValues(1) - .setMaxValues(1); - - for (let i = 0; i < config.nodes.length; i++) { - const configNode = config.nodes[i]; - const connectedNode = nodeList.find(n => n.name === configNode.name); - const connected = connectedNode?.connected || connectedNode?.isConnected || false; - const nodeStatusEmoji = connected ? "🟢" : "🔴"; - const displayName = i === 0 ? "Main Node" : `Node ${i}`; - selectMenu.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel(displayName) - .setDescription(`${nodeStatusEmoji} ${connected ? "Connected" : "Disconnected"}`) - .setValue(`node_${i}`) - ); - } - - container.addActionRowComponents(new ActionRowBuilder().addComponents(selectMenu)); + const { buildStatusContainer } = require("../utils/statusPage"); + const container = buildStatusContainer(client); try { await interaction.editReply({ @@ -250,7 +349,7 @@ async function handleButtonInteraction(client, interaction) { return; } - // Handle help dropdown navigation + // ─── Help Dropdown Navigation ───────────────────────────────── if (interaction.isStringSelectMenu() && interaction.customId === "help_select") { await interaction.deferUpdate(); @@ -269,10 +368,10 @@ async function handleButtonInteraction(client, interaction) { return; } - // Handle song suggestion select menu + // ─── Song Suggestion Select Menu ────────────────────────────── if (interaction.isStringSelectMenu() && interaction.customId === "song_suggestion") { if (!player) { - return interaction.reply({ content: `❌ No music playing. Start with ${getCmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); + return interaction.reply({ content: `❌ No music playing. Start with ${cmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); } if (!canControlMusic(interaction.member, player)) { return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); @@ -294,7 +393,7 @@ async function handleButtonInteraction(client, interaction) { return; } - // Handle buttons + // ─── Buttons ────────────────────────────────────────────────── if (!interaction.isButton()) return; const customId = interaction.customId; @@ -302,18 +401,14 @@ async function handleButtonInteraction(client, interaction) { // Queue button opens an ephemeral reply if (customId === "queue") { if (!player || !player.current) { - return interaction.reply({ content: `❌ No music playing. Start with ${getCmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); + return interaction.reply({ content: `❌ No music playing. Start with ${cmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); } if (!canControlMusic(interaction.member, player)) { return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); } if (!guildData.queuePages) guildData.queuePages = new Map(); guildData.queuePages.set(interaction.user.id, 0); - const queueContainer = createQueueContainer( - player.queue, - player.current, - 0 - ); + const queueContainer = createQueueContainer(player.queue, player.current, 0); return interaction.reply({ components: [queueContainer], flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, @@ -323,7 +418,7 @@ async function handleButtonInteraction(client, interaction) { // Queue pagination buttons if (customId.startsWith("queue_") && customId !== "queue") { if (!player || !player.current) { - return interaction.reply({ content: `❌ No music playing. Start with ${getCmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); + return interaction.reply({ content: `❌ No music playing. Start with ${cmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); } if (!canControlMusic(interaction.member, player)) { return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); @@ -338,27 +433,15 @@ async function handleButtonInteraction(client, interaction) { let currentPage = guildData.queuePages.get(interaction.user.id) || 0; switch (customId) { - case "queue_first": - currentPage = 0; - break; - case "queue_prev": - currentPage = Math.max(0, currentPage - 1); - break; - case "queue_next": - currentPage = Math.min(totalPages - 1, currentPage + 1); - break; - case "queue_last": - currentPage = totalPages - 1; - break; + case "queue_first": currentPage = 0; break; + case "queue_prev": currentPage = Math.max(0, currentPage - 1); break; + case "queue_next": currentPage = Math.min(totalPages - 1, currentPage + 1); break; + case "queue_last": currentPage = totalPages - 1; break; } guildData.queuePages.set(interaction.user.id, currentPage); - const queueContainer = createQueueContainer( - player.queue, - player.current, - currentPage - ); + const queueContainer = createQueueContainer(player.queue, player.current, currentPage); try { await interaction.editReply({ @@ -371,10 +454,10 @@ async function handleButtonInteraction(client, interaction) { return; } - // Most buttons need an active player — send ephemeral if not + // Most buttons need an active player const needsPlayer = ["pause_resume", "skip", "previous", "stop", "shuffle", "loop", "autoplay", "vol_up", "vol_down"]; if (needsPlayer.includes(customId) && !player) { - return interaction.reply({ content: `❌ No music playing. Start with ${getCmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); + return interaction.reply({ content: `❌ No music playing. Start with ${cmd("play")} or ChatPlay!`, flags: MessageFlags.Ephemeral }); } if (needsPlayer.includes(customId) && !canControlMusic(interaction.member, player)) { @@ -390,8 +473,10 @@ async function handleButtonInteraction(client, interaction) { case "pause_resume": { if (player.paused) { player.pause(false); + guildData.manuallyPaused = false; } else { player.pause(true); + guildData.manuallyPaused = true; } needsVisualUpdate = true; break; @@ -420,7 +505,6 @@ async function handleButtonInteraction(client, interaction) { const isChatPlay = guildData.chatPlayChannelId && guildData.chatPlayMessageId; if (isChatPlay && queueLength >= 5 && !guildData.stopConfirmPending) { guildData.stopConfirmPending = interaction.user.id; - // Clear confirmation after 15 seconds setTimeout(() => { if (guildData.stopConfirmPending === interaction.user.id) { guildData.stopConfirmPending = null; @@ -433,52 +517,25 @@ async function handleButtonInteraction(client, interaction) { } guildData.stopConfirmPending = null; - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - guildData.suggestions = []; - guildData.previousTracks = []; + // Set stopping flag to prevent queueEnd race condition + guildData.isStopping = true; - // If ChatPlay, edit message back to idle state - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - try { - const container = createChatPlayIdleContainer(); - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [container], - attachments: [], - flags: MessageFlags.IsComponentsV2, - }); - } - } catch (err) { - console.error("[Musicify] Failed to edit ChatPlay message on stop:", err.message); - } - } else 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 (err) { - // message already deleted - } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } + // Clean up UI + await cleanupPlayerUI(client, guildData); + + // Reset player state (timers, suggestions, etc.) + resetPlayerState(guildData); player.queue.clear(); player.stop(); if (guildData.twentyFourSeven) { + guildData.isStopping = false; return; } player.destroy(); + guildData.isStopping = false; return; } @@ -486,6 +543,8 @@ async function handleButtonInteraction(client, interaction) { if (player.queue.length > 0) { player.queue.shuffle(); guildData.shuffle = true; + // Reset shuffle visual after 3 seconds + setTimeout(() => { guildData.shuffle = false; }, 3000); } needsVisualUpdate = true; break; @@ -519,7 +578,6 @@ async function handleButtonInteraction(client, interaction) { break; } - case "vol_up": { guildData.volume = Math.min(100, guildData.volume + 10); player.setVolume(guildData.volume); diff --git a/src/handlers/chatPlayHandler.js b/src/handlers/chatPlayHandler.js index 7b2d788..fb5ac72 100644 --- a/src/handlers/chatPlayHandler.js +++ b/src/handlers/chatPlayHandler.js @@ -54,7 +54,11 @@ async function handleChatPlayMessage(client, message) { } // Check if the user is in a voice channel - const voiceChannel = message.member?.voice?.channel; + let voiceChannel = message.member?.voice?.channel; + if (!voiceChannel && guildData.defaultVoiceChannel) { + voiceChannel = client.channels.cache.get(guildData.defaultVoiceChannel); + } + if (!voiceChannel) { try { const warn = await message.channel.send({ diff --git a/src/handlers/playerHandler.js b/src/handlers/playerHandler.js index ee3e1e4..b2e56c3 100644 --- a/src/handlers/playerHandler.js +++ b/src/handlers/playerHandler.js @@ -1,9 +1,10 @@ const { MessageFlags, AttachmentBuilder, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); -const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); +const { getGuildData, clearUpdateInterval, clearVoiceMonitor, resetPlayerState } = require("../utils/playerStore"); const { createNowPlayingContainer, createChatPlayIdleContainer, createChatPlayNowPlayingContainer } = require("../utils/components"); const { generateMusicCard } = require("../utils/musicard"); const { recordIncident } = require("../utils/incidents"); const { scheduleStatusUpdate } = require("../services/statusMonitor"); +const { cleanupPlayerUI } = require("../utils/cleanup"); const config = require("../../config"); const UPDATE_INTERVAL_MS = 15 * 1000; // 15 seconds @@ -248,8 +249,14 @@ function setupPlayerHandler(client) { try { const guildData = getGuildData(player.guildId); + if (guildData.isStopping) { + return; + } + // Stop the auto-update interval clearUpdateInterval(guildData); + // Stop voice channel monitoring on queue end + stopVoiceChannelMonitoring(player.guildId); if (guildData.autoplay) { player.autoplay(player); @@ -259,36 +266,8 @@ function setupPlayerHandler(client) { // 24/7 mode: stay in VC, just update the message const stayInVC = guildData.twentyFourSeven; - // If this is a ChatPlay session, edit the message to idle state - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - const container = createChatPlayIdleContainer(); - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel) { - try { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [container], - attachments: [], - flags: MessageFlags.IsComponentsV2, - }); - } catch (err) { - // message deleted - } - } - } else if (guildData.playerMessageId && guildData.playerChannelId) { - // For regular /play: delete the old message - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch (err) { - // message already deleted - } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } + // Clean up player UI using shared utility + await cleanupPlayerUI(client, guildData); // If NOT 24/7, disconnect after a delay if (!stayInVC) { @@ -319,46 +298,12 @@ function setupPlayerHandler(client) { // --- Player Disconnect --- client.riffy.on("playerDisconnect", async (player) => { const guildData = getGuildData(player.guildId); - clearUpdateInterval(guildData); - stopVoiceChannelMonitoring(player.guildId); - // Reset ChatPlay to idle if active (safety net for force disconnects) - if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { - try { - const { createChatPlayIdleContainer } = require("../utils/components"); - const { MessageFlags } = require("discord.js"); - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [createChatPlayIdleContainer()], - attachments: [], - flags: MessageFlags.IsComponentsV2, - }); - } - } catch (err) { - // message may have been deleted - } - } - // Delete regular player message if it exists (normal /play sessions) - else 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 (err) { - // message already deleted - } - } + // Clean up UI + await cleanupPlayerUI(client, guildData); - guildData.playerMessageId = null; - guildData.playerChannelId = null; - guildData.suggestions = []; - guildData.previousTracks = []; - if (guildData.idleTimeout) clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; + // Reset player state (timers, suggestions, history, etc.) + resetPlayerState(guildData); }); // --- Track Error / Stuck --- @@ -391,13 +336,13 @@ function setupPlayerHandler(client) { function startVoiceChannelMonitoring(client, guildId) { const guildData = getGuildData(guildId); - // Clear existing timeout - if (guildData.voiceStateTimeout) { - clearTimeout(guildData.voiceStateTimeout); + // Clear existing interval + if (guildData.voiceMonitorInterval) { + clearInterval(guildData.voiceMonitorInterval); } // Check voice channel state every 5 seconds - guildData.voiceStateTimeout = setInterval(() => { + guildData.voiceMonitorInterval = setInterval(() => { checkVoiceChannelState(client, guildId); }, 5000); } @@ -435,8 +380,8 @@ function checkVoiceChannelState(client, guildId) { } } - // Auto-resume when users rejoin - if (hasUsers && guildData.wasPaused && player.paused) { + // Auto-resume when users rejoin (only if NOT manually paused by a user) + if (hasUsers && guildData.wasPaused && player.paused && !guildData.manuallyPaused) { player.pause(false); guildData.wasPaused = false; @@ -459,9 +404,9 @@ function checkVoiceChannelState(client, guildId) { */ function stopVoiceChannelMonitoring(guildId) { const guildData = getGuildData(guildId); - if (guildData.voiceStateTimeout) { - clearTimeout(guildData.voiceStateTimeout); - guildData.voiceStateTimeout = null; + if (guildData.voiceMonitorInterval) { + clearInterval(guildData.voiceMonitorInterval); + guildData.voiceMonitorInterval = null; } } diff --git a/src/utils/cleanup.js b/src/utils/cleanup.js new file mode 100644 index 0000000..fdc3386 --- /dev/null +++ b/src/utils/cleanup.js @@ -0,0 +1,54 @@ +const { MessageFlags } = require("discord.js"); +const { createChatPlayIdleContainer } = require("./components"); + +/** + * Reset the ChatPlay persistent message back to idle state. + * Safe to call even if the message was already deleted. + */ +async function resetChatPlayToIdle(client, guildData) { + if (!guildData.chatPlayChannelId || !guildData.chatPlayMessageId) return; + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (channel) { + const msg = await channel.messages.fetch(guildData.chatPlayMessageId); + await msg.edit({ + components: [createChatPlayIdleContainer()], + attachments: [], + flags: MessageFlags.IsComponentsV2, + }); + } + } catch (err) { + // message may have been deleted + } +} + +/** + * Delete the regular /play player message and clear stale IDs. + */ +async function deletePlayerMessage(client, guildData) { + if (!guildData.playerMessageId || !guildData.playerChannelId) return; + try { + const channel = client.channels.cache.get(guildData.playerChannelId); + if (channel) { + const msg = await channel.messages.fetch(guildData.playerMessageId); + await msg.delete(); + } + } catch (err) { + // message already deleted + } + guildData.playerMessageId = null; + guildData.playerChannelId = null; +} + +/** + * Clean up the player UI — resets ChatPlay to idle OR deletes /play message. + */ +async function cleanupPlayerUI(client, guildData) { + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + await resetChatPlayToIdle(client, guildData); + } else { + await deletePlayerMessage(client, guildData); + } +} + +module.exports = { resetChatPlayToIdle, deletePlayerMessage, cleanupPlayerUI }; diff --git a/src/utils/playerStore.js b/src/utils/playerStore.js index d14710d..d0f4383 100644 --- a/src/utils/playerStore.js +++ b/src/utils/playerStore.js @@ -9,6 +9,7 @@ class GuildData { this.chatPlayChannelId = null; this.chatPlayMessageId = null; this.chatPlayEnabled = false; + this.defaultVoiceChannel = null; // Predefined VC from /onboard this.autoplay = false; this.loop = "none"; // "none" | "track" | "queue" this.volume = 75; @@ -19,8 +20,10 @@ class GuildData { this.queuePages = new Map(); // per-user queue page state this.updateInterval = null; // 15s musicard auto-update timer this.idleTimeout = null; // 30s disconnect timeout - this.voiceStateTimeout = null; // voice channel monitoring timeout - this.wasPaused = false; // track if music was paused due to empty channel + this.voiceMonitorInterval = null; // voice channel monitoring interval + this.manuallyPaused = false; // true when user explicitly paused (not auto-paused) + this.isStopping = false; // prevents queueEnd race condition during explicit stop + this.stopConfirmPending = null; } } @@ -31,6 +34,33 @@ function clearUpdateInterval(guildData) { } } +function clearVoiceMonitor(guildData) { + if (guildData.voiceMonitorInterval) { + clearInterval(guildData.voiceMonitorInterval); + guildData.voiceMonitorInterval = null; + } +} + +/** + * Reset all transient player state (timers, suggestions, history). + * Does NOT clear chatplay/onboard config or 24/7 settings. + */ +function resetPlayerState(guildData) { + clearUpdateInterval(guildData); + clearVoiceMonitor(guildData); + if (guildData.idleTimeout) { + clearTimeout(guildData.idleTimeout); + guildData.idleTimeout = null; + } + guildData.suggestions = []; + guildData.previousTracks = []; + guildData.queuePages = new Map(); + guildData.shuffle = false; + guildData.manuallyPaused = false; + guildData.isStopping = false; + guildData.stopConfirmPending = null; +} + const guildStore = new Map(); function getGuildData(guildId) { @@ -44,4 +74,11 @@ function deleteGuildData(guildId) { guildStore.delete(guildId); } -module.exports = { getGuildData, deleteGuildData, clearUpdateInterval, GuildData }; +module.exports = { + getGuildData, + deleteGuildData, + clearUpdateInterval, + clearVoiceMonitor, + resetPlayerState, + GuildData, +}; diff --git a/src/utils/statusPage.js b/src/utils/statusPage.js index 9ee7436..de98609 100644 --- a/src/utils/statusPage.js +++ b/src/utils/statusPage.js @@ -2,6 +2,7 @@ const { ContainerBuilder, TextDisplayBuilder, SeparatorBuilder, + SectionBuilder, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, @@ -65,64 +66,57 @@ function buildStatusContainer(client, { interactive = true, showSupportButton = const container = new ContainerBuilder(); + // --- Status header --- container.addTextDisplayComponents( new TextDisplayBuilder().setContent(`## ${statusEmoji} ${statusText}`) ); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `**Recent Incidents**\n` + formatIncidents(4) + // --- Incidents section with Support/Report button accessory --- + const incidentsSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `**Recent Incidents**\n` + formatIncidents(4) + ) ) - ); + .setButtonAccessory( + showSupportButton + ? new ButtonBuilder() + .setLabel("Support") + .setURL("https://discord.gg/MRjEUhDCpZ") + .setStyle(ButtonStyle.Link) + : new ButtonBuilder() + .setLabel("🐛 Report") + .setURL("https://github.com/codebymitch/Musicify/issues") + .setStyle(ButtonStyle.Link) + ); - container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); - - const linkButtons = showSupportButton - ? [ - new ButtonBuilder() - .setLabel("Support Server") - .setURL("https://discord.gg/MRjEUhDCpZ") - .setStyle(ButtonStyle.Link), - new ButtonBuilder() - .setLabel("Vote") - .setEmoji("⭐") - .setURL("https://top.gg/bot/1502977716196999309/vote") - .setStyle(ButtonStyle.Link), - ] - : [ - new ButtonBuilder() - .setLabel("Report Issue") - .setEmoji("🐛") - .setURL("https://github.com/codebymitch/Musicify/issues") - .setStyle(ButtonStyle.Link), - new ButtonBuilder() - .setLabel("Vote") - .setEmoji("⭐") - .setURL("https://top.gg/bot/1502977716196999309/vote") - .setStyle(ButtonStyle.Link), - new ButtonBuilder() - .setLabel("Suggest") - .setEmoji("💡") - .setURL("https://discord.com/channels/1503210009251545152/1503721044291092480") - .setStyle(ButtonStyle.Link), - ]; - - container.addActionRowComponents(new ActionRowBuilder().addComponents(...linkButtons)); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + container.addSectionComponents(incidentsSection); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `**Uptime**\n` + - `-# 🕒 ()\n` + - `-# *Times shown in your local timezone*` + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + // --- Uptime section with Vote button accessory --- + const uptimeSection = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `**Uptime**\n` + + `-# 🕒 ()\n` + + `-# *Times shown in your local timezone*` + ) ) - ); + .setButtonAccessory( + new ButtonBuilder() + .setLabel("⭐ Vote") + .setURL("https://top.gg/bot/1502977716196999309/vote") + .setStyle(ButtonStyle.Link) + ); + + container.addSectionComponents(uptimeSection); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + // --- Lavalink Node Stats --- container.addTextDisplayComponents( new TextDisplayBuilder().setContent( "### Lavalink Node Stats\n" +