diff --git a/.env.example b/.env.example index f7346be..767bdcf 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -BOT_TOKEN=your_bot_token_here -CLIENT_ID=your_client_id_here -STATUS_WEBHOOK_URL=your_status_webhook_url_here +BOT_TOKEN=your_discord_bot_token_here +CLIENT_ID=your_discord_client_id_here +STATUS_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token diff --git a/.gitignore b/.gitignore index 955f8bb..2578ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules/ .env data/ +data/*.db +data/*.db-wal +data/*.db-shm MESSAGE_CONTENT_INTENT.md config.js diff --git a/package-lock.json b/package-lock.json index 78eaaaa..795d0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "musicify", "version": "1.0.0", "dependencies": { + "bottleneck": "^2.19.5", "discord.js": "14.25.1", "dotenv": "^16.4.7", "musicard": "latest", @@ -605,6 +606,11 @@ "node": ">= 14" } }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" + }, "node_modules/cropify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/cropify/-/cropify-2.0.2.tgz", @@ -1057,10 +1063,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 1de3ac8..e6a0a16 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,12 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "deploy": "node src/deploy-commands.js" + "deploy": "node src/deploy-commands.js", + "backup": "node scripts/backup-db.js", + "restore-db": "node scripts/restore-db.js" }, "dependencies": { + "bottleneck": "^2.19.5", "discord.js": "14.25.1", "dotenv": "^16.4.7", "musicard": "latest", diff --git a/scripts/backup-db.js b/scripts/backup-db.js new file mode 100644 index 0000000..fa7e19a --- /dev/null +++ b/scripts/backup-db.js @@ -0,0 +1,17 @@ +require("dotenv").config(); + +const db = require("../src/db/sqlite"); +const { runBackup } = require("../src/db/backup"); + +runBackup(db, { force: true }) + .then((backupPath) => { + if (!backupPath) { + console.error("[Musicify] No database file found to back up."); + process.exit(1); + } + process.exit(0); + }) + .catch((err) => { + console.error("[Musicify] Backup failed:", err.message); + process.exit(1); + }); diff --git a/scripts/restore-db.js b/scripts/restore-db.js new file mode 100644 index 0000000..2d4137a --- /dev/null +++ b/scripts/restore-db.js @@ -0,0 +1,56 @@ +require("dotenv").config(); + +const fs = require("fs"); +const path = require("path"); +const { + BACKUP_DIR, + DB_PATH, + listTimestampedBackups, +} = require("../src/db/backup"); + +function usage() { + console.log("Usage: node scripts/restore-db.js [backup-file-or-name]"); + console.log(""); + console.log("Examples:"); + console.log(" node scripts/restore-db.js latest"); + console.log(" node scripts/restore-db.js musicify-2026-07-07T05-00-00.db"); + console.log(""); + console.log("Stop the bot before restoring."); +} + +const arg = process.argv[2]; +if (!arg) { + usage(); + process.exit(1); +} + +let sourcePath; +if (arg === "latest") { + sourcePath = path.join(BACKUP_DIR, "musicify-latest.db"); +} else if (path.isAbsolute(arg)) { + sourcePath = arg; +} else if (arg.includes(path.sep)) { + sourcePath = path.resolve(arg); +} else { + sourcePath = path.join(BACKUP_DIR, arg); +} + +if (!fs.existsSync(sourcePath)) { + console.error(`[Musicify] Backup not found: ${sourcePath}`); + console.error(""); + console.error("Available backups:"); + for (const backup of listTimestampedBackups()) { + console.error(` ${backup.name}`); + } + process.exit(1); +} + +const safetyCopy = `${DB_PATH}.before-restore-${Date.now()}`; +if (fs.existsSync(DB_PATH)) { + fs.copyFileSync(DB_PATH, safetyCopy); + console.log(`[Musicify] Saved current database to ${safetyCopy}`); +} + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +fs.copyFileSync(sourcePath, DB_PATH); +console.log(`[Musicify] Restored database from ${sourcePath}`); diff --git a/src/commands/247.js b/src/commands/247.js index 3fda941..7e862dd 100644 --- a/src/commands/247.js +++ b/src/commands/247.js @@ -1,59 +1,109 @@ -const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); -const { getGuildData } = require("../utils/playerStore"); -const { setGuildSetting, getGuildSettings } = require("../utils/database"); +const { + SlashCommandBuilder, + MessageFlags, + ContainerBuilder, + TextDisplayBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} = require("discord.js"); +const { getGuildSettings } = require("../utils/database"); -module.exports = { - data: new SlashCommandBuilder() - .setName("247") - .setDescription("Toggle 24/7 mode — bot stays in VC even when queue is empty"), +function build247ConfirmContainer(isEnabled, inVoiceChannel = true) { + const container = new ContainerBuilder(); + const voiceNote = inVoiceChannel + ? "" + : "\n\n-# Join a voice channel to enable or disable 24/7."; - async execute(interaction, client) { - if (!interaction.member.voice?.channel) { - return interaction.reply({ - content: "❌ You need to be in a voice channel!", - flags: MessageFlags.Ephemeral, - }); - } - - const guildId = interaction.guild.id; - const guildData = getGuildData(guildId); - const dbSettings = getGuildSettings(guildId); - - const newState = !dbSettings.twentyFourSeven; - guildData.twentyFourSeven = newState; - setGuildSetting(guildId, "twentyFourSeven", newState); - - if (newState) { - let player = client.riffy.players.get(guildId); - if (!player) { - player = client.riffy.createConnection({ - guildId: guildId, - voiceChannel: interaction.member.voice.channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - } - } - - const emoji = newState ? "✅" : "⏹"; - const label = newState ? "Enabled" : "Disabled"; - const desc = newState - ? "Active — I'll stay in the voice channel." - : "Inactive — I'll leave when the queue is empty."; - - const container = new ContainerBuilder(); + if (isEnabled) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - `### ${emoji} 24/7 Mode ${label}\n\n` + - "**Status**\n" + - `-# ${desc}\n\n` + - "**Persists**\n" + - "-# This setting is saved across bot restarts." + "### 24/7 Mode\n\n" + + "**Current status**\n" + + "-# Enabled — I stay in voice even when the queue is empty.\n\n" + + "**Disable 24/7?**\n" + + "-# I'll leave the voice channel once playback stops and the queue is empty." + + voiceNote + ) + ); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("247_disable") + .setLabel("Disable 24/7") + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId("247_cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) ) ); + } else { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### 24/7 Mode\n\n" + + "**Current status**\n" + + "-# Inactive — I leave when the queue is empty.\n\n" + + "**Enable 24/7?**\n" + + "-# I'll stay connected to your voice channel even between songs." + + voiceNote + ) + ); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("247_enable") + .setLabel("Enable 24/7") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("247_cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) + ) + ); + } + + return container; +} + +function build247ResultContainer(isEnabled) { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + isEnabled + ? "### ✅ 24/7 Enabled\n\n**Status**\n-# I'll stay in the voice channel even when nothing is playing." + : "### ⏹ 24/7 Disabled\n\n**Status**\n-# I'll leave the voice channel when the queue is empty." + ) + ); + return container; +} + +function build247CancelledContainer() { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("### Cancelled\n\n-# 24/7 mode was not changed.") + ); + return container; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("247") + .setDescription("Toggle 24/7 mode — bot stays in VC even when queue is empty"), + + async execute(interaction) { + const isEnabled = Boolean(getGuildSettings(interaction.guild.id).twentyFourSeven); + const inVoiceChannel = Boolean(interaction.member.voice?.channel); + await interaction.reply({ - components: [container], + components: [build247ConfirmContainer(isEnabled, inVoiceChannel)], flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, }); }, + + build247ConfirmContainer, + build247ResultContainer, + build247CancelledContainer, }; diff --git a/src/commands/about.js b/src/commands/about.js index 614340b..a9bbc5f 100644 --- a/src/commands/about.js +++ b/src/commands/about.js @@ -25,7 +25,7 @@ module.exports = { new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent( - "# <:Musicify_Logo:1504329028356673536> About Musicify" + "# <:Musicify_Logo:1517828581638541493> About Musicify" ) ) .setThumbnailAccessory( diff --git a/src/commands/bot-stats.js b/src/commands/bot-stats.js index 7f91207..0c8dcd2 100644 --- a/src/commands/bot-stats.js +++ b/src/commands/bot-stats.js @@ -20,7 +20,7 @@ module.exports = { const header = new SectionBuilder() .addTextDisplayComponents( - new TextDisplayBuilder().setContent("### <:Musicify_Logo:1504329028356673536> Statistics") + new TextDisplayBuilder().setContent("### <:Musicify_Logo:1517828581638541493> Statistics") ) .setThumbnailAccessory( new ThumbnailBuilder().setURL( diff --git a/src/commands/chatplay.js b/src/commands/chatplay.js index f13464f..c4e7a16 100644 --- a/src/commands/chatplay.js +++ b/src/commands/chatplay.js @@ -1,129 +1,82 @@ -const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder, SeparatorBuilder } = require("discord.js"); +const { SlashCommandBuilder, MessageFlags, PermissionFlagsBits } = require("discord.js"); const { getGuildData } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); -const { setGuildSetting, deleteGuildSetting } = require("../utils/database"); +const { buildErrorContainer, ephemeralV2 } = require("../utils/replies"); +const { createSession, auditChannelPermissions } = require("../utils/chatPlaySetupSession"); +const { + buildSetupStep2Container, + buildChatPlayManageContainer, + hasChatPlayConfigured, +} = require("../utils/chatPlaySetup"); -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 - } - } +function canManageChatPlay(memberPermissions) { + return ( + memberPermissions?.has(PermissionFlagsBits.ManageChannels) || + memberPermissions?.has(PermissionFlagsBits.Administrator) + ); +} - 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); +async function startChatPlaySetup(interaction) { + if (!canManageChatPlay(interaction.memberPermissions)) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Permission required**\n-# You need **Manage Channels** or **Administrator** to set up ChatPlay." + ) + ) + ); + } - 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!" + const { canProceed } = auditChannelPermissions(interaction.guild, interaction.channel); + if (!canProceed) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Missing permissions**\n-# Musicify needs **View Channel**, **Send Messages**, **Embed Links**, and **Connect & Speak** in a voice channel before ChatPlay can be set up." ) - ); - 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, - }); - } + const session = createSession( + interaction.guild.id, + interaction.user.id, + interaction.channel.id + ); + const container = buildSetupStep2Container(session, interaction.guild); - guildData.chatPlayEnabled = true; - setGuildSetting(guildId, "chatPlayEnabled", true); + await interaction.reply({ + components: [container], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); +} - 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, - }); +module.exports = { + data: new SlashCommandBuilder() + .setName("chatplay") + .setDescription("Set up or manage ChatPlay in this server"), - } else if (sub === "disable") { - if (!guildData.chatPlayChannelId) { - return interaction.reply({ - content: "❌ ChatPlay is not set up in any channel.", - flags: MessageFlags.Ephemeral, - }); - } + async execute(interaction) { + const guildData = getGuildData(interaction.guild.id); - guildData.chatPlayEnabled = false; - setGuildSetting(guildId, "chatPlayEnabled", false); + if (!hasChatPlayConfigured(guildData)) { + return startChatPlaySetup(interaction); + } - 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." + if (!canManageChatPlay(interaction.memberPermissions)) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Permission required**\n-# You need **Manage Channels** or **Administrator** to manage ChatPlay." + ) ) ); - await interaction.reply({ - components: [reply], - flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, - }); } + + await interaction.reply({ + components: [await buildChatPlayManageContainer(guildData, interaction.guild, interaction.client)], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }); }, + + startChatPlaySetup, }; diff --git a/src/commands/help.js b/src/commands/help.js index 3e7ae64..50fd7a5 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -13,6 +13,9 @@ const { StringSelectMenuOptionBuilder, } = require("discord.js"); +const SUPPORTED_PLATFORMS = + "Spotify, SoundCloud, Deezer, Apple Music, Tidal, Qobuz & JioSaavn"; + const PAGES = { home: { label: "Home", @@ -79,7 +82,7 @@ async function buildHelpPage(client, page = "home") { // --- Header with bot avatar --- const section = new SectionBuilder() .addTextDisplayComponents( - new TextDisplayBuilder().setContent("# <:Musicify_Logo:1504329028356673536> Musicify") + new TextDisplayBuilder().setContent("# <:Musicify_Logo:1517828581638541493> Musicify") ) .setThumbnailAccessory( new ThumbnailBuilder().setURL( @@ -153,7 +156,7 @@ function addHomePage(container, getCmd) { `**1.** Join a voice channel\n` + `-# Make sure you're connected before requesting a song.\n` + `**2.** Use ${getCmd("play")} \`\`\n` + - `-# Supports Spotify, SoundCloud, Deezer & Apple Music.\n` + + `-# Supports ${SUPPORTED_PLATFORMS}.\n` + `**3.** Control with buttons or commands!\n` + `-# Use the interactive player or slash commands.` ) @@ -190,7 +193,7 @@ function addMusicPage(container, getCmd) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( "### 🎶 Command Browser\n" + - "-# 17 commands available" + "-# 15 commands available" ) ); @@ -200,7 +203,7 @@ function addMusicPage(container, getCmd) { 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` + + `**3.** ${getCmd("stop")} — Stop playback and clear the queue\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` + @@ -209,12 +212,10 @@ function addMusicPage(container, getCmd) { `**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` + + `**12.** ${getCmd("247")} — Toggle 24/7 mode (confirmation required)\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` + `**14.** ${getCmd("chatplay")} — Set up or manage ChatPlay (enable, disable, delete)\n\n` + + `**15.** ${getCmd("about")} — Learn more about Musicify` ) ); } @@ -251,11 +252,9 @@ function addFiltersPage(container, getCmd) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `### 💬 ChatPlay\n\n` + - `**Setup**\n` + - `-# ${getCmd("chatplay", "setup")} — Send the persistent player message\n\n` + - `**Enable / Disable**\n` + - `-# ${getCmd("chatplay", "enable")} — Resume listening for song requests\n` + - `-# ${getCmd("chatplay", "disable")} — Pause listening (keeps message)\n\n` + + `**Setup & manage**\n` + + `-# ${getCmd("chatplay")} — Runs setup if ChatPlay isn't configured yet\n` + + `-# If ChatPlay exists, opens a panel to **Enable**, **Disable**, or **Delete** it\n\n` + `-# Once set up, just **type a song name** in the channel and Musicify plays it automatically!` ) ); @@ -266,7 +265,8 @@ function addFiltersPage(container, getCmd) { new TextDisplayBuilder().setContent( `### 🔁 24/7 Mode\n\n` + `**Toggle**\n` + - `-# ${getCmd("247")} — Turn 24/7 mode on or off\n\n` + + `-# ${getCmd("247")} — Stay in VC when idle; confirm before toggling\n\n` + + `-# ${getCmd("stop")} — Clears queue; disconnects unless 24/7 is on\n\n` + `**How it works**\n` + `-# Musicify stays in the voice channel even after the queue ends.\n` + `-# Setting persists across bot restarts.` @@ -342,7 +342,7 @@ function addTroubleshootPage(container, getCmd) { "**Bot won't play music / Track Error**\n" + "-# The song may be age-restricted or region-blocked.\n" + "-# The streaming source may be temporarily unavailable.\n" + - "-# **Fix:** Try a different song or paste a direct Spotify/SoundCloud URL.\n\n" + + `-# **Fix:** Try a different song or paste a direct URL from ${SUPPORTED_PLATFORMS}.\n\n` + "**Bot joins but immediately leaves**\n" + "-# Musicify may lack Speak/Connect permissions in that channel.\n" + @@ -361,7 +361,7 @@ function addTroubleshootPage(container, getCmd) { `-# **Fix:** Make sure you're in the same VC and not deafened.\n\n` + `**ChatPlay not responding**\n` + - `-# Ensure ChatPlay is enabled: ${getCmd("chatplay", "enable")}.\n` + + `-# Ensure ChatPlay is enabled — run ${getCmd("chatplay")} and tap **Enable**.\n` + `-# Make sure you're typing in the correct channel.\n` + `-# **Fix:** Try disabling and re-enabling it.\n\n` + diff --git a/src/commands/play.js b/src/commands/play.js index 81aa713..41ae08c 100644 --- a/src/commands/play.js +++ b/src/commands/play.js @@ -1,5 +1,6 @@ -const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder, SeparatorBuilder } = require("discord.js"); -const { getGuildData } = require("../utils/playerStore"); +const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); +const { buildErrorContainer, buildFeedbackContainer, ephemeralV2 } = require("../utils/replies"); +const { playQuery } = require("../services/playQuery"); module.exports = { data: new SlashCommandBuilder() @@ -11,153 +12,93 @@ module.exports = { async execute(interaction, client) { const query = interaction.options.getString("query"); + if (/(?:youtube\.com|youtu\.be)/i.test(query)) { - return interaction.reply({ - content: "❌ YouTube links are currently not supported.", - flags: MessageFlags.Ephemeral, - }); + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**YouTube not supported**\n-# YouTube links are currently not supported." + ) + ) + ); } - const member = interaction.member; + const member = interaction.member; if (!member.voice?.channel) { - return interaction.reply({ - content: "❌ You need to be in a voice channel!", - flags: MessageFlags.Ephemeral, - }); + return interaction.reply( + ephemeralV2( + buildErrorContainer("**Voice channel required**\n-# Join a voice channel first.") + ) + ); } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const guildData = getGuildData(interaction.guild.id); + const result = await playQuery(client, { + guild: interaction.guild, + member, + query, + textChannelId: interaction.channel.id, + source: "slash", + }); - // Create or get player - let player = client.riffy.players.get(interaction.guild.id); - if (!player) { - player = client.riffy.createConnection({ - guildId: interaction.guild.id, - voiceChannel: member.voice.channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - guildData.playerChannelId = interaction.channel.id; + if (!result.ok) { + const isSoft = + result.type === "lavalink_down" || + result.type === "vc_mismatch" || + result.type === "duplicate"; + let container; + if (result.type === "duplicate") { + container = buildFeedbackContainer( + `### ⚠️ Duplicate track\n\n-# ${result.message}` + ); + } else { + container = isSoft + ? buildFeedbackContainer(result.message) + : buildErrorContainer(result.message); + } + return interaction.editReply(ephemeralV2(container)); } - // Set volume - player.setVolume(guildData.volume); - - try { - const result = await client.riffy.resolve({ - query: query, - requester: interaction.user, - }); - - const { loadType, tracks, playlistInfo } = result; + if (result.type === "playlist") { + let content = + "### ✅ Playlist Added\n\n" + + `**${result.playlistName}**\n\n` + + "**Tracks**\n" + + `-# ${result.addedCount} of ${result.totalCount} songs added to queue`; - // Handle all Lavalink v3 + v4 loadType variants - if ( - loadType === "playlist" || - loadType === "PLAYLIST_LOADED" - ) { - const duplicates = []; - const addedTracks = []; - - for (const track of tracks) { - track.info.requester = interaction.user; - - // Check for duplicates - const isDuplicate = player.queue.some(existingTrack => - existingTrack.info.uri === track.info.uri - ) || (player.current && player.current.info.uri === track.info.uri); - - if (isDuplicate) { - duplicates.push(track.info.title || "Unknown"); - } else { - player.queue.add(track); - addedTracks.push(track.info.title || "Unknown"); - } - } - - let content = "### ✅ Playlist Added\n\n" + - "**Playlist**\n" + - `-# ${playlistInfo?.name || "Unknown Playlist"}\n\n` + - "**Tracks**\n" + - `-# ${addedTracks.length} of ${tracks.length} songs added to queue`; - - if (duplicates.length > 0) { - content += `\n\n⚠️ **Duplicates Skipped**\n-# ${duplicates.length} songs already in queue`; - } - - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(content) - ); - await interaction.editReply({ - components: [container], - flags: MessageFlags.IsComponentsV2, - }); - - if (!player.playing && !player.paused && !player.current) player.play(); - } else if ( - loadType === "search" || - loadType === "track" || - loadType === "SEARCH_RESULT" || - loadType === "TRACK_LOADED" - ) { - const track = tracks[0]; - if (!track) { - return interaction.editReply({ content: "❌ No results found." }); - } - - // Check for duplicate - const isDuplicate = player.queue.some(existingTrack => - existingTrack.info.uri === track.info.uri - ) || (player.current && player.current.info.uri === track.info.uri); - - if (isDuplicate) { - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ⚠️ Duplicate Detected\n\n" + - "**Track**\n" + - `-# ${track.info.title}\n\n` + - "**Status**\n" + - `-# Already in queue or currently playing` - ) - ); - return await interaction.editReply({ - components: [container], - flags: MessageFlags.IsComponentsV2, - }); - } - - track.info.requester = interaction.user; - player.queue.add(track); + if (result.duplicates.length > 0) { + content += `\n\n**Duplicates Skipped**\n-# ${result.duplicates.length} songs already in queue`; + } - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ✅ Track Added\n\n" + - "**Title**\n" + - `-# ${track.info.title}\n\n` + - "**Artist**\n" + - `-# ${track.info.author}\n\n` + - "**Position**\n" + - `-# #${player.queue.length} in queue` - ) - ); - await interaction.editReply({ - components: [container], - flags: MessageFlags.IsComponentsV2, - }); + const container = new ContainerBuilder(); + container.addTextDisplayComponents(new TextDisplayBuilder().setContent(content)); + return interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2, + }); + } - if (!player.playing && !player.paused && !player.current) player.play(); - } else { - console.log(`[Musicify] Unhandled loadType: "${loadType}"`); - return interaction.editReply({ content: `❌ No results found. (loadType: ${loadType})` }); - } - } catch (error) { - console.error("[Musicify] Play error:", error); - return interaction.editReply({ content: "❌ An error occurred while searching." }); + let positionLine = "-# Starting playback now"; + if (result.queuePosition) { + positionLine = `-# #${result.queuePosition} in queue`; } + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### ✅ Track Added\n\n" + + "**Title**\n" + + `-# ${result.title}\n\n` + + "**Artist**\n" + + `-# ${result.author}\n\n` + + "**Position**\n" + + positionLine + ) + ); + return interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2, + }); }, }; diff --git a/src/commands/stop.js b/src/commands/stop.js index 507f377..2cabf5d 100644 --- a/src/commands/stop.js +++ b/src/commands/stop.js @@ -1,85 +1,34 @@ const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); -const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); +const { handleStop } = require("../services/sessionManager"); +const { buildErrorContainer, ephemeralV2 } = require("../utils/replies"); module.exports = { data: new SlashCommandBuilder() .setName("stop") - .setDescription("Stop playback, clear queue, and disconnect"), + .setDescription("Stop playback and clear the queue (disconnects unless 24/7 is on)"), async execute(interaction, client) { const player = client.riffy.players.get(interaction.guild.id); if (!player) { - return interaction.reply({ - content: "❌ No active player.", - flags: MessageFlags.Ephemeral, - }); + return interaction.reply( + ephemeralV2(buildErrorContainer("**No active player**\n-# Nothing is playing right now.")) + ); } if (!interaction.member.voice?.channel) { - return interaction.reply({ - content: "❌ You need to be in a voice channel!", - 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 = []; - - 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; - } - - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "### ⏹ Stopped\n\n" + - "**Status**\n" + - "-# Queue cleared. Staying in voice channel (24/7 mode)." - ) + return interaction.reply( + ephemeralV2(buildErrorContainer("**Voice channel required**\n-# Join a voice channel first.")) ); - return interaction.reply({ - components: [container], - flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, - }); } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - player.destroy(); + const { stayed } = await handleStop(client, interaction.guild.id); const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - "### ⏹ Stopped\n\n" + - "**Status**\n" + - "-# Queue cleared and disconnected from voice channel." + stayed + ? "### ⏹ Stopped\n\n**Status**\n-# Queue cleared. Staying in voice channel (24/7 mode)." + : "### ⏹ Stopped\n\n**Status**\n-# Queue cleared and disconnected from voice channel." ) ); await interaction.reply({ diff --git a/src/commands/volume.js b/src/commands/volume.js index 1a38efd..c15fa4a 100644 --- a/src/commands/volume.js +++ b/src/commands/volume.js @@ -1,5 +1,6 @@ const { SlashCommandBuilder, MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); const { getGuildData } = require("../utils/playerStore"); +const { persistGuildPlaybackSettings } = require("../utils/database"); module.exports = { data: new SlashCommandBuilder() @@ -35,6 +36,7 @@ module.exports = { const guildData = getGuildData(interaction.guild.id); guildData.volume = level; player.setVolume(level); + persistGuildPlaybackSettings(interaction.guild.id, guildData); // Build a simple volume bar const filled = Math.round(level / 10); diff --git a/src/db/appSettings.js b/src/db/appSettings.js new file mode 100644 index 0000000..8086453 --- /dev/null +++ b/src/db/appSettings.js @@ -0,0 +1,23 @@ +const db = require("./sqlite"); + +function getAppSetting(key) { + const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key); + return row?.value ?? null; +} + +function setAppSetting(key, value) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run( + key, + value + ); +} + +function deleteAppSetting(key) { + db.prepare("DELETE FROM app_settings WHERE key = ?").run(key); +} + +module.exports = { + getAppSetting, + setAppSetting, + deleteAppSetting, +}; diff --git a/src/db/backup.js b/src/db/backup.js new file mode 100644 index 0000000..02f4e75 --- /dev/null +++ b/src/db/backup.js @@ -0,0 +1,163 @@ +const fs = require("fs"); +const path = require("path"); + +const DATA_DIR = path.join(__dirname, "..", "..", "data"); +const BACKUP_DIR = path.join(DATA_DIR, "backups"); +const DB_PATH = path.join(DATA_DIR, "musicify.db"); + +const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_RETAIN = 14; + +let schedulerTimer = null; + +function getBackupConfig() { + return { + enabled: process.env.BACKUP_ENABLED !== "false", + intervalMs: Math.max( + 60_000, + Number(process.env.BACKUP_INTERVAL_MS) || DEFAULT_INTERVAL_MS + ), + retain: Math.max(5, Number(process.env.BACKUP_RETAIN_COUNT) || DEFAULT_RETAIN), + }; +} + +function ensureBackupDir() { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); +} + +function formatTimestamp(date = new Date()) { + return date.toISOString().replace(/[:.]/g, "-").slice(0, 19); +} + +function escapeSqlPath(filePath) { + return filePath.replace(/'/g, "''"); +} + +function listTimestampedBackups() { + if (!fs.existsSync(BACKUP_DIR)) return []; + + return fs + .readdirSync(BACKUP_DIR) + .filter( + (name) => + name.startsWith("musicify-") && + name.endsWith(".db") && + name !== "musicify-latest.db" + ) + .map((name) => { + const fullPath = path.join(BACKUP_DIR, name); + return { + name, + path: fullPath, + mtimeMs: fs.statSync(fullPath).mtimeMs, + }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +function pruneOldBackups(retain) { + ensureBackupDir(); + + for (const backup of listTimestampedBackups().slice(retain)) { + fs.unlinkSync(backup.path); + } +} + +function getLatestBackupAgeMs() { + const latestPath = path.join(BACKUP_DIR, "musicify-latest.db"); + if (fs.existsSync(latestPath)) { + return Date.now() - fs.statSync(latestPath).mtimeMs; + } + + const backups = listTimestampedBackups(); + if (backups.length === 0) { + return Infinity; + } + + return Date.now() - backups[0].mtimeMs; +} + +async function createBackup(db) { + if (!fs.existsSync(DB_PATH)) { + return null; + } + + ensureBackupDir(); + + const stamp = formatTimestamp(); + const backupPath = path.join(BACKUP_DIR, `musicify-${stamp}.db`); + const latestPath = path.join(BACKUP_DIR, "musicify-latest.db"); + + db.exec(`VACUUM INTO '${escapeSqlPath(backupPath)}'`); + fs.copyFileSync(backupPath, latestPath); + + return backupPath; +} + +async function runBackup(db, { force = false } = {}) { + const { retain, intervalMs } = getBackupConfig(); + + if (!force && getLatestBackupAgeMs() < intervalMs) { + return null; + } + + try { + const backupPath = await createBackup(db); + if (!backupPath) { + return null; + } + + pruneOldBackups(retain); + console.log(`[Musicify] Database backup saved to ${backupPath}`); + return backupPath; + } catch (err) { + console.error("[Musicify] Database backup failed:", err.message); + return null; + } +} + +function startBackupScheduler(db) { + const config = getBackupConfig(); + if (!config.enabled) { + console.log("[Musicify] Database backups disabled (BACKUP_ENABLED=false)"); + return; + } + + ensureBackupDir(); + pruneOldBackups(config.retain); + + const latestAgeMs = getLatestBackupAgeMs(); + if (latestAgeMs >= config.intervalMs) { + void runBackup(db); + } + + if (schedulerTimer) { + clearInterval(schedulerTimer); + } + + schedulerTimer = setInterval(() => { + void runBackup(db); + }, config.intervalMs); + + const hours = config.intervalMs / (60 * 60 * 1000); + console.log( + `[Musicify] Database backups enabled — every ${hours}h, keeping ${config.retain} snapshot(s)` + ); +} + +function stopBackupScheduler() { + if (schedulerTimer) { + clearInterval(schedulerTimer); + schedulerTimer = null; + } +} + +module.exports = { + BACKUP_DIR, + DATA_DIR, + DB_PATH, + listTimestampedBackups, + runBackup, + startBackupScheduler, + stopBackupScheduler, +}; diff --git a/src/db/guildColumns.js b/src/db/guildColumns.js new file mode 100644 index 0000000..f43181d --- /dev/null +++ b/src/db/guildColumns.js @@ -0,0 +1,74 @@ +const GUILD_COLUMN_MAP = { + chatPlayChannelId: "chat_play_channel_id", + chatPlayMessageId: "chat_play_message_id", + chatPlayEnabled: "chat_play_enabled", + chatPlaySlowmode: "chat_play_slowmode", + chatPlayDeleteMessages: "chat_play_delete_messages", + chatPlayPinPlayerMessage: "chat_play_pin_player_message", + twentyFourSeven: "twenty_four_seven", + boundVoiceChannelId: "bound_voice_channel_id", + defaultVolume: "default_volume", + defaultAutoplay: "default_autoplay", +}; + +const BOOLEAN_KEYS = new Set([ + "chatPlayEnabled", + "chatPlaySlowmode", + "chatPlayDeleteMessages", + "chatPlayPinPlayerMessage", + "twentyFourSeven", + "defaultAutoplay", +]); + +const REVERSE_MAP = Object.fromEntries( + Object.entries(GUILD_COLUMN_MAP).map(([camel, column]) => [column, camel]) +); + +function camelToColumn(key) { + return GUILD_COLUMN_MAP[key] ?? null; +} + +function serializeValue(key, value) { + if (value === null || value === undefined) { + return null; + } + if (BOOLEAN_KEYS.has(key)) { + return value ? 1 : 0; + } + return value; +} + +function rowToSettings(row) { + if (!row) return {}; + + const settings = {}; + for (const [column, camelKey] of Object.entries(REVERSE_MAP)) { + const value = row[column]; + if (value === null || value === undefined) continue; + + if (BOOLEAN_KEYS.has(camelKey)) { + settings[camelKey] = value === 1; + } else { + settings[camelKey] = value; + } + } + return settings; +} + +function settingsToColumns(settings) { + const columns = {}; + for (const [key, value] of Object.entries(settings)) { + const column = camelToColumn(key); + if (!column) continue; + columns[column] = serializeValue(key, value); + } + return columns; +} + +module.exports = { + GUILD_COLUMN_MAP, + camelToColumn, + serializeValue, + rowToSettings, + settingsToColumns, +}; diff --git a/src/db/migrate-from-json.js b/src/db/migrate-from-json.js new file mode 100644 index 0000000..b924976 --- /dev/null +++ b/src/db/migrate-from-json.js @@ -0,0 +1,141 @@ +const fs = require("fs"); +const path = require("path"); +const { settingsToColumns } = require("./guildColumns"); + +const DATA_DIR = path.join(__dirname, "..", "..", "data"); + +const JSON_FILES = { + guilds: path.join(DATA_DIR, "guilds.json"), + incidents: path.join(DATA_DIR, "incidents.json"), + statusWebhook: path.join(DATA_DIR, "status-webhook.json"), +}; + +function backupPath(filePath) { + return `${filePath}.bak`; +} + +function fileExists(filePath) { + return fs.existsSync(filePath); +} + +function renameToBackup(filePath) { + if (!fileExists(filePath)) return false; + fs.renameSync(filePath, backupPath(filePath)); + return true; +} + +function importGuilds(db) { + const jsonPath = JSON_FILES.guilds; + if (fileExists(backupPath(jsonPath))) return 0; + if (!fileExists(jsonPath)) return 0; + + const existingCount = db.prepare("SELECT COUNT(*) AS count FROM guilds").get().count; + if (existingCount > 0) { + renameToBackup(jsonPath); + return 0; + } + + let imported = 0; + const raw = fs.readFileSync(jsonPath, "utf-8"); + const data = JSON.parse(raw); + + const insertGuild = db.prepare("INSERT INTO guilds (guild_id) VALUES (?)"); + + db.exec("BEGIN"); + try { + for (const [guildId, settings] of Object.entries(data)) { + if (!settings || typeof settings !== "object") continue; + + insertGuild.run(guildId); + const columns = settingsToColumns(settings); + + for (const [column, value] of Object.entries(columns)) { + db.prepare(`UPDATE guilds SET ${column} = ? WHERE guild_id = ?`).run( + value, + guildId + ); + } + imported++; + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + + renameToBackup(jsonPath); + return imported; +} + +function importIncidents(db) { + const jsonPath = JSON_FILES.incidents; + if (fileExists(backupPath(jsonPath))) return 0; + if (!fileExists(jsonPath)) return 0; + + let imported = 0; + const raw = fs.readFileSync(jsonPath, "utf-8"); + const parsed = JSON.parse(raw); + const incidents = Array.isArray(parsed) ? parsed : []; + + const insert = db.prepare( + "INSERT INTO incidents (timestamp, component, description) VALUES (?, ?, ?)" + ); + + db.exec("BEGIN"); + try { + for (const incident of incidents) { + if (!incident?.timestamp || !incident?.component || !incident?.description) { + continue; + } + insert.run(incident.timestamp, incident.component, incident.description); + imported++; + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + + renameToBackup(jsonPath); + return imported; +} + +function importStatusWebhook(db) { + const jsonPath = JSON_FILES.statusWebhook; + if (fileExists(backupPath(jsonPath))) return false; + if (!fileExists(jsonPath)) return false; + + let messageId = null; + try { + const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + messageId = data.messageId || null; + } catch { + messageId = null; + } + + if (messageId) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run( + "status_webhook_message_id", + messageId + ); + } + + renameToBackup(jsonPath); + return Boolean(messageId); +} + +function importJsonIfNeeded(db) { + const guildCount = importGuilds(db); + const incidentCount = importIncidents(db); + const statusImported = importStatusWebhook(db); + + if (guildCount > 0 || incidentCount > 0 || statusImported) { + console.log( + `[Musicify] Migrated JSON data to SQLite (${guildCount} guild(s), ${incidentCount} incident(s)${statusImported ? ", status webhook" : ""})` + ); + } +} + +module.exports = { + importJsonIfNeeded, +}; diff --git a/src/db/migrations.js b/src/db/migrations.js new file mode 100644 index 0000000..bf6db8e --- /dev/null +++ b/src/db/migrations.js @@ -0,0 +1,95 @@ +const SCHEMA_VERSION = 6; + +function getSchemaVersion(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'schema_version'").get(); + return row ? Number(row.value) : 0; +} + +function setSchemaVersion(db, version) { + db.prepare( + "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('schema_version', ?)" + ).run(String(version)); +} + +function migrateToV1(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS guilds ( + guild_id TEXT PRIMARY KEY, + chat_play_channel_id TEXT, + chat_play_message_id TEXT, + chat_play_enabled INTEGER, + chat_play_slowmode INTEGER, + chat_play_delete_messages INTEGER, + chat_play_pin_player_message INTEGER, + twenty_four_seven INTEGER, + bound_voice_channel_id TEXT, + default_volume INTEGER + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + component TEXT NOT NULL, + description TEXT NOT NULL + ) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_incidents_timestamp + ON incidents(timestamp DESC) + `); +} + +function migrateToV5(db) { + db.exec("DROP TABLE IF EXISTS playlist_tracks"); + db.exec("DROP TABLE IF EXISTS playlists"); +} + +function migrateToV6(db) { + const columns = db.prepare("PRAGMA table_info(guilds)").all(); + if (!columns.some((col) => col.name === "default_autoplay")) { + db.exec("ALTER TABLE guilds ADD COLUMN default_autoplay INTEGER"); + } +} + +function runMigrations(db) { + let version = getSchemaVersion(db); + + if (version < 1) { + migrateToV1(db); + setSchemaVersion(db, 1); + version = 1; + } + + if (version < 5) { + migrateToV5(db); + setSchemaVersion(db, 5); + version = 5; + } + + if (version < 6) { + migrateToV6(db); + setSchemaVersion(db, 6); + version = 6; + } + + if (version !== SCHEMA_VERSION) { + throw new Error( + `[Musicify] Unsupported database schema version ${version} (expected ${SCHEMA_VERSION})` + ); + } +} + +module.exports = { + runMigrations, + SCHEMA_VERSION, +}; diff --git a/src/db/sqlite.js b/src/db/sqlite.js new file mode 100644 index 0000000..60ae997 --- /dev/null +++ b/src/db/sqlite.js @@ -0,0 +1,26 @@ +const fs = require("fs"); +const path = require("path"); +const { DatabaseSync } = require("node:sqlite"); +const { runMigrations } = require("./migrations"); + +const DB_PATH = path.join(__dirname, "..", "..", "data", "musicify.db"); + +function ensureDataDir() { + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +ensureDataDir(); + +const db = new DatabaseSync(DB_PATH); +db.exec("PRAGMA journal_mode = WAL"); +db.exec("PRAGMA foreign_keys = ON"); + +runMigrations(db); + +const { importJsonIfNeeded } = require("./migrate-from-json"); +importJsonIfNeeded(db); + +module.exports = db; diff --git a/src/deploy-commands.js b/src/deploy-commands.js index b3b94e3..9c36c87 100644 --- a/src/deploy-commands.js +++ b/src/deploy-commands.js @@ -19,7 +19,7 @@ const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN); (async () => { try { - console.log(`[Musicify] Registering ${commands.length} slash commands...`); + console.log(`[Musicify] Registering ${commands.length} slash commands globally…`); await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands, @@ -28,5 +28,6 @@ const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN); console.log("[Musicify] Slash commands registered successfully!"); } catch (error) { console.error("[Musicify] Failed to register commands:", error); + process.exit(1); } })(); diff --git a/src/events/guildCreate.js b/src/events/guildCreate.js new file mode 100644 index 0000000..45a79ba --- /dev/null +++ b/src/events/guildCreate.js @@ -0,0 +1,9 @@ +const { sendGuildWelcome } = require("../utils/guildWelcome"); + +module.exports = { + name: "guildCreate", + once: false, + async execute(client, guild) { + await sendGuildWelcome(client, guild); + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 9d0f161..5756b5f 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,10 +1,21 @@ const { MessageFlags } = require("discord.js"); const { handleButtonInteraction } = require("../handlers/buttonHandler"); +const { handleChatPlaySetupModal } = require("../handlers/chatPlaySetupHandler"); +const { applyChatPlayEphemeral } = require("../handlers/chatPlayHandler"); +const { + isInteractionExpired, + replyExpiredInteraction, + buildErrorContainer, + ephemeralV2, +} = require("../utils/replies"); module.exports = { name: "interactionCreate", async execute(client, interaction) { - // Handle slash commands + if (interaction.guild) { + applyChatPlayEphemeral(interaction); + } + if (interaction.isChatInputCommand()) { const command = client.commands.get(interaction.commandName); if (!command) return; @@ -13,23 +24,24 @@ module.exports = { await command.execute(interaction, client); } catch (error) { console.error(`[Musicify] Command error (${interaction.commandName}):`, error); - - // Handle expired interactions - if (error.code === 10062) { - console.log("[Musicify] Interaction expired - unable to respond"); + + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); return; } - - const reply = { content: "An error occurred.", flags: MessageFlags.Ephemeral }; + try { + const payload = ephemeralV2( + buildErrorContainer("**Something went wrong**\n-# An unexpected error occurred. Try again.") + ); if (interaction.replied || interaction.deferred) { - await interaction.followUp(reply); + await interaction.followUp(payload); } else { - await interaction.reply(reply); + await interaction.reply(payload); } } catch (replyError) { - if (replyError.code === 10062) { - console.log("[Musicify] Interaction expired during error reply"); + if (isInteractionExpired(replyError)) { + await replyExpiredInteraction(interaction); } else { console.error("[Musicify] Error sending error reply:", replyError); } @@ -38,18 +50,54 @@ module.exports = { return; } - // Handle buttons and select menus + if (interaction.isModalSubmit()) { + try { + const handled = await handleChatPlaySetupModal(client, interaction); + if (handled) return; + } catch (error) { + console.error("[Musicify] Modal interaction error:", error); + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); + return; + } + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply( + ephemeralV2( + buildErrorContainer("**Something went wrong**\n-# That interaction failed. Try again.") + ) + ); + } + } catch (replyError) { + if (isInteractionExpired(replyError)) { + await replyExpiredInteraction(interaction); + } + } + } + return; + } + if (interaction.isButton() || interaction.isStringSelectMenu()) { try { await handleButtonInteraction(client, interaction); } catch (error) { console.error("[Musicify] Interaction error:", error); + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); + return; + } try { if (!interaction.replied && !interaction.deferred) { - await interaction.deferUpdate(); + await interaction.reply( + ephemeralV2( + buildErrorContainer("**Something went wrong**\n-# That interaction failed. Try again.") + ) + ); + } + } catch (replyError) { + if (isInteractionExpired(replyError)) { + await replyExpiredInteraction(interaction); } - } catch (e) { - // ignore } } } diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js new file mode 100644 index 0000000..1933b74 --- /dev/null +++ b/src/events/messageDelete.js @@ -0,0 +1,20 @@ +const { getGuildData } = require("../utils/playerStore"); +const { recreateChatPlayMessage } = require("../services/sessionManager"); + +module.exports = { + name: "messageDelete", + async execute(client, message) { + const guildId = message.guildId ?? message.guild?.id; + if (!guildId) return; + + const guildData = getGuildData(guildId); + if (!guildData.chatPlayChannelId) return; + + const channelId = message.channelId ?? message.channel?.id; + if (channelId !== guildData.chatPlayChannelId) return; + if (!guildData.chatPlayMessageId || guildData.chatPlayMessageId !== message.id) return; + + guildData.chatPlayMessageId = null; + await recreateChatPlayMessage(client, guildId); + }, +}; diff --git a/src/events/ready.js b/src/events/ready.js index 00b00f3..98d0d92 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -4,6 +4,13 @@ const path = require("path"); const { readDB } = require("../utils/database"); const { getGuildData } = require("../utils/playerStore"); const { startStatusMonitor } = require("../services/statusMonitor"); +const db = require("../db/sqlite"); +const { startBackupScheduler } = require("../db/backup"); +const { + restoreSessionFromDatabase, + reconnectTwentyFourSeven, + recreateChatPlayMessage, +} = require("../services/sessionManager"); module.exports = { name: "clientReady", @@ -16,6 +23,7 @@ module.exports = { client.riffy.init(client.user.id); startStatusMonitor(client); + startBackupScheduler(db); // Cycling statuses const statuses = [ @@ -42,7 +50,7 @@ module.exports = { }); }, 120000); // 2 minutes - // --- Restore ChatPlay channels from JSON database --- + // --- Restore ChatPlay channels from SQLite database --- try { const db = readDB(); let restoredCount = 0; @@ -53,6 +61,17 @@ module.exports = { if (!guild) continue; const guildData = getGuildData(guildId); + restoreSessionFromDatabase(guildId, settings); + + if (typeof settings.chatPlaySlowmode === "boolean") { + guildData.chatPlaySlowmode = settings.chatPlaySlowmode; + } + if (typeof settings.chatPlayDeleteMessages === "boolean") { + guildData.chatPlayDeleteMessages = settings.chatPlayDeleteMessages; + } + if (typeof settings.chatPlayPinPlayerMessage === "boolean") { + guildData.chatPlayPinPlayerMessage = settings.chatPlayPinPlayerMessage; + } // Restore ChatPlay state with message validation if (settings.chatPlayChannelId) { @@ -80,22 +99,10 @@ module.exports = { guildData.chatPlayMessageId = null; } - // If message is invalid, create a new idle message - if (!guildData.chatPlayMessageId && guildData.chatPlayEnabled) { + // If message is invalid, create a new player message + if (!guildData.chatPlayMessageId) { try { - const { createChatPlayIdleContainer } = require("../utils/components"); - const container = createChatPlayIdleContainer(); - const channel = client.channels.cache.get(settings.chatPlayChannelId); - if (channel) { - const chatMsg = await channel.send({ - components: [container], - flags: MessageFlags.IsComponentsV2, - }); - guildData.chatPlayMessageId = chatMsg.id; - // Update database with new message ID - const { setGuildSetting } = require("../utils/database"); - setGuildSetting(guildId, "chatPlayMessageId", chatMsg.id); - } + await recreateChatPlayMessage(client, guildId); } catch (err) { console.error(`[Musicify] Failed to recreate ChatPlay message for guild ${guildId}:`, err.message); } @@ -103,11 +110,18 @@ module.exports = { restoredCount++; } + } - // Restore 24/7 mode - if (settings.twentyFourSeven) { - guildData.twentyFourSeven = true; - } + const reconnectTargets = Object.entries(db).filter( + ([, settings]) => settings.twentyFourSeven && settings.boundVoiceChannelId + ); + + if (reconnectTargets.length > 0) { + setTimeout(async () => { + for (const [guildId] of reconnectTargets) { + await reconnectTwentyFourSeven(client, guildId); + } + }, 3000); } if (restoredCount > 0) { @@ -156,7 +170,7 @@ module.exports = { } } } - }, 2000); // Wait 2 seconds for Lavalink to be fully connected + }, 5000); } } 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..e21d653 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -1,57 +1,45 @@ -const { getGuildData, deleteGuildData } = require("../utils/playerStore"); -const { createChatPlayIdleContainer } = require("../utils/components"); -const { MessageFlags } = require("discord.js"); +const { getGuildData } = require("../utils/playerStore"); +const { + resetChatPlayToIdle, + clearRegularPlayerMessage, + scheduleAloneLeave, + cancelAloneLeaveIfUsersPresent, + cancelAloneLeaveTimer, +} = require("../services/sessionManager"); module.exports = { name: "voiceStateUpdate", async execute(client, oldState, newState) { - // Check if the bot was disconnected from a voice channel + const guildId = oldState.guild.id; + + // Bot was disconnected from voice — reset UI and destroy player if (oldState.id === client.user.id && !newState.channelId) { - const player = client.riffy.players.get(oldState.guild.id); + const player = client.riffy?.players.get(guildId); if (player) { - await resetChatPlayIfActive(client, oldState.guild.id); + await resetChatPlayIfActive(client, guildId); player.destroy(); } return; } - // Check if bot is alone in VC - if (oldState.channelId && oldState.channel) { - const botMember = oldState.guild.members.cache.get(client.user.id); - if (botMember?.voice?.channel) { - const members = botMember.voice.channel.members.filter( - (m) => !m.user.bot - ); - if (members.size === 0) { - // Skip auto-disconnect if 24/7 mode is enabled - const guildData = getGuildData(oldState.guild.id); - if (guildData.twentyFourSeven) return; + // User joined the bot's voice channel — cancel alone-leave timer + if (newState.channelId && !newState.member.user.bot) { + const botMember = newState.guild.members.cache.get(client.user.id); + if (botMember?.voice?.channelId === newState.channelId) { + cancelAloneLeaveIfUsersPresent(client, guildId); + } + } - // Bot is alone, disconnect after 30 seconds - setTimeout(async () => { - const currentChannel = oldState.guild.members.cache - .get(client.user.id) - ?.voice?.channel; - if (currentChannel) { - const currentMembers = currentChannel.members.filter( - (m) => !m.user.bot - ); - if (currentMembers.size === 0) { - // Re-check 24/7 in case it was toggled during the timeout - const currentGuildData = getGuildData(oldState.guild.id); - if (currentGuildData.twentyFourSeven) return; + // Last human left the bot's voice channel + if (oldState.channelId && oldState.channel && !oldState.member.user.bot) { + const botMember = oldState.guild.members.cache.get(client.user.id); + if (botMember?.voice?.channelId !== oldState.channelId) return; - const player = client.riffy.players.get( - oldState.guild.id - ); - if (player) { - await resetChatPlayIfActive(client, oldState.guild.id); - player.destroy(); - } - } - } - }, 30000); - } + const members = oldState.channel.members.filter((m) => !m.user.bot); + if (members.size === 0) { + const guildData = getGuildData(guildId); + if (guildData.twentyFourSeven) return; + scheduleAloneLeave(client, guildId); } } }, @@ -59,32 +47,12 @@ 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 - } + await resetChatPlayToIdle(client, guildId); + } else if (guildData.playerMessageId && guildData.playerChannelId) { + await clearRegularPlayerMessage(client, guildData); } + + cancelAloneLeaveTimer(guildData); } diff --git a/src/handlers/buttonHandler.js b/src/handlers/buttonHandler.js index f05fd18..c9c67b7 100644 --- a/src/handlers/buttonHandler.js +++ b/src/handlers/buttonHandler.js @@ -1,15 +1,52 @@ const { MessageFlags, AttachmentBuilder } = require("discord.js"); const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); -const { createNowPlayingContainer, createChatPlayNowPlayingContainer, createQueueContainer, createChatPlayIdleContainer } = require("../utils/components"); +const { createNowPlayingContainer, createChatPlayNowPlayingContainer, createQueueContainer, createStopConfirmContainer } = require("../utils/components"); const { generateMusicCard } = require("../utils/musicard"); const { addNodeDetails } = require("../utils/nodeDetails"); const { canControlMusic, VOICE_CHANNEL_DENIAL } = require("../utils/permissions"); +const { handleStop, toggleTwentyFourSeven } = require("../services/sessionManager"); +const { build247ResultContainer, build247CancelledContainer } = require("../commands/247"); +const { + safeInteractionUpdate, + isInteractionExpired, + replyExpiredInteraction, + buildErrorContainer, + buildFeedbackContainer, + ephemeralV2, +} = require("../utils/replies"); +const { + handleChatPlaySetupButton, + handleChatPlayManageButton, + isChatPlaySetupButton, + isChatPlayManageButton, +} = require("./chatPlaySetupHandler"); +const { notifyPlayerFeedback } = require("./chatPlayHandler"); +const { dismissWelcomeMessage } = require("../utils/guildWelcome"); const config = require("../../config"); +const { getTrackQueuePosition, formatDuplicateTrackMessage } = require("../utils/queueUtils"); +const { persistGuildPlaybackSettings } = require("../utils/database"); /** * Handle all button and select menu interactions from the player container */ async function handleButtonInteraction(client, interaction) { + if (interaction.isButton()) { + const customId = interaction.customId; + + if (customId === "welcome_dismiss") { + await dismissWelcomeMessage(interaction); + return; + } + + if (isChatPlaySetupButton(customId)) { + return handleChatPlaySetupButton(client, interaction); + } + + if (isChatPlayManageButton(customId)) { + return handleChatPlayManageButton(client, interaction); + } + } + if (!client.riffy) { console.warn('[Musicify] Riffy client not initialized; button handler ignored.'); return; @@ -32,6 +69,94 @@ async function handleButtonInteraction(client, interaction) { let player = client.riffy.players.get(guildId); const guildData = getGuildData(guildId); + if (interaction.isButton()) { + const customId = interaction.customId; + + if (customId === "247_enable" || customId === "247_disable" || customId === "247_cancel") { + try { + if (customId === "247_cancel") { + await safeInteractionUpdate(interaction, { + components: [build247CancelledContainer()], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (!interaction.member.voice?.channel) { + return interaction.reply( + ephemeralV2( + buildErrorContainer("**Voice channel required**\n-# Join a voice channel first.") + ) + ); + } + + const enabling = customId === "247_enable"; + + await toggleTwentyFourSeven(client, interaction.guild.id, { + voiceChannelId: interaction.member.voice.channel.id, + textChannelId: guildData.chatPlayChannelId || interaction.channel.id, + enabled: enabling, + }); + + await safeInteractionUpdate(interaction, { + components: [build247ResultContainer(enabling)], + flags: MessageFlags.IsComponentsV2, + }); + } catch (error) { + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); + } else { + throw error; + } + } + return; + } + + if (customId === "stop_confirm") { + try { + if (!player) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Nothing playing**\n-# Start music with `/play` or ChatPlay first." + ) + ) + ); + } + if (!canControlMusic(interaction.member, player)) { + return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); + } + if (guildData.stopConfirmPending !== interaction.user.id) { + return interaction.reply( + ephemeralV2( + buildFeedbackContainer( + "### ⚠️ Expired\n\n-# Stop confirmation expired — press **stop** again." + ) + ) + ); + } + + guildData.stopConfirmPending = null; + await handleStop(client, interaction.guild.id); + await safeInteractionUpdate(interaction, { + components: [ + buildFeedbackContainer( + "### ⏹ Stopped\n\n-# Playback stopped and queue cleared." + ), + ], + flags: MessageFlags.IsComponentsV2, + }); + } catch (error) { + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); + } else { + throw error; + } + } + return; + } + } + // If no player exists but ChatPlay was active, try to recreate it if (!player && guildData.chatPlayChannelId && guildData.chatPlayEnabled) { const voiceChannel = interaction.member?.voice?.channel; @@ -278,17 +403,37 @@ async function handleButtonInteraction(client, interaction) { return interaction.reply({ content: VOICE_CHANNEL_DENIAL, flags: MessageFlags.Ephemeral }); } - await interaction.deferUpdate(); + const index = Number.parseInt(interaction.values[0], 10); + const suggestion = Number.isInteger(index) + ? guildData.suggestions[index] + : null; - const selectedUri = interaction.values[0]; - const suggestion = guildData.suggestions.find( - (s) => (s.info?.uri || s.info?.title) === selectedUri - ); + if (suggestion) { + const position = getTrackQueuePosition(player, suggestion.info?.uri); + if (position) { + return interaction.reply( + ephemeralV2( + buildFeedbackContainer( + `### ⚠️ Duplicate track\n\n-# ${formatDuplicateTrackMessage(suggestion.info?.title, position)}` + ) + ) + ); + } + } + + await interaction.deferUpdate(); if (suggestion) { + const wasIdle = !player.current && !player.playing && !player.paused; suggestion.info.requester = interaction.user; player.queue.add(suggestion); - if (!player.playing && !player.paused && !player.current) player.play(); + if (wasIdle) player.play(); + + const title = suggestion.info?.title || "Unknown"; + const feedback = wasIdle + ? `✅ Now playing **${title}** · *Suggested song*` + : `✅ Added **${title}** — **#${player.queue.length}** in queue · *Suggested song*`; + await notifyPlayerFeedback(client, interaction.guild.id, feedback, 3000); } return; @@ -366,7 +511,11 @@ async function handleButtonInteraction(client, interaction) { flags: MessageFlags.IsComponentsV2, }); } catch (err) { - console.error("[Musicify] Queue pagination error:", err.message); + if (isInteractionExpired(err)) { + await replyExpiredInteraction(interaction); + } else { + console.error("[Musicify] Queue pagination error:", err.message); + } } return; } @@ -415,70 +564,36 @@ async function handleButtonInteraction(client, interaction) { } case "stop": { - // If ChatPlay and 5+ songs in queue, ask for confirmation first const queueLength = player.queue?.length || 0; const isChatPlay = guildData.chatPlayChannelId && guildData.chatPlayMessageId; - if (isChatPlay && queueLength >= 5 && !guildData.stopConfirmPending) { + if (isChatPlay && queueLength >= 20) { + if (guildData.stopConfirmPending === interaction.user.id) { + guildData.stopConfirmPending = null; + await handleStop(client, interaction.guild.id); + return; + } + if (guildData.stopConfirmPending) { + return interaction.followUp( + ephemeralV2( + buildFeedbackContainer( + "### ⚠️ Stop pending\n\n-# Someone else is confirming a stop — wait for them to finish." + ) + ) + ); + } + guildData.stopConfirmPending = interaction.user.id; - // Clear confirmation after 15 seconds setTimeout(() => { if (guildData.stopConfirmPending === interaction.user.id) { guildData.stopConfirmPending = null; } }, 15000); - return interaction.followUp({ - content: `⚠️ There are **${queueLength} songs** in the queue. Click stop again within 15 seconds to confirm.`, - flags: MessageFlags.Ephemeral, - }); + return interaction.followUp( + ephemeralV2(createStopConfirmContainer(queueLength)) + ); } guildData.stopConfirmPending = null; - - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - guildData.suggestions = []; - guildData.previousTracks = []; - - // 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; - } - - player.queue.clear(); - player.stop(); - - if (guildData.twentyFourSeven) { - return; - } - - player.destroy(); + await handleStop(client, interaction.guild.id); return; } @@ -508,6 +623,7 @@ async function handleButtonInteraction(client, interaction) { case "autoplay": { guildData.autoplay = !guildData.autoplay; + persistGuildPlaybackSettings(player.guildId, guildData); needsVisualUpdate = true; break; } @@ -515,6 +631,7 @@ async function handleButtonInteraction(client, interaction) { case "vol_down": { guildData.volume = Math.max(0, guildData.volume - 10); player.setVolume(guildData.volume); + persistGuildPlaybackSettings(player.guildId, guildData); needsVisualUpdate = true; break; } @@ -523,6 +640,7 @@ async function handleButtonInteraction(client, interaction) { case "vol_up": { guildData.volume = Math.min(100, guildData.volume + 10); player.setVolume(guildData.volume); + persistGuildPlaybackSettings(player.guildId, guildData); needsVisualUpdate = true; break; } diff --git a/src/handlers/chatPlayHandler.js b/src/handlers/chatPlayHandler.js index 7b2d788..1d3e648 100644 --- a/src/handlers/chatPlayHandler.js +++ b/src/handlers/chatPlayHandler.js @@ -1,6 +1,5 @@ -const { MessageFlags } = require("discord.js"); const { getGuildData } = require("../utils/playerStore"); -const { createChatPlayLoadingContainer } = require("../utils/components"); +const { playQuery } = require("../services/playQuery"); async function sendChatPlayFeedback(channel, content, timeoutMs = 5000) { try { @@ -11,187 +10,136 @@ async function sendChatPlayFeedback(channel, content, timeoutMs = 5000) { } } +async function notifyPlayerFeedback(client, guildId, content, timeoutMs = 5000) { + const guildData = getGuildData(guildId); + const channelId = guildData.chatPlayChannelId || guildData.playerChannelId; + if (!channelId) return; + + const channel = client.channels.cache.get(channelId); + if (!channel) return; + + await sendChatPlayFeedback(channel, content, timeoutMs); +} + /** * Handle ChatPlay messages * - Deletes user's message - * - Resolves the song + * - Resolves the song via shared playQuery * - Plays in user's VC * - Edits the persistent ChatPlay message (never sends a new one) */ async function handleChatPlayMessage(client, message) { const guildData = getGuildData(message.guild.id); - // Only handle messages in the ChatPlay channel when enabled if (!guildData.chatPlayChannelId || message.channel.id !== guildData.chatPlayChannelId) { return false; } - // Check if ChatPlay is enabled if (!guildData.chatPlayEnabled) return false; - // Ignore bot messages if (message.author.bot) return false; const query = message.content.trim(); if (!query) return false; - if (/(?:youtube\.com|youtu\.be)/i.test(query)) { + if (guildData.chatPlayDeleteMessages !== false) { try { await message.delete(); - const warn = await message.channel.send({ - content: "❌ YouTube links are currently not supported.", - }); - setTimeout(() => warn.delete().catch(() => {}), 5000); - } catch (err) {} - return true; + } catch (err) { + console.error("[Musicify ChatPlay] Failed to delete message:", err.message); + } } - // Delete the user's message immediately - try { - await message.delete(); - } catch (err) { - console.error("[Musicify ChatPlay] Failed to delete message:", err.message); + const result = await playQuery(client, { + guild: message.guild, + member: message.member, + query, + textChannelId: message.channel.id, + source: "chatplay", + }); + + if (!result.ok) { + const prefix = + result.type === "duplicate" + ? "⚠️" + : result.message.startsWith("❌") || result.message.startsWith("⏳") + ? "" + : "❌"; + const feedback = prefix ? `${prefix} ${result.message}` : result.message; + await sendChatPlayFeedback( + message.channel, + feedback, + result.type === "lavalink_down" || result.type === "vc_mismatch" ? 8000 : 5000 + ); + return true; } - // Check if the user is in a voice channel - const voiceChannel = message.member?.voice?.channel; - if (!voiceChannel) { - try { - const warn = await message.channel.send({ - content: "❌ You need to join a voice channel first!", - }); - setTimeout(() => warn.delete().catch(() => {}), 5000); - } catch (err) { - // Can't send in channel — ignore + if (result.type === "playlist") { + let feedbackMsg = `✅ Added **${result.addedCount}** of **${result.totalCount}** tracks from **${result.playlistName}**!`; + if (result.duplicates.length > 0) { + feedbackMsg += `\n⚠️ Skipped ${result.duplicates.length} duplicate(s): ${result.duplicates.slice(0, 3).join(", ")}${result.duplicates.length > 3 ? "..." : ""}`; } + await sendChatPlayFeedback(message.channel, feedbackMsg); return true; } - try { - // Create or get the player - let player = client.riffy.players.get(message.guild.id); - if (!player) { - player = client.riffy.createConnection({ - guildId: message.guild.id, - voiceChannel: voiceChannel.id, - textChannel: message.channel.id, - deaf: true, - }); - } + if (result.startedPlayback) { + await sendChatPlayFeedback(message.channel, `✅ Now playing **${result.title}**!`, 3000); + } else { + await sendChatPlayFeedback( + message.channel, + `✅ Added **${result.title}** — **#${result.queuePosition}** in queue!`, + 3000 + ); + } - // Set volume - player.setVolume(guildData.volume); - - // Update ChatPlay message to show loading state (only for first song) - if (!player.playing && !player.paused && !player.current) { - try { - const loadingContainer = createChatPlayLoadingContainer(); - const channel = client.channels.cache.get(guildData.chatPlayChannelId); - if (channel && guildData.chatPlayMessageId) { - const msg = await channel.messages.fetch(guildData.chatPlayMessageId); - await msg.edit({ - components: [loadingContainer], - flags: MessageFlags.IsComponentsV2, - }); - } - } catch (err) { - // Ignore if message edit fails - } - } + return true; +} - // Resolve the query - const result = await client.riffy.resolve({ - query: query, - requester: message.author, - }); - - const { loadType, tracks, playlistInfo } = result; - - - - // Handle all loadType variants (v3 + v4) - if ( - loadType === "playlist" || - loadType === "PLAYLIST_LOADED" - ) { - const duplicates = []; - const addedTracks = []; - - for (const track of tracks) { - track.info.requester = message.author; - - // Check for duplicates - const isDuplicate = player.queue.some(existingTrack => - existingTrack.info.uri === track.info.uri - ) || (player.current && player.current.info.uri === track.info.uri); - - if (isDuplicate) { - duplicates.push(track.info.title || "Unknown"); - } else { - player.queue.add(track); - addedTracks.push(track.info.title || "Unknown"); - } - } - - // Send feedback for playlist - try { - const playlistName = playlistInfo?.name || "Playlist"; - let feedbackMsg = `✅ Added **${addedTracks.length}** of **${tracks.length}** tracks from **${playlistName}**!`; - if (duplicates.length > 0) { - feedbackMsg += `\n⚠️ Skipped ${duplicates.length} duplicates: ${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? "..." : ""}`; - } - await sendChatPlayFeedback(message.channel, feedbackMsg); - } catch (err) {} - if (!player.playing && !player.paused && !player.current) player.play(); - } else if ( - loadType === "search" || - loadType === "track" || - loadType === "SEARCH_RESULT" || - loadType === "TRACK_LOADED" - ) { - const track = tracks[0]; - if (!track) { - await sendChatPlayFeedback(message.channel, "❌ No results found for that search."); - return true; - } - - // Check for duplicate - const isDuplicate = player.queue.some(existingTrack => - existingTrack.info.uri === track.info.uri - ) || (player.current && player.current.info.uri === track.info.uri); - - if (isDuplicate) { - try { - const feedback = await message.channel.send({ - content: `⚠️ **${track.info.title}** is already in the queue!` - }); - setTimeout(() => feedback.delete().catch(() => {}), 3000); - } catch (err) {} - return true; - } - - track.info.requester = message.author; - player.queue.add(track); - // Send feedback for single track - try { - const feedback = await message.channel.send({ - content: `✅ Added **${track.info.title}** to queue!` - }); - setTimeout(() => feedback.delete().catch(() => {}), 3000); - } catch (err) {} - if (!player.playing && !player.paused && !player.current) player.play(); - } else if (loadType === "empty" || loadType === "EMPTY" || loadType === "NO_MATCHES") { - await sendChatPlayFeedback(message.channel, "❌ No results found for that search."); - } else { - console.log(`[Musicify ChatPlay] Unhandled loadType: "${loadType}"`); - await sendChatPlayFeedback(message.channel, "❌ No results found for that search."); - } - } catch (error) { - console.error("[Musicify ChatPlay] Error:", error.message); - await sendChatPlayFeedback(message.channel, "❌ Something went wrong while searching for that song."); +function isChatPlayChannel(guildId, channelId) { + const guildData = getGuildData(guildId); + return Boolean(guildData.chatPlayChannelId && channelId === guildData.chatPlayChannelId); +} + +function isActiveChatPlayChannel(guildId, channelId) { + const guildData = getGuildData(guildId); + return Boolean( + guildData.chatPlayEnabled && + guildData.chatPlayChannelId && + channelId === guildData.chatPlayChannelId + ); +} + +/** + * In the ChatPlay channel, force slash/button/select replies to be ephemeral + * so command output doesn't clutter the request channel. + */ +function applyChatPlayEphemeral(interaction) { + if (!interaction.guild || !isChatPlayChannel(interaction.guild.id, interaction.channelId)) { + return; } - return true; + const { MessageFlags } = require("discord.js"); + const withEphemeral = (options) => { + if (options == null) return { flags: MessageFlags.Ephemeral }; + if (typeof options === "string") { + return { content: options, flags: MessageFlags.Ephemeral }; + } + return { ...options, flags: (options.flags ?? 0) | MessageFlags.Ephemeral }; + }; + + for (const method of ["reply", "deferReply", "followUp", "editReply"]) { + if (typeof interaction[method] !== "function") continue; + const original = interaction[method].bind(interaction); + interaction[method] = (options) => original(withEphemeral(options)); + } } -module.exports = { handleChatPlayMessage }; +module.exports = { + handleChatPlayMessage, + sendChatPlayFeedback, + notifyPlayerFeedback, + isChatPlayChannel, + isActiveChatPlayChannel, + applyChatPlayEphemeral, +}; diff --git a/src/handlers/chatPlaySetupHandler.js b/src/handlers/chatPlaySetupHandler.js new file mode 100644 index 0000000..d47a7ac --- /dev/null +++ b/src/handlers/chatPlaySetupHandler.js @@ -0,0 +1,390 @@ +const { MessageFlags, PermissionFlagsBits } = require("discord.js"); +const { + buildSetupStep2Container, + buildSetupStep3Container, + buildSetupCancelledContainer, + buildSetupSuccessContainer, + buildChatPlayManageContainer, + buildChatPlayDeleteConfirmContainer, + buildChatPlayDeletedContainer, + buildChangeChannelModal, + finalizeChatPlaySetup, + deleteChatPlay, +} = require("../utils/chatPlaySetup"); +const { getSession, deleteSession, auditChannelPermissions } = require("../utils/chatPlaySetupSession"); +const { getGuildData } = require("../utils/playerStore"); +const { setGuildSetting } = require("../utils/database"); +const { toggleTwentyFourSeven } = require("../services/sessionManager"); +const { refreshChatPlayPlayer } = require("../services/chatPlayPlayer"); +const { safeInteractionUpdate, ephemeralV2, buildErrorContainer } = require("../utils/replies"); + +const SETUP_BUTTONS = new Set([ + "cp_setup_cancel", + "cp_setup_continue", + "cp_setup_back", + "cp_setup_confirm", + "cp_setup_change_channel", + "cp_setup_toggle_slowmode", + "cp_setup_toggle_deletemsg", + "cp_setup_toggle_pin", + "cp_setup_toggle_247", +]); + +const MANAGE_BUTTONS = new Set([ + "cp_manage_enable", + "cp_manage_disable", + "cp_manage_delete", + "cp_manage_delete_confirm", + "cp_manage_delete_cancel", + "cp_manage_toggle_247", +]); + +function canManageChatPlay(memberPermissions) { + return ( + memberPermissions?.has(PermissionFlagsBits.ManageChannels) || + memberPermissions?.has(PermissionFlagsBits.Administrator) + ); +} + +async function handleChatPlayManageButton(client, interaction) { + if (!canManageChatPlay(interaction.memberPermissions)) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Permission required**\n-# You need **Manage Channels** or **Administrator** to manage ChatPlay." + ) + ) + ); + } + + const guildId = interaction.guild.id; + const guildData = getGuildData(guildId); + const customId = interaction.customId; + + if (!guildData.chatPlayChannelId) { + return safeInteractionUpdate(interaction, { + components: [ + buildErrorContainer( + "**Not set up**\n-# ChatPlay isn't configured. Run `/chatplay` to set it up." + ), + ], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_enable") { + guildData.chatPlayEnabled = true; + setGuildSetting(guildId, "chatPlayEnabled", true); + await refreshChatPlayPlayer(client, guildId); + return safeInteractionUpdate(interaction, { + components: [await buildChatPlayManageContainer(guildData, interaction.guild, client)], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_disable") { + guildData.chatPlayEnabled = false; + setGuildSetting(guildId, "chatPlayEnabled", false); + await refreshChatPlayPlayer(client, guildId); + return safeInteractionUpdate(interaction, { + components: [await buildChatPlayManageContainer(guildData, interaction.guild, client)], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_toggle_247") { + const enabling = !guildData.twentyFourSeven; + const voiceChannel = interaction.member.voice?.channel; + + if (!voiceChannel) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + enabling + ? "**Join a voice channel**\n-# Join a VC first — the bot will stay in that channel 24/7." + : "**Join a voice channel**\n-# Join a voice channel to change 24/7 mode." + ) + ) + ); + } + + await toggleTwentyFourSeven(client, guildId, { + voiceChannelId: voiceChannel.id, + textChannelId: guildData.chatPlayChannelId, + enabled: enabling, + }); + + return safeInteractionUpdate(interaction, { + components: [await buildChatPlayManageContainer(getGuildData(guildId), interaction.guild, client)], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_delete") { + return safeInteractionUpdate(interaction, { + components: [buildChatPlayDeleteConfirmContainer(guildData, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_delete_cancel") { + return safeInteractionUpdate(interaction, { + components: [await buildChatPlayManageContainer(guildData, interaction.guild, client)], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_manage_delete_confirm") { + await deleteChatPlay(client, interaction.guild, guildData); + return safeInteractionUpdate(interaction, { + components: [buildChatPlayDeletedContainer()], + flags: MessageFlags.IsComponentsV2, + }); + } +} + +async function handleChatPlaySetupButton(client, interaction) { + const session = getSession(interaction.guild.id, interaction.user.id); + if (!session) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Setup expired**\n-# Run `/chatplay` again to start over." + ) + ) + ); + } + + if (interaction.user.id !== session.userId) { + return interaction.reply( + ephemeralV2( + buildErrorContainer("**Not your setup**\n-# Only the person who started setup can use these buttons.") + ) + ); + } + + const customId = interaction.customId; + const channel = interaction.guild.channels.cache.get(session.channelId); + if (!channel && customId !== "cp_setup_cancel") { + deleteSession(interaction.guild.id, interaction.user.id); + return interaction.update({ + components: [ + buildErrorContainer("**Channel missing**\n-# The setup channel was deleted. Run `/chatplay` again."), + ], + flags: MessageFlags.IsComponentsV2, + }); + } + + if (customId === "cp_setup_cancel") { + deleteSession(interaction.guild.id, interaction.user.id); + await safeInteractionUpdate(interaction, { + components: [buildSetupCancelledContainer()], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_change_channel") { + return interaction.showModal(buildChangeChannelModal()); + } + + if (customId === "cp_setup_toggle_slowmode") { + session.slowmode = !session.slowmode; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_toggle_deletemsg") { + session.deleteMessages = !session.deleteMessages; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_toggle_pin") { + session.pinPlayerMessage = !session.pinPlayerMessage; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_toggle_247") { + if (!session.enable247) { + const voiceChannelId = interaction.member.voice?.channelId; + if (!voiceChannelId) { + return interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Join a voice channel**\n-# Join a VC first — the bot will stay in that channel 24/7." + ) + ) + ); + } + + session.enable247 = true; + session.voiceChannelIdFor247 = voiceChannelId; + } else { + session.enable247 = false; + session.voiceChannelIdFor247 = null; + } + + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_back") { + session.step = 1; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_continue") { + session.step = 2; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep3Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return; + } + + if (customId === "cp_setup_confirm") { + if ( + !interaction.memberPermissions?.has(PermissionFlagsBits.ManageChannels) && + !interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) + ) { + return interaction.followUp( + ephemeralV2( + buildErrorContainer( + "**Permission required**\n-# You need **Manage Channels** or **Administrator** to finish setup." + ) + ) + ); + } + + try { + const { slowmodeWarning, twentyFourSevenWarning, pinWarning } = await finalizeChatPlaySetup( + client, + session, + interaction.guild, + { voiceChannelId: session.voiceChannelIdFor247 ?? null } + ); + await safeInteractionUpdate(interaction, { + components: [ + buildSetupSuccessContainer(session, interaction.guild, { + slowmodeWarning, + twentyFourSevenWarning, + pinWarning, + }), + ], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { + console.error("[Musicify] ChatPlay setup failed:", err.message); + await safeInteractionUpdate(interaction, { + components: [ + buildErrorContainer(`**Setup failed**\n-# ${err.message}`), + ], + flags: MessageFlags.IsComponentsV2, + }); + } + } +} + +async function handleChatPlaySetupModal(client, interaction) { + if (interaction.customId !== "cp_setup_change_channel_modal") { + return false; + } + + const session = getSession(interaction.guild.id, interaction.user.id); + if (!session) { + await interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Setup expired**\n-# Run `/chatplay` again to start over." + ) + ) + ); + return true; + } + + if (interaction.user.id !== session.userId) { + await interaction.reply( + ephemeralV2( + buildErrorContainer("**Not your setup**\n-# Only the person who started setup can change the channel.") + ) + ); + return true; + } + + const selectedChannels = interaction.fields.getSelectedChannels("cp_setup_channel_select"); + const selectedChannel = selectedChannels.first(); + if (!selectedChannel) { + await interaction.reply( + ephemeralV2( + buildErrorContainer("**No channel selected**\n-# Pick a text channel and try again.") + ) + ); + return true; + } + + const channel = + interaction.guild.channels.cache.get(selectedChannel.id) ?? + (await interaction.guild.channels.fetch(selectedChannel.id).catch(() => null)); + + if (!channel?.isTextBased?.()) { + await interaction.reply( + ephemeralV2( + buildErrorContainer("**Invalid channel**\n-# ChatPlay needs a text channel where members can send messages.") + ) + ); + return true; + } + + const { canProceed } = auditChannelPermissions(interaction.guild, channel); + if (!canProceed) { + await interaction.reply( + ephemeralV2( + buildErrorContainer( + "**Missing permissions**\n-# Musicify needs **View Channel**, **Send Messages**, **Embed Links**, and **Connect & Speak** in a voice channel before ChatPlay can use that channel." + ) + ) + ); + return true; + } + + session.channelId = channel.id; + await safeInteractionUpdate(interaction, { + components: [buildSetupStep2Container(session, interaction.guild)], + flags: MessageFlags.IsComponentsV2, + }); + return true; +} + +function isChatPlaySetupButton(customId) { + return SETUP_BUTTONS.has(customId); +} + +function isChatPlayManageButton(customId) { + return MANAGE_BUTTONS.has(customId); +} + +module.exports = { + handleChatPlaySetupButton, + handleChatPlaySetupModal, + handleChatPlayManageButton, + isChatPlaySetupButton, + isChatPlayManageButton, +}; diff --git a/src/handlers/playerHandler.js b/src/handlers/playerHandler.js index ee3e1e4..00b5f32 100644 --- a/src/handlers/playerHandler.js +++ b/src/handlers/playerHandler.js @@ -1,10 +1,16 @@ -const { MessageFlags, AttachmentBuilder, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); +const { MessageFlags, AttachmentBuilder } = require("discord.js"); const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); -const { createNowPlayingContainer, createChatPlayIdleContainer, createChatPlayNowPlayingContainer } = require("../utils/components"); +const { createNowPlayingContainer, createChatPlayNowPlayingContainer } = require("../utils/components"); const { generateMusicCard } = require("../utils/musicard"); const { recordIncident } = require("../utils/incidents"); const { scheduleStatusUpdate } = require("../services/statusMonitor"); +const { + cancelScheduledLeave, + handleQueueEnd, + handlePlayerDisconnect, +} = require("../services/sessionManager"); const config = require("../../config"); +const { limitedResolve, PRIORITY_SUGGESTIONS } = require("../utils/resolveLimiter"); const UPDATE_INTERVAL_MS = 15 * 1000; // 15 seconds const LAVALINK_RECONNECT_INTERVAL_MS = 30 * 60 * 1000; @@ -193,15 +199,13 @@ function setupPlayerHandler(client) { } } - // Clear any idle timeout - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; + // Clear any pending leave timers + cancelScheduledLeave(guildData); + if (guildData.autoplayWatchdog) { + clearTimeout(guildData.autoplayWatchdog); + guildData.autoplayWatchdog = null; } - // Start voice channel monitoring - startVoiceChannelMonitoring(client, player.guildId); - // Generate musicard image const musicardBuffer = await generateMusicCard(track, player, guildData); @@ -226,9 +230,11 @@ function setupPlayerHandler(client) { // Fetch suggestions for the dropdown try { const searchQuery = `${track.info.author} ${track.info.title}`; - const result = await client.riffy.resolve({ + const result = await limitedResolve(client, { query: searchQuery, requester: track.info.requester, + guildId: player.guildId, + priority: PRIORITY_SUGGESTIONS, }); if (result.tracks && result.tracks.length > 1) { guildData.suggestions = result.tracks @@ -246,223 +252,64 @@ function setupPlayerHandler(client) { // --- Queue End --- client.riffy.on("queueEnd", async (player) => { try { - const guildData = getGuildData(player.guildId); - - // Stop the auto-update interval - clearUpdateInterval(guildData); - - if (guildData.autoplay) { - player.autoplay(player); - return; - } + await handleQueueEnd(client, player); + } catch (error) { + console.error("[Musicify] queueEnd error:", error); + } + }); - // 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; - } + // --- Track End (safety net for idle UI) --- + client.riffy.on("trackEnd", async (player) => { + try { + const guildData = getGuildData(player.guildId); + if (guildData.loop !== "none") return; + if (guildData.autoplay) return; - // If NOT 24/7, disconnect after a delay - if (!stayInVC) { - // Clear existing timeout if any - if (guildData.idleTimeout) clearTimeout(guildData.idleTimeout); - - guildData.idleTimeout = setTimeout(() => { - try { - const currentPlayer = client.riffy.players.get(player.guildId); - // Check if player exists and is not actively playing - if (currentPlayer && !currentPlayer.playing && !currentPlayer.paused && !currentPlayer.current) { - currentPlayer.destroy(); - } - } catch (err) { - // player already destroyed - } - guildData.idleTimeout = null; - }, 30000); // 30s idle timeout + const hasQueue = player.queue?.length > 0; + if (!hasQueue && !player.current) { + await handleQueueEnd(client, player); } - - // Clear suggestions - guildData.suggestions = []; } catch (error) { - console.error("[Musicify] queueEnd error:", error); + console.error("[Musicify] trackEnd error:", error); } }); // --- 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 - } + try { + await handlePlayerDisconnect(client, player); + } catch (error) { + console.error("[Musicify] playerDisconnect error:", error); } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - guildData.suggestions = []; - guildData.previousTracks = []; - if (guildData.idleTimeout) clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; }); // --- Track Error / Stuck --- client.riffy.on("trackError", async (player, track, payload) => { console.error(`[Musicify] Track error in ${player.guildId} for "${track.info.title}":`, payload.error || payload); - const guildData = getGuildData(player.guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send(`❌ Failed to play **${track.info.title}** (Lavalink Error). Skipping...`).catch(() => {}); - } - } + const { notifyPlayerFeedback } = require("./chatPlayHandler"); + const { refreshChatPlayPlayer } = require("../services/chatPlayPlayer"); + await notifyPlayerFeedback( + client, + player.guildId, + `❌ Failed to play **${track.info.title}** — skipping...`, + 6000 + ); + await refreshChatPlayPlayer(client, player.guildId); }); client.riffy.on("trackStuck", async (player, track, payload) => { console.warn(`[Musicify] Track stuck in ${player.guildId} for "${track.info.title}" (${payload.thresholdMs}ms)`); - const guildData = getGuildData(player.guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send(`⚠️ Track stuck: **${track.info.title}**. Skipping...`).catch(() => {}); - } - } + const { notifyPlayerFeedback } = require("./chatPlayHandler"); + await notifyPlayerFeedback( + client, + player.guildId, + `⚠️ **${track.info.title}** got stuck — skipping...`, + 6000 + ); }); } -/** - * Start monitoring voice channel for auto-pause/resume functionality - */ -function startVoiceChannelMonitoring(client, guildId) { - const guildData = getGuildData(guildId); - - // Clear existing timeout - if (guildData.voiceStateTimeout) { - clearTimeout(guildData.voiceStateTimeout); - } - - // Check voice channel state every 5 seconds - guildData.voiceStateTimeout = setInterval(() => { - checkVoiceChannelState(client, guildId); - }, 5000); -} - -/** - * Check voice channel state and pause/resume accordingly - */ -function checkVoiceChannelState(client, guildId) { - const guildData = getGuildData(guildId); - const player = client.riffy.players.get(guildId); - - if (!player || !player.voiceChannel) return; - - const voiceChannel = client.channels.cache.get(player.voiceChannel); - if (!voiceChannel) return; - - const membersInChannel = voiceChannel.members.filter(member => !member.user.bot); - const hasUsers = membersInChannel.size > 0; - - // Auto-pause when channel becomes empty - if (!hasUsers && !player.paused && player.playing) { - player.pause(true); - guildData.wasPaused = true; - - // Send notification to text channel - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent("### ⏸️ Music paused\n-# Voice channel is empty. I'll resume when someone joins!") - ); - channel.send({ components: [container], flags: MessageFlags.IsComponentsV2 }).catch(() => {}); - } - } - } - - // Auto-resume when users rejoin - if (hasUsers && guildData.wasPaused && player.paused) { - player.pause(false); - guildData.wasPaused = false; - - // Send notification to text channel - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent("### ▶️ Music resumed\n-# Welcome back!") - ); - channel.send({ components: [container], flags: MessageFlags.IsComponentsV2 }).catch(() => {}); - } - } - } -} - -/** - * Stop voice channel monitoring - */ -function stopVoiceChannelMonitoring(guildId) { - const guildData = getGuildData(guildId); - if (guildData.voiceStateTimeout) { - clearTimeout(guildData.voiceStateTimeout); - guildData.voiceStateTimeout = null; - } -} - -module.exports = { setupPlayerHandler, startVoiceChannelMonitoring, stopVoiceChannelMonitoring }; +module.exports = { + setupPlayerHandler, + refreshPlayerMessage, +}; diff --git a/src/index.js b/src/index.js index a0fb945..0a37acf 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ const path = require("path"); const config = require("../config"); const { loadCommands } = require("./handlers/commandHandler"); const { setupPlayerHandler } = require("./handlers/playerHandler"); +const db = require("./db/sqlite"); +const { runBackup, stopBackupScheduler } = require("./db/backup"); // --- Create Discord Client --- const client = new Client({ @@ -91,3 +93,28 @@ if (!token) { } client.login(token); + +let shuttingDown = false; + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + + console.log(`[Musicify] ${signal} received — saving database backup...`); + stopBackupScheduler(); + await runBackup(db); + + try { + client.destroy(); + } catch {} + + process.exit(0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); + +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); diff --git a/src/services/chatPlayPlayer.js b/src/services/chatPlayPlayer.js new file mode 100644 index 0000000..6f7202a --- /dev/null +++ b/src/services/chatPlayPlayer.js @@ -0,0 +1,68 @@ +const { MessageFlags, AttachmentBuilder } = require("discord.js"); +const { getGuildData } = require("../utils/playerStore"); +const { + createChatPlayIdleContainer, + createChatPlayLoadingContainer, + createChatPlayNowPlayingContainer, +} = require("../utils/components"); +const { generateMusicCard } = require("../utils/musicard"); + +async function editChatPlayMessage(client, guildId, container, files = []) { + const guildData = getGuildData(guildId); + if (!guildData.chatPlayChannelId || !guildData.chatPlayMessageId) return false; + + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (!channel) return false; + + const msg = await channel.messages.fetch(guildData.chatPlayMessageId); + await msg.edit({ + components: [container], + files, + attachments: [], + flags: MessageFlags.IsComponentsV2, + }); + return true; + } catch (err) { + console.error("[Musicify] Failed to edit ChatPlay message:", err.message); + return false; + } +} + +async function showChatPlayLoading(client, guildId) { + return editChatPlayMessage(client, guildId, createChatPlayLoadingContainer()); +} + +async function refreshChatPlayPlayer(client, guildId) { + const guildData = getGuildData(guildId); + if (!guildData.chatPlayChannelId || !guildData.chatPlayMessageId) return; + + if (!guildData.chatPlayEnabled) { + await editChatPlayMessage(client, guildId, createChatPlayIdleContainer(guildData)); + return; + } + + const player = client.riffy?.players.get(guildId); + if (player?.current) { + const musicardBuffer = await generateMusicCard(player.current, player, guildData); + const container = createChatPlayNowPlayingContainer( + player.current, + player, + guildData, + musicardBuffer + ); + const files = musicardBuffer + ? [new AttachmentBuilder(musicardBuffer, { name: "musicard.png" })] + : []; + await editChatPlayMessage(client, guildId, container, files); + return; + } + + await editChatPlayMessage(client, guildId, createChatPlayIdleContainer(guildData)); +} + +module.exports = { + editChatPlayMessage, + showChatPlayLoading, + refreshChatPlayPlayer, +}; diff --git a/src/services/playQuery.js b/src/services/playQuery.js new file mode 100644 index 0000000..e6c1cee --- /dev/null +++ b/src/services/playQuery.js @@ -0,0 +1,212 @@ +const { getGuildData } = require("../utils/playerStore"); +const { isLavalinkAvailable, getLavalinkUnavailableMessage } = require("../utils/lavalink"); +const { getVoiceChannelMismatch, formatVoiceChannelMismatch } = require("../utils/voiceChannel"); +const { getVoicePermissionError } = require("../utils/voicePermissions"); +const { + limitedResolve, + ResolveRateLimitError, + RATE_LIMIT_MESSAGE, +} = require("../utils/resolveLimiter"); +const { + classifyResolveResult, + getPlaylistDisplayName, + queueResolvedTracks, +} = require("../utils/resolveResult"); +const { getTrackQueuePosition, formatDuplicateTrackMessage } = require("../utils/queueUtils"); +const { showChatPlayLoading, refreshChatPlayPlayer } = require("./chatPlayPlayer"); + +const YOUTUBE_PATTERN = /(?:youtube\.com|youtu\.be)/i; + +function isYouTubeQuery(query) { + return YOUTUBE_PATTERN.test(query); +} + +/** + * Resolve a query and queue track(s). Shared by /play and ChatPlay. + */ +async function playQuery(client, { guild, member, query, textChannelId, source }) { + const guildId = guild.id; + const guildData = getGuildData(guildId); + const isChatPlay = source === "chatplay"; + + if (isYouTubeQuery(query)) { + return { + ok: false, + type: "youtube_blocked", + message: "**YouTube not supported**\n-# YouTube links are currently not supported.", + }; + } + + const voiceError = getVoicePermissionError(member, guild); + if (voiceError) { + return { ok: false, type: voiceError.code, message: voiceError.message }; + } + + if (!isLavalinkAvailable(client)) { + return { + ok: false, + type: "lavalink_down", + message: await getLavalinkUnavailableMessage(client), + }; + } + + const voiceChannelId = member.voice.channel.id; + const existingPlayer = client.riffy.players.get(guildId); + const mismatch = getVoiceChannelMismatch(guildData, voiceChannelId, existingPlayer); + if (mismatch) { + return { + ok: false, + type: "vc_mismatch", + message: formatVoiceChannelMismatch(guild, mismatch), + }; + } + + let player = existingPlayer; + if (!player) { + player = client.riffy.createConnection({ + guildId, + voiceChannel: voiceChannelId, + textChannel: textChannelId, + deaf: true, + }); + if (source === "slash") { + guildData.playerChannelId = textChannelId; + } + } + + player.setVolume(guildData.volume); + + const hasActivePlayback = + existingPlayer?.current && (existingPlayer.playing || existingPlayer.paused); + + if (isChatPlay && !hasActivePlayback) { + await showChatPlayLoading(client, guildId); + } + + let result; + try { + const resolveResult = await limitedResolve(client, { + query, + requester: member.user, + guildId, + userId: isChatPlay ? member.user.id : undefined, + }); + + const classified = classifyResolveResult(resolveResult, query); + + if (classified.mode === "empty") { + result = { + ok: false, + type: "no_results", + message: "**No results**\n-# Nothing matched that search.", + }; + return result; + } + + if (classified.mode === "playlist") { + const { duplicates, addedTracks } = queueResolvedTracks( + player, + classified.tracks, + member.user + ); + + if (!player.playing && !player.paused && !player.current) { + player.play(); + } + + result = { + ok: true, + type: "playlist", + playlistName: getPlaylistDisplayName(classified.playlistInfo), + addedCount: addedTracks.length, + totalCount: classified.tracks.length, + duplicates, + }; + return result; + } + + const track = classified.tracks[0]; + if (!track) { + result = { + ok: false, + type: "no_results", + message: "**No results**\n-# Nothing matched that search.", + }; + return result; + } + + const duplicatePosition = getTrackQueuePosition(player, track.info.uri); + if (duplicatePosition) { + result = { + ok: false, + type: "duplicate", + message: formatDuplicateTrackMessage(track.info.title, duplicatePosition), + title: track.info.title, + }; + return result; + } + + const wasIdle = !player.current && !player.playing && !player.paused; + track.info.requester = member.user; + player.queue.add(track); + + if (wasIdle) { + player.play(); + result = { + ok: true, + type: "track", + title: track.info.title, + author: track.info.author, + queuePosition: null, + startedPlayback: true, + }; + } else { + result = { + ok: true, + type: "track", + title: track.info.title, + author: track.info.author, + queuePosition: player.queue.length, + startedPlayback: false, + }; + } + return result; + } catch (error) { + if (error instanceof ResolveRateLimitError) { + result = { + ok: false, + type: "rate_limit", + message: error.message, + }; + return result; + } + + console.error(`[Musicify] playQuery error (${source}):`, error); + result = { + ok: false, + type: "error", + message: isLavalinkAvailable(client) + ? "**Search failed**\n-# Something went wrong while searching. Try again." + : await getLavalinkUnavailableMessage(client), + }; + return result; + } finally { + if (isChatPlay) { + const player = client.riffy?.players.get(guildId); + const shouldRefresh = + !result?.ok || + result?.type === "track" || + result?.type === "playlist" || + Boolean(player?.current && (player.playing || player.paused)); + if (shouldRefresh) { + await refreshChatPlayPlayer(client, guildId); + } + } + } +} + +module.exports = { + playQuery, + isYouTubeQuery, + RATE_LIMIT_MESSAGE, +}; diff --git a/src/services/sessionManager.js b/src/services/sessionManager.js new file mode 100644 index 0000000..6240719 --- /dev/null +++ b/src/services/sessionManager.js @@ -0,0 +1,409 @@ +const { MessageFlags, AttachmentBuilder } = require("discord.js"); +const { getGuildData, clearUpdateInterval } = require("../utils/playerStore"); +const { setGuildSetting, getGuildSettings } = require("../utils/database"); +const { + createChatPlayIdleContainer, + createChatPlayNowPlayingContainer, +} = require("../utils/components"); +const { generateMusicCard } = require("../utils/musicard"); + +const IDLE_LEAVE_MS = 30 * 1000; +const ALONE_LEAVE_MS = 30 * 1000; +const AUTOPLAY_WATCHDOG_MS = 8 * 1000; +const recreatingChatPlay = new Set(); + +function cancelScheduledLeave(guildData) { + if (guildData.idleTimeout) { + clearTimeout(guildData.idleTimeout); + guildData.idleTimeout = null; + } +} + +function cancelAloneLeaveTimer(guildData) { + if (guildData.aloneLeaveTimeout) { + clearTimeout(guildData.aloneLeaveTimeout); + guildData.aloneLeaveTimeout = null; + } +} + +function isPlayerIdle(player) { + if (!player) return true; + const hasQueue = player.queue?.length > 0; + return !player.playing && !player.paused && !hasQueue; +} + +async function resetChatPlayToIdle(client, guildId) { + const guildData = getGuildData(guildId); + if (!guildData.chatPlayChannelId || !guildData.chatPlayMessageId) return; + + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (!channel) return; + + const msg = await channel.messages.fetch(guildData.chatPlayMessageId); + await msg.edit({ + components: [createChatPlayIdleContainer(guildData)], + attachments: [], + flags: MessageFlags.IsComponentsV2, + }); + } catch (err) { + await recreateChatPlayMessage(client, guildId); + } +} + +async function recreateChatPlayMessage(client, guildId) { + if (recreatingChatPlay.has(guildId)) return null; + + const guildData = getGuildData(guildId); + if (!guildData.chatPlayChannelId) return null; + + recreatingChatPlay.add(guildId); + try { + const channel = client.channels.cache.get(guildData.chatPlayChannelId); + if (!channel) return null; + + const player = client.riffy?.players?.get(guildId); + let container; + let files = []; + + if (player?.current) { + const musicardBuffer = await generateMusicCard(player.current, player, guildData); + container = createChatPlayNowPlayingContainer( + player.current, + player, + guildData, + musicardBuffer + ); + if (musicardBuffer) { + files.push(new AttachmentBuilder(musicardBuffer, { name: "musicard.png" })); + } + } else { + container = createChatPlayIdleContainer(guildData); + } + + const chatMsg = await channel.send({ + components: [container], + files, + flags: MessageFlags.IsComponentsV2, + }); + + guildData.chatPlayMessageId = chatMsg.id; + setGuildSetting(guildId, "chatPlayMessageId", chatMsg.id); + + if (guildData.chatPlayPinPlayerMessage) { + const { pinChatPlayPlayerMessage } = require("../utils/chatPlaySetup"); + await pinChatPlayPlayerMessage(channel, chatMsg, channel.guild.members.me); + } + + console.log(`[Musicify] Recreated ChatPlay message for guild ${guildId}`); + return chatMsg; + } catch (err) { + console.error( + `[Musicify] Failed to recreate ChatPlay message for guild ${guildId}:`, + err.message + ); + return null; + } finally { + recreatingChatPlay.delete(guildId); + } +} + +async function clearRegularPlayerMessage(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; +} + +function scheduleIdleLeave(client, guildId) { + const guildData = getGuildData(guildId); + if (guildData.twentyFourSeven) return; + + cancelScheduledLeave(guildData); + + guildData.idleTimeout = setTimeout(() => { + try { + const currentGuildData = getGuildData(guildId); + if (currentGuildData.twentyFourSeven) return; + + const player = client.riffy?.players.get(guildId); + if (player && isPlayerIdle(player)) { + player.destroy(); + } + } catch (err) { + // player already destroyed + } + guildData.idleTimeout = null; + }, IDLE_LEAVE_MS); +} + +function scheduleAloneLeave(client, guildId) { + const guildData = getGuildData(guildId); + if (guildData.twentyFourSeven) return; + + cancelAloneLeaveTimer(guildData); + + guildData.aloneLeaveTimeout = setTimeout(async () => { + guildData.aloneLeaveTimeout = null; + + const currentGuildData = getGuildData(guildId); + if (currentGuildData.twentyFourSeven) return; + + const guild = client.guilds.cache.get(guildId); + const botMember = guild?.members.cache.get(client.user.id); + const voiceChannel = botMember?.voice?.channel; + + if (!voiceChannel) return; + + const humans = voiceChannel.members.filter((m) => !m.user.bot); + if (humans.size > 0) return; + + const player = client.riffy?.players.get(guildId); + if (player) { + await transitionToIdle(client, guildId, { scheduleLeave: false }); + player.destroy(); + } + }, ALONE_LEAVE_MS); +} + +function cancelAloneLeaveIfUsersPresent(client, guildId) { + const guild = client.guilds.cache.get(guildId); + const botMember = guild?.members.cache.get(client.user.id); + const voiceChannel = botMember?.voice?.channel; + if (!voiceChannel) return; + + const humans = voiceChannel.members.filter((m) => !m.user.bot); + if (humans.size > 0) { + cancelAloneLeaveTimer(getGuildData(guildId)); + } +} + +async function transitionToIdle(client, guildId, { scheduleLeave = true } = {}) { + const guildData = getGuildData(guildId); + + clearUpdateInterval(guildData); + guildData.suggestions = []; + + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + await resetChatPlayToIdle(client, guildId); + } else { + await clearRegularPlayerMessage(client, guildData); + } + + if (scheduleLeave) { + scheduleIdleLeave(client, guildId); + } +} + +async function handleQueueEnd(client, player) { + const guildData = getGuildData(player.guildId); + clearUpdateInterval(guildData); + + if (guildData.autoplay) { + try { + player.autoplay(player); + } catch (err) { + console.error("[Musicify] Autoplay failed:", err.message); + await transitionToIdle(client, player.guildId); + return; + } + + if (guildData.autoplayWatchdog) { + clearTimeout(guildData.autoplayWatchdog); + } + + guildData.autoplayWatchdog = setTimeout(async () => { + guildData.autoplayWatchdog = null; + const currentPlayer = client.riffy?.players.get(player.guildId); + if (!currentPlayer?.current && !currentPlayer?.playing) { + await transitionToIdle(client, player.guildId); + } + }, AUTOPLAY_WATCHDOG_MS); + + return; + } + + await transitionToIdle(client, player.guildId); +} + +async function handlePlayerDisconnect(client, player) { + const guildData = getGuildData(player.guildId); + + clearUpdateInterval(guildData); + cancelScheduledLeave(guildData); + cancelAloneLeaveTimer(guildData); + + if (guildData.autoplayWatchdog) { + clearTimeout(guildData.autoplayWatchdog); + guildData.autoplayWatchdog = null; + } + + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + await resetChatPlayToIdle(client, player.guildId); + } else { + await clearRegularPlayerMessage(client, guildData); + } + + guildData.suggestions = []; + guildData.previousTracks = []; +} + +async function ensureVoiceConnection(client, guildId, voiceChannelId, textChannelId) { + let player = client.riffy?.players.get(guildId); + if (!player) { + player = client.riffy.createConnection({ + guildId, + voiceChannel: voiceChannelId, + textChannel: textChannelId, + deaf: true, + }); + } + return player; +} + +async function toggleTwentyFourSeven(client, guildId, { voiceChannelId, textChannelId, enabled = null }) { + const guildData = getGuildData(guildId); + const dbSettings = getGuildSettings(guildId); + const newState = enabled !== null ? enabled : !dbSettings.twentyFourSeven; + + guildData.twentyFourSeven = newState; + setGuildSetting(guildId, "twentyFourSeven", newState); + + if (newState) { + cancelScheduledLeave(guildData); + cancelAloneLeaveTimer(guildData); + + guildData.boundVoiceChannelId = voiceChannelId; + setGuildSetting(guildId, "boundVoiceChannelId", voiceChannelId); + + await ensureVoiceConnection(client, guildId, voiceChannelId, textChannelId); + } else { + guildData.boundVoiceChannelId = null; + setGuildSetting(guildId, "boundVoiceChannelId", null); + + const player = client.riffy?.players.get(guildId); + if (isPlayerIdle(player)) { + await transitionToIdle(client, guildId, { scheduleLeave: false }); + if (player) { + player.destroy(); + } + } + } + + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + const player = client.riffy?.players.get(guildId); + if (player?.current) { + const { refreshPlayerMessage } = require("../handlers/playerHandler"); + await refreshPlayerMessage(client, guildId); + } else { + await resetChatPlayToIdle(client, guildId); + } + } + + return newState; +} + +function restoreSessionFromDatabase(guildId, settings) { + const guildData = getGuildData(guildId); + + if (settings.twentyFourSeven) { + guildData.twentyFourSeven = true; + } + if (settings.boundVoiceChannelId) { + guildData.boundVoiceChannelId = settings.boundVoiceChannelId; + } + if (typeof settings.defaultVolume === "number") { + guildData.volume = settings.defaultVolume; + } + if (typeof settings.defaultAutoplay === "boolean") { + guildData.autoplay = settings.defaultAutoplay; + } +} + +async function reconnectTwentyFourSeven(client, guildId) { + const guildData = getGuildData(guildId); + if (!guildData.twentyFourSeven || !guildData.boundVoiceChannelId) return; + + const guild = client.guilds.cache.get(guildId); + if (!guild) return; + + const voiceChannel = guild.channels.cache.get(guildData.boundVoiceChannelId); + if (!voiceChannel) return; + + const botMember = guild.members.cache.get(client.user.id); + if (botMember?.voice?.channelId === guildData.boundVoiceChannelId) return; + + const textChannelId = guildData.chatPlayChannelId || guildData.playerChannelId; + if (!textChannelId) return; + + try { + await ensureVoiceConnection( + client, + guildId, + guildData.boundVoiceChannelId, + textChannelId + ); + } catch (err) { + console.error(`[Musicify] Failed to restore 24/7 connection for guild ${guildId}:`, err.message); + } +} + +async function handleStop(client, guildId, { destroyPlayer = null } = {}) { + const guildData = getGuildData(guildId); + const player = client.riffy?.players.get(guildId); + + clearUpdateInterval(guildData); + cancelScheduledLeave(guildData); + guildData.suggestions = []; + guildData.previousTracks = []; + + if (player) { + player.queue.clear(); + player.stop(); + } + + const shouldStay = destroyPlayer === false || (destroyPlayer === null && guildData.twentyFourSeven); + + if (shouldStay) { + await resetChatPlayToIdle(client, guildId); + if (!guildData.chatPlayChannelId) { + await clearRegularPlayerMessage(client, guildData); + } + return { stayed: true }; + } + + await transitionToIdle(client, guildId, { scheduleLeave: false }); + if (player) { + player.destroy(); + } + + return { stayed: false }; +} + +module.exports = { + cancelScheduledLeave, + cancelAloneLeaveTimer, + cancelAloneLeaveIfUsersPresent, + scheduleAloneLeave, + resetChatPlayToIdle, + recreateChatPlayMessage, + clearRegularPlayerMessage, + transitionToIdle, + handleQueueEnd, + handlePlayerDisconnect, + toggleTwentyFourSeven, + restoreSessionFromDatabase, + reconnectTwentyFourSeven, + handleStop, + isPlayerIdle, +}; diff --git a/src/services/statusMonitor.js b/src/services/statusMonitor.js index bcbc652..9522bd6 100644 --- a/src/services/statusMonitor.js +++ b/src/services/statusMonitor.js @@ -1,11 +1,10 @@ -const fs = require("fs"); -const path = require("path"); const { WebhookClient, MessageFlags } = require("discord.js"); const config = require("../../config"); const { buildStatusContainer } = require("../utils/statusPage"); +const { getAppSetting, setAppSetting, deleteAppSetting } = require("../db/appSettings"); -const STATUS_STORE_PATH = path.join(__dirname, "..", "..", "data", "status-webhook.json"); const UPDATE_INTERVAL_MS = 60 * 1000; +const STATUS_MESSAGE_KEY = "status_webhook_message_id"; let webhookClient = null; let messageId = null; @@ -14,24 +13,15 @@ let lastSnapshot = null; let activeClient = null; function loadMessageId() { - if (!fs.existsSync(STATUS_STORE_PATH)) return null; - - try { - const data = JSON.parse(fs.readFileSync(STATUS_STORE_PATH, "utf-8")); - return data.messageId || null; - } catch { - return null; - } + return getAppSetting(STATUS_MESSAGE_KEY); } function saveMessageId(id) { - const dir = path.dirname(STATUS_STORE_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + if (id) { + setAppSetting(STATUS_MESSAGE_KEY, id); + } else { + deleteAppSetting(STATUS_MESSAGE_KEY); } - - const payload = id ? { messageId: id } : {}; - fs.writeFileSync(STATUS_STORE_PATH, JSON.stringify(payload, null, 2), "utf-8"); } function getStatusSnapshot(client) { diff --git a/src/utils/chatPlaySetup.js b/src/utils/chatPlaySetup.js new file mode 100644 index 0000000..6aeafc0 --- /dev/null +++ b/src/utils/chatPlaySetup.js @@ -0,0 +1,642 @@ +const { + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SectionBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + PermissionFlagsBits, + MessageType, + ModalBuilder, + LabelBuilder, + ChannelSelectMenuBuilder, + ChannelType, +} = require("discord.js"); +const { getGuildData } = require("../utils/playerStore"); +const { setGuildSettings } = require("../utils/database"); +const { createChatPlayIdleContainer } = require("./components"); +const { toggleTwentyFourSeven } = require("../services/sessionManager"); +const { deleteSession } = require("./chatPlaySetupSession"); + +const SETUP_LOGO = "<:Musicify_Logo:1517828581638541493>"; +const SETUP_STEPS = 2; +const EMOJI_UNCHECKED = { id: "1522858566141214844", name: "Musicify_Unchecked" }; +const EMOJI_CHECKED = { id: "1522858608478388275", name: "Musicify_Checked" }; + +function buildSetupStepHeader(step) { + return `## ${SETUP_LOGO} Musicify ChatPlay\n-# Step ${step} of ${SETUP_STEPS}`; +} + +function buildToggleButton(customId, enabled) { + return new ButtonBuilder() + .setCustomId(customId) + .setStyle(ButtonStyle.Secondary) + .setEmoji(enabled ? EMOJI_CHECKED : EMOJI_UNCHECKED); +} + +function buildBehaviourSection(title, description, customId, enabled) { + return new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`${title}\n-# ${description}`) + ) + .setButtonAccessory(buildToggleButton(customId, enabled)); +} + +async function deletePinNotification(channel) { + for (let attempt = 0; attempt < 4; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 350)); + } + const messages = await channel.messages.fetch({ limit: 10 }); + const pinNotice = messages.find((m) => m.type === MessageType.ChannelPinnedMessage); + if (pinNotice) { + await pinNotice.delete().catch(() => {}); + return; + } + } +} + +async function pinChatPlayPlayerMessage(channel, message, botMember) { + const perms = botMember?.permissionsIn(channel); + const canPin = + perms?.has(PermissionFlagsBits.PinMessages) || + perms?.has(PermissionFlagsBits.ManageMessages); + + if (!canPin) { + return { + ok: false, + warning: + "Couldn't pin the player message — grant **Pin Messages** or **Manage Messages** in this channel.", + }; + } + + try { + await message.pin(); + await deletePinNotification(channel); + return { ok: true }; + } catch (err) { + return { + ok: false, + warning: "Couldn't pin the player message — check **Pin Messages** permission.", + }; + } +} + +function buildChannelSection(session, guild) { + const channel = guild.channels.cache.get(session.channelId); + const channelLine = channel ? `<#${session.channelId}>` : "Unknown channel"; + + return new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `**ChatPlay channel**\n-# ChatPlay will be set up in ${channelLine}.` + ) + ) + .setButtonAccessory( + new ButtonBuilder() + .setCustomId("cp_setup_change_channel") + .setLabel("Edit") + .setStyle(ButtonStyle.Secondary) + ); +} + +function buildChangeChannelModal() { + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId("cp_setup_channel_select") + .setPlaceholder("Choose a text channel") + .setRequired(true) + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); + + const channelLabel = new LabelBuilder() + .setLabel("ChatPlay channel") + .setDescription("Where members type song names to request music") + .setChannelSelectMenuComponent(channelSelect); + + return new ModalBuilder() + .setCustomId("cp_setup_change_channel_modal") + .setTitle("Edit ChatPlay channel") + .addLabelComponents(channelLabel); +} + +function buildReviewSection(title, description, customId, valueLabel) { + return new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`${title}\n-# ${description}`) + ) + .setButtonAccessory( + new ButtonBuilder() + .setCustomId(customId) + .setLabel(valueLabel) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true) + ); +} + +function buildManage247Section(guildData, command247) { + const enable247 = Boolean(guildData.twentyFourSeven); + + return new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `**24/7 mode**\n-# Click **${enable247 ? "Turn off" : "Turn on"}** to toggle, or use ${command247}.` + ) + ) + .setButtonAccessory( + new ButtonBuilder() + .setCustomId("cp_manage_toggle_247") + .setLabel(enable247 ? "Turn off" : "Turn on") + .setStyle(enable247 ? ButtonStyle.Secondary : ButtonStyle.Success) + ); +} + +async function getCommandMention(client, name) { + try { + const commands = await client.application.commands.fetch(); + const cmd = commands?.find((c) => c.name === name); + if (cmd) return ``; + } catch {} + return `\`/${name}\``; +} + +const SETUP_BEHAVIOURS = [ + { + title: "**Slowmode (5s)** *(recommended)*", + description: "Reduces spam in the request channel.", + toggleCustomId: "cp_setup_toggle_slowmode", + reviewCustomId: "cp_setup_review_slowmode", + isEnabled: (session) => session.slowmode, + reviewValue: (session) => (session.slowmode ? "5 seconds" : "Off"), + }, + { + title: "**Auto-delete requests** *(recommended)*", + description: "Removes song messages to keep the channel clean.", + toggleCustomId: "cp_setup_toggle_deletemsg", + reviewCustomId: "cp_setup_review_deletemsg", + isEnabled: (session) => session.deleteMessages, + reviewValue: (session) => (session.deleteMessages ? "On" : "Off"), + }, + { + title: "**Pin player message** *(recommended)*", + description: "Pins the player message so it stays easy to find.", + toggleCustomId: "cp_setup_toggle_pin", + reviewCustomId: "cp_setup_review_pin", + isEnabled: (session) => session.pinPlayerMessage, + reviewValue: (session) => (session.pinPlayerMessage ? "On" : "Off"), + }, + { + title: "**24/7 mode**", + description: "Join a VC to enable — bot stays in that channel 24/7.", + toggleCustomId: "cp_setup_toggle_247", + reviewCustomId: "cp_setup_review_247", + isEnabled: (session) => session.enable247, + reviewValue: (session) => (session.enable247 ? "On" : "Off"), + }, +]; + +function getBehaviourDescription(option, session, guild) { + if (typeof option.getDescription === "function") { + return option.getDescription(session, guild); + } + return option.description; +} + +function buildSetupStep2Container(session, guild) { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(buildSetupStepHeader(1)) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addSectionComponents(buildChannelSection(session, guild)); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Channel behaviour**\n" + + "-# Tap the toggle next to each option." + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + container.addSectionComponents( + ...SETUP_BEHAVIOURS.map((option) => + buildBehaviourSection( + option.title, + getBehaviourDescription(option, session, guild), + option.toggleCustomId, + option.isEnabled(session) + ) + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + const navRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("cp_setup_cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId("cp_setup_continue") + .setLabel("Continue") + .setStyle(ButtonStyle.Primary) + ); + + container.addActionRowComponents(navRow); + return container; +} + +function stripRecommended(title) { + return title.replace(/ \*\(recommended\)\*/g, ""); +} + +function buildSetupStep3Container(session, guild) { + const channel = guild.channels.cache.get(session.channelId); + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `${buildSetupStepHeader(2)}\n\n` + + "**Review & confirm**\n" + + `-# Channel: ${channel ? `<#${session.channelId}>` : "Unknown"}` + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "**Your settings**\n" + + "-# Review your choices before creating ChatPlay." + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + container.addSectionComponents( + ...SETUP_BEHAVIOURS.map((option) => + buildReviewSection( + stripRecommended(option.title), + getBehaviourDescription(option, session, guild), + option.reviewCustomId, + option.reviewValue(session, guild) + ) + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("cp_setup_back") + .setLabel("Back") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId("cp_setup_confirm") + .setLabel("Create ChatPlay") + .setStyle(ButtonStyle.Success) + ); + + container.addActionRowComponents(row); + return container; +} + +function buildSetupCancelledContainer() { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### Setup cancelled\n\n-# ChatPlay was not changed." + ) + ); + return container; +} + +function hasChatPlayConfigured(guildData) { + return Boolean(guildData.chatPlayChannelId); +} + +function buildManageSettingsSections(guildData, command247) { + const slowmode = guildData.chatPlaySlowmode !== false; + const deleteMessages = guildData.chatPlayDeleteMessages !== false; + const pinPlayer = guildData.chatPlayPinPlayerMessage !== false; + + return [ + buildReviewSection( + "**Slowmode (5s)**", + "Reduces spam in the request channel.", + "cp_manage_review_slowmode", + slowmode ? "5 seconds" : "Off" + ), + buildReviewSection( + "**Auto-delete requests**", + "Removes song messages to keep the channel clean.", + "cp_manage_review_deletemsg", + deleteMessages ? "On" : "Off" + ), + buildReviewSection( + "**Pin player message**", + "Pins the player message so it stays easy to find.", + "cp_manage_review_pin", + pinPlayer ? "On" : "Off" + ), + buildManage247Section(guildData, command247), + ]; +} + +async function buildChatPlayManageContainer(guildData, guild, client) { + const command247 = await getCommandMention(client, "247"); + const channel = guild.channels.cache.get(guildData.chatPlayChannelId); + const channelLine = channel ? `<#${guildData.chatPlayChannelId}>` : "Unknown channel"; + const statusLine = guildData.chatPlayEnabled + ? `Enabled in ${channelLine}` + : `Disabled in ${channelLine}`; + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `## ${SETUP_LOGO} Musicify ChatPlay\n\n` + + "**Status**\n" + + `-# ${statusLine}` + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("**Your settings**") + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + + container.addSectionComponents(...buildManageSettingsSections(guildData, command247)); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("cp_manage_enable") + .setLabel("Enable") + .setStyle(ButtonStyle.Success) + .setDisabled(Boolean(guildData.chatPlayEnabled)), + new ButtonBuilder() + .setCustomId("cp_manage_disable") + .setLabel("Disable") + .setStyle(ButtonStyle.Secondary) + .setDisabled(!guildData.chatPlayEnabled), + new ButtonBuilder() + .setCustomId("cp_manage_delete") + .setLabel("Delete ChatPlay") + .setStyle(ButtonStyle.Danger) + ) + ); + + return container; +} + +function buildChatPlayDeleteConfirmContainer(guildData, guild) { + const channel = guild.channels.cache.get(guildData.chatPlayChannelId); + const channelLine = channel ? `<#${guildData.chatPlayChannelId}>` : "Unknown channel"; + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "## ⚠️ Delete ChatPlay\n\n" + + "**This action is permanent**\n" + + `-# ChatPlay will be removed from ${channelLine}.\n\n` + + "**What will be deleted**\n" + + "-# • The player message in this channel\n" + + "-# • All ChatPlay settings for this server\n\n" + + "-# This cannot be undone. Press **Confirm** to proceed." + ) + ); + + container.addSeparatorComponents(new SeparatorBuilder().setDivider(false)); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("cp_manage_delete_confirm") + .setLabel("Confirm") + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId("cp_manage_delete_cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) + ) + ); + + return container; +} + +function buildChatPlayDeletedContainer() { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### 🗑 ChatPlay Deleted\n\n" + + "**Removed**\n" + + "-# ChatPlay has been removed from this server.\n\n" + + "**Set up again**\n" + + "-# Run `/chatplay` in your music channel to start fresh." + ) + ); + return container; +} + +async function deleteChatPlay(client, guild, guildData) { + const guildId = guild.id; + const channelId = guildData.chatPlayChannelId; + + if (channelId && guildData.chatPlayMessageId) { + const messageId = guildData.chatPlayMessageId; + guildData.chatPlayMessageId = null; + setGuildSettings(guildId, { chatPlayMessageId: null }); + + try { + const channel = + client.channels.cache.get(channelId) ?? (await client.channels.fetch(channelId)); + const msg = await channel.messages.fetch(messageId); + await msg.delete(); + } catch {} + } + + if (channelId && guildData.chatPlaySlowmode) { + try { + const channel = guild.channels.cache.get(channelId); + const botMember = guild.members.me; + if ( + channel && + botMember?.permissionsIn(channel)?.has(PermissionFlagsBits.ManageChannels) + ) { + await channel.setRateLimitPerUser(0, "ChatPlay removed"); + } + } catch {} + } + + guildData.chatPlayChannelId = null; + guildData.chatPlayMessageId = null; + guildData.chatPlayEnabled = false; + + if (guildData.playerChannelId === channelId) { + guildData.playerChannelId = null; + } + + setGuildSettings(guildId, { + chatPlayChannelId: null, + chatPlayMessageId: null, + chatPlayEnabled: false, + }); +} + +function buildSetupSuccessContainer(session, guild, extras = {}) { + const lines = [ + "### ✅ ChatPlay is live\n", + "**Try it now**", + "1. Join a voice channel", + `2. Type a song name in <#${session.channelId}>`, + ]; + + if (session.pinPlayerMessage && !extras.pinWarning) { + lines.push("", "-# Player message pinned in this channel."); + } else if (!session.pinPlayerMessage) { + lines.push("", "**Recommended**", "• Pin the player message so it stays visible"); + } + + if (session.enable247) { + lines.push("-# 24/7 mode is enabled — the bot will stay in voice between songs."); + } + + if (extras.twentyFourSevenWarning) { + lines.push("", "**Note**", `-# ${extras.twentyFourSevenWarning}`); + } + + if (extras.slowmodeWarning) { + lines.push("", "**Note**", `-# ${extras.slowmodeWarning}`); + } + + if (extras.pinWarning) { + lines.push("", "**Note**", `-# ${extras.pinWarning}`); + } + + lines.push("", "**Manage**", "-# Run `/chatplay` to enable, disable, or delete ChatPlay."); + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(lines.join("\n")) + ); + return container; +} + +async function finalizeChatPlaySetup(client, session, guild, { voiceChannelId = null } = {}) { + const guildId = session.guildId; + const guildData = getGuildData(guildId); + const channel = guild.channels.cache.get(session.channelId); + + if (!channel) { + throw new Error("Setup channel no longer exists."); + } + + if (guildData.chatPlayChannelId && guildData.chatPlayMessageId) { + const oldMessageId = guildData.chatPlayMessageId; + guildData.chatPlayMessageId = null; + setGuildSettings(guildId, { chatPlayMessageId: null }); + + try { + const oldChannel = client.channels.cache.get(guildData.chatPlayChannelId); + if (oldChannel) { + const oldMsg = await oldChannel.messages.fetch(oldMessageId); + await oldMsg.delete(); + } + } catch {} + } + + guildData.chatPlaySlowmode = session.slowmode; + guildData.chatPlayDeleteMessages = session.deleteMessages; + guildData.chatPlayPinPlayerMessage = session.pinPlayerMessage; + + let twentyFourSevenWarning = ""; + const voiceChannelIdFor247 = session.voiceChannelIdFor247 ?? voiceChannelId ?? null; + if (session.enable247) { + if (voiceChannelIdFor247) { + await toggleTwentyFourSeven(client, guildId, { + voiceChannelId: voiceChannelIdFor247, + textChannelId: session.channelId, + enabled: true, + }); + } else { + twentyFourSevenWarning = + "24/7 wasn't enabled — join a voice channel and run `/247` to turn it on."; + } + } + + const container = createChatPlayIdleContainer(guildData); + const chatMsg = await channel.send({ + components: [container], + flags: MessageFlags.IsComponentsV2, + }); + + let slowmodeWarning = ""; + let pinWarning = ""; + const botMember = guild.members.me; + const canManageChannel = botMember + ?.permissionsIn(channel) + ?.has(PermissionFlagsBits.ManageChannels); + + if (canManageChannel) { + try { + await channel.setRateLimitPerUser( + session.slowmode ? 5 : 0, + session.slowmode ? "ChatPlay setup — prevents spam" : "ChatPlay setup — slowmode disabled" + ); + } catch (err) { + slowmodeWarning = "Couldn't update slowmode — check **Manage Channels** permission."; + } + } else if (session.slowmode) { + slowmodeWarning = "Slowmode wasn't applied — grant **Manage Channels** in this channel."; + } + + if (session.pinPlayerMessage) { + const pinResult = await pinChatPlayPlayerMessage(channel, chatMsg, botMember); + if (!pinResult.ok) { + pinWarning = pinResult.warning; + } + } + + guildData.chatPlayChannelId = session.channelId; + guildData.chatPlayMessageId = chatMsg.id; + guildData.chatPlayEnabled = true; + guildData.playerChannelId = session.channelId; + + setGuildSettings(guildId, { + chatPlayChannelId: session.channelId, + chatPlayMessageId: chatMsg.id, + chatPlayEnabled: true, + chatPlaySlowmode: session.slowmode, + chatPlayDeleteMessages: session.deleteMessages, + chatPlayPinPlayerMessage: session.pinPlayerMessage, + }); + + deleteSession(guildId, session.userId); + + return { slowmodeWarning, twentyFourSevenWarning, pinWarning }; +} + +module.exports = { + buildSetupStep2Container, + buildSetupStep3Container, + buildChangeChannelModal, + buildSetupCancelledContainer, + buildSetupSuccessContainer, + buildChatPlayManageContainer, + buildChatPlayDeleteConfirmContainer, + buildChatPlayDeletedContainer, + hasChatPlayConfigured, + finalizeChatPlaySetup, + deleteChatPlay, + pinChatPlayPlayerMessage, +}; diff --git a/src/utils/chatPlaySetupSession.js b/src/utils/chatPlaySetupSession.js new file mode 100644 index 0000000..a5d384c --- /dev/null +++ b/src/utils/chatPlaySetupSession.js @@ -0,0 +1,99 @@ +const { PermissionFlagsBits } = require("discord.js"); + +const SETUP_TTL_MS = 15 * 60 * 1000; +const sessions = new Map(); + +function sessionKey(guildId, userId) { + return `${guildId}:${userId}`; +} + +function createSession(guildId, userId, channelId) { + const key = sessionKey(guildId, userId); + const session = { + step: 1, + guildId, + userId, + channelId, + slowmode: true, + deleteMessages: true, + pinPlayerMessage: true, + enable247: false, + voiceChannelIdFor247: null, + expiresAt: Date.now() + SETUP_TTL_MS, + }; + sessions.set(key, session); + return session; +} + +function getSession(guildId, userId) { + const key = sessionKey(guildId, userId); + const session = sessions.get(key); + if (!session) return null; + if (Date.now() > session.expiresAt) { + sessions.delete(key); + return null; + } + return session; +} + +function deleteSession(guildId, userId) { + sessions.delete(sessionKey(guildId, userId)); +} + +function auditChannelPermissions(guild, channel) { + const botMember = guild.members.me; + if (!botMember) { + return { checks: [], canProceed: false }; + } + + const perms = botMember.permissionsIn(channel); + const checks = [ + { + label: "View Channel", + ok: perms.has(PermissionFlagsBits.ViewChannel), + }, + { + label: "Send Messages", + ok: perms.has(PermissionFlagsBits.SendMessages), + }, + { + label: "Embed Links", + ok: perms.has(PermissionFlagsBits.EmbedLinks), + }, + { + label: "Manage Channels", + ok: perms.has(PermissionFlagsBits.ManageChannels), + optional: true, + }, + ]; + + const voiceChannels = guild.channels.cache.filter((c) => c.isVoiceBased?.()); + let voiceOk = false; + for (const vc of voiceChannels.values()) { + const vcPerms = botMember.permissionsIn(vc); + if ( + vcPerms.has(PermissionFlagsBits.Connect) && + vcPerms.has(PermissionFlagsBits.Speak) + ) { + voiceOk = true; + break; + } + } + + checks.push({ + label: "Connect & Speak", + ok: voiceOk, + }); + + const required = checks.filter((c) => !c.optional); + const canProceed = required.every((c) => c.ok); + + return { checks, canProceed }; +} + +module.exports = { + createSession, + getSession, + deleteSession, + auditChannelPermissions, +}; diff --git a/src/utils/components.js b/src/utils/components.js index f2b0202..cd207c8 100644 --- a/src/utils/components.js +++ b/src/utils/components.js @@ -24,6 +24,58 @@ function formatDuration(ms) { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } +function truncateText(text, max, fallback = "Unknown") { + const value = (text || fallback).trim(); + if (value.length <= max) return value; + return `${value.slice(0, Math.max(1, max - 1))}…`; +} + +/** Single-line queue entry — compact for mobile */ +function formatQueueTrackLine(index, track, { titleMax = 34, authorMax = 18 } = {}) { + const title = truncateText(track.info?.title, titleMax); + const author = truncateText(track.info?.author, authorMax, "?"); + const duration = formatDuration(track.info?.length); + return `-# **${index}.** ${title} — ${author} · \`${duration}\``; +} + +function formatUpNextPreview(queue, maxItems = 3) { + if (!queue?.length) { + return null; + } + + const lines = ["-# **Up next**"]; + const upcoming = queue.slice(0, maxItems); + for (let i = 0; i < upcoming.length; i++) { + lines.push(formatQueueTrackLine(i + 1, upcoming[i], { titleMax: 32, authorMax: 16 })); + } + if (queue.length > maxItems) { + lines.push(`-# ***+${queue.length - maxItems} in queue***`); + } + return lines.join("\n"); +} + +/** Discord select option values are capped at 100 chars — use index, not URI */ +function buildSongSuggestionSelectMenu(suggestions) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId("song_suggestion") + .setPlaceholder("🎶 Pick a similar song") + .setMinValues(1) + .setMaxValues(1); + + const items = suggestions.slice(0, 10); + for (let i = 0; i < items.length; i++) { + const suggestion = items[i]; + selectMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(truncateText(suggestion.info?.title, 100)) + .setDescription(truncateText(suggestion.info?.author, 100, "Unknown Artist")) + .setValue(String(i)) + ); + } + + return selectMenu; +} + /** * Create the "Now Playing" container using Components V2 * Design matches the example screenshot with -# subtext @@ -62,27 +114,14 @@ function createNowPlayingContainer(track, player, guildData, musicardBuffer) { ) ); - // --- Separator --- - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - - // --- Next on Deck --- - const queue = player.queue; - let nextContent = "**Next on Deck**\n"; - if (queue && queue.length > 0) { - const upcoming = queue.slice(0, 3); - for (let i = 0; i < upcoming.length; i++) { - const t = upcoming[i]; - nextContent += `**${i + 1}.** ${(t.info.title || "Unknown").substring(0, 45)} - ${(t.info.author || "Unknown Artist").substring(0, 30)} · ${formatDuration(t.info.length)}\n`; - } - if (queue.length > 3) { - nextContent += `-# *...and ${queue.length - 3} more in queue*`; - } - } else { - nextContent += "-# *No upcoming tracks*"; + // --- Separator + up next (only when queue has tracks) --- + const upNext = formatUpNextPreview(player.queue); + if (upNext) { + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(upNext) + ); } - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(nextContent.trim()) - ); // --- Musicard image --- if (musicardBuffer) { @@ -95,24 +134,10 @@ function createNowPlayingContainer(track, player, guildData, musicardBuffer) { // --- Song suggestions dropdown --- if (guildData.suggestions && guildData.suggestions.length > 0) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId("song_suggestion") - .setPlaceholder("🎶 Pick a similar song") - .setMinValues(1) - .setMaxValues(1); - - const suggestions = guildData.suggestions.slice(0, 10); - for (const suggestion of suggestions) { - selectMenu.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel((suggestion.info?.title || "Unknown").substring(0, 100)) - .setDescription((suggestion.info?.author || "Unknown Artist").substring(0, 100)) - .setValue(suggestion.info?.uri || suggestion.info?.title || "unknown") - ); - } - container.addActionRowComponents( - new ActionRowBuilder().addComponents(selectMenu) + new ActionRowBuilder().addComponents( + buildSongSuggestionSelectMenu(guildData.suggestions) + ) ); } @@ -174,23 +199,38 @@ function createNowPlayingContainer(track, player, guildData, musicardBuffer) { /** * Create a ChatPlay idle container (no image, disabled buttons) */ -function createChatPlayIdleContainer() { +function buildChatPlayHeaderContent() { + return ( + "## <:Musicify_Logo:1517828581638541493> Musicify ChatPlay\n" + + "-# *Type a song name in this channel to play it!*\n" + + "-# I'll search, play it in your voice channel, and keep this message updated." + ); +} + +function formatChatPlayIdleStatusLine(guildData = {}) { + if (guildData.chatPlayEnabled === false) { + if (guildData.twentyFourSeven) { + return "-# **Status:** Not waiting 24 hours, 7 days a week for a song request..."; + } + return "-# **Status:** Not waiting for a song request..."; + } + if (guildData.twentyFourSeven) { + return "-# **Status:** Waiting 24 hours, 7 days a week for a song request..."; + } + return "-# **Status:** Waiting for a song request..."; +} + +function createChatPlayIdleContainer(guildData = {}) { const container = new ContainerBuilder(); container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "## <:Musicify_Logo:1504329028356673536> Musicify ChatPlay\n" + - "-# *Type a song name in this channel to play it!*\n" + - "-# I'll search, play it in your voice channel, and keep this message updated." - ) + new TextDisplayBuilder().setContent(buildChatPlayHeaderContent()) ); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "-# **Status:** Waiting for a song request..." - ) + new TextDisplayBuilder().setContent(formatChatPlayIdleStatusLine(guildData)) ); // Disabled control buttons @@ -215,6 +255,14 @@ function createChatPlayIdleContainer() { return container; } +function formatChatPlayStatusLine(guildData) { + return ( + `Autoplay: ${guildData.autoplay ? "On" : "Off"}\n` + + `Loop: ${capitalize(guildData.loop)}\n` + + `Volume: ${guildData.volume}%` + ); +} + /** * Create a ChatPlay now-playing container (includes ChatPlay header + now playing info) */ @@ -223,11 +271,7 @@ function createChatPlayNowPlayingContainer(track, player, guildData, musicardBuf // --- ChatPlay Header (always visible) --- container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "## <:Musicify_Logo:1504329028356673536> Musicify ChatPlay\n" + - "-# *Type a song name in this channel to play it!*\n" + - "-# I'll search, play it in your voice channel, and keep this message updated." - ) + new TextDisplayBuilder().setContent(buildChatPlayHeaderContent()) ); container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); @@ -256,34 +300,17 @@ function createChatPlayNowPlayingContainer(track, player, guildData, musicardBuf // --- Status: Autoplay / Loop / Volume --- container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `Autoplay: ${guildData.autoplay ? "On" : "Off"}\n` + - `Loop: ${capitalize(guildData.loop)}\n` + - `Volume: ${guildData.volume}%` - ) + new TextDisplayBuilder().setContent(formatChatPlayStatusLine(guildData)) ); - // --- Separator --- - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); - - // --- Next on Deck --- - const queue = player.queue; - let nextContent = "**Next on Deck**\n"; - if (queue && queue.length > 0) { - const upcoming = queue.slice(0, 3); - for (let i = 0; i < upcoming.length; i++) { - const t = upcoming[i]; - nextContent += `**${i + 1}.** ${(t.info.title || "Unknown").substring(0, 45)} - ${(t.info.author || "Unknown Artist").substring(0, 30)} · ${formatDuration(t.info.length)}\n`; - } - if (queue.length > 3) { - nextContent += `-# *...and ${queue.length - 3} more in queue*`; - } - } else { - nextContent += "-# *No upcoming tracks*"; + // --- Separator + up next (only when queue has tracks) --- + const upNext = formatUpNextPreview(player.queue); + if (upNext) { + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(upNext) + ); } - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(nextContent.trim()) - ); // --- Musicard image --- if (musicardBuffer) { @@ -296,24 +323,10 @@ function createChatPlayNowPlayingContainer(track, player, guildData, musicardBuf // --- Song suggestions dropdown --- if (guildData.suggestions && guildData.suggestions.length > 0) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId("song_suggestion") - .setPlaceholder("🎶 Pick a similar song") - .setMinValues(1) - .setMaxValues(1); - - const suggestions = guildData.suggestions.slice(0, 10); - for (const suggestion of suggestions) { - selectMenu.addOptions( - new StringSelectMenuOptionBuilder() - .setLabel((suggestion.info?.title || "Unknown").substring(0, 100)) - .setDescription((suggestion.info?.author || "Unknown Artist").substring(0, 100)) - .setValue(suggestion.info?.uri || suggestion.info?.title || "unknown") - ); - } - container.addActionRowComponents( - new ActionRowBuilder().addComponents(selectMenu) + new ActionRowBuilder().addComponents( + buildSongSuggestionSelectMenu(guildData.suggestions) + ) ); } @@ -367,6 +380,7 @@ function createChatPlayNowPlayingContainer(track, player, guildData, musicardBuf ); container.addActionRowComponents(row1, row2); + return container; } @@ -375,109 +389,69 @@ function createChatPlayNowPlayingContainer(track, player, guildData, musicardBuf */ function createQueueContainer(queue, currentTrack, page = 0) { const container = new ContainerBuilder(); - const pageSize = 10; - const totalTracks = queue ? queue.length : 0; + const pageSize = 12; + const totalTracks = queue?.length || 0; const totalPages = Math.max(1, Math.ceil(totalTracks / pageSize)); - // Clamp page if (page < 0) page = 0; if (page >= totalPages) page = totalPages - 1; - // --- Total duration --- - let totalDuration = 0; - if (currentTrack && currentTrack.info.length) totalDuration += currentTrack.info.length; - if (queue && queue.length > 0) { - for (const t of queue) { - if (t.info.length) totalDuration += t.info.length; - } - } - - // --- Header --- - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `### 📜 Queue\n` + - `-# ${totalTracks} track${totalTracks !== 1 ? "s" : ""} · ${formatDuration(totalDuration)} total` - ) - ); - - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + const lines = []; + lines.push(`### Queue · ${totalTracks}`); - // --- Now Playing --- if (currentTrack) { - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**🎶 Now Playing**\n\n" + - `**${(currentTrack.info.title || "Unknown").substring(0, 50)}**\n` + - `-# ${(currentTrack.info.author || "Unknown Artist").substring(0, 30)} · ${formatDuration(currentTrack.info.length)}` - ) + const title = truncateText(currentTrack.info?.title, 40); + const author = truncateText(currentTrack.info?.author, 22, "?"); + lines.push( + `-# **Now Playing:** **${title}** — ${author} · \`${formatDuration(currentTrack.info?.length)}\`` ); - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + if (queue?.length) { + lines.push(""); + } } - // --- Queue tracks (numbered like Command Browser) --- - if (!queue || queue.length === 0) { - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - "**Up Next**\n\n" + - "-# *No upcoming tracks in queue*" - ) - ); - } else { + if (queue?.length) { const start = page * pageSize; const end = Math.min(start + pageSize, queue.length); - - let queueText = "**Up Next**\n\n"; for (let i = start; i < end; i++) { - const t = queue[i]; - const duration = formatDuration(t.info.length); - queueText += `**${i + 1}.** ${(t.info.title || "Unknown").substring(0, 45)}\n`; - queueText += `-# ${(t.info.author || "?").substring(0, 25)} · \`${duration}\`\n\n`; + lines.push(formatQueueTrackLine(i + 1, queue[i])); } - - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(queueText.trim()) - ); } - // --- Pagination --- - if (totalPages > 1) { - container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(lines.join("\n")) + ); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `-# Page ${page + 1} of ${totalPages} · ${totalTracks} tracks` + if (totalPages > 1) { + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("queue_first") + .setEmoji("⏮") + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === 0), + new ButtonBuilder() + .setCustomId("queue_prev") + .setEmoji("◀️") + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === 0), + new ButtonBuilder() + .setCustomId("queue_page_info") + .setLabel(`${page + 1}/${totalPages}`) + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId("queue_next") + .setEmoji("▶️") + .setStyle(ButtonStyle.Secondary) + .setDisabled(page >= totalPages - 1), + new ButtonBuilder() + .setCustomId("queue_last") + .setEmoji("⏭") + .setStyle(ButtonStyle.Secondary) + .setDisabled(page >= totalPages - 1) ) ); - - const paginationRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`queue_first`) - .setEmoji("⏮") - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 0), - new ButtonBuilder() - .setCustomId(`queue_prev`) - .setEmoji("◀️") - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 0), - new ButtonBuilder() - .setCustomId(`queue_page_info`) - .setLabel(`${page + 1}/${totalPages}`) - .setStyle(ButtonStyle.Primary) - .setDisabled(true), - new ButtonBuilder() - .setCustomId(`queue_next`) - .setEmoji("▶️") - .setStyle(ButtonStyle.Secondary) - .setDisabled(page >= totalPages - 1), - new ButtonBuilder() - .setCustomId(`queue_last`) - .setEmoji("⏭") - .setStyle(ButtonStyle.Secondary) - .setDisabled(page >= totalPages - 1) - ); - - container.addActionRowComponents(paginationRow); } return container; @@ -496,7 +470,7 @@ function createChatPlayLoadingContainer() { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - "## <:Musicify_Logo:1504329028356673536> Musicify ChatPlay\n" + + "## <:Musicify_Logo:1517828581638541493> Musicify ChatPlay\n" + "-# *Type a song name in this channel to play it!*\n" + "-# I'll search, play it in your voice channel, and keep this message updated." ) @@ -531,11 +505,36 @@ function createChatPlayLoadingContainer() { return container; } +function createStopConfirmContainer(queueLength) { + const container = new ContainerBuilder(); + const songLabel = queueLength === 1 ? "song" : "songs"; + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### ⚠️ Stop playback?\n\n" + + `There are **${queueLength}** ${songLabel} in the queue.\n\n` + + "-# Press **stop** again or confirm below within 15 seconds." + ) + ); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("stop_confirm") + .setLabel("STOP") + .setStyle(ButtonStyle.Danger) + ) + ); + + return container; +} + module.exports = { createNowPlayingContainer, createChatPlayIdleContainer, createChatPlayNowPlayingContainer, createChatPlayLoadingContainer, createQueueContainer, + createStopConfirmContainer, formatDuration, }; diff --git a/src/utils/database.js b/src/utils/database.js index 6dbb54b..1f95260 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -1,83 +1,107 @@ -const fs = require("fs"); -const path = require("path"); +const db = require("../db/sqlite"); +const { + camelToColumn, + rowToSettings, + serializeValue, + settingsToColumns, +} = require("../db/guildColumns"); -const DB_PATH = path.join(__dirname, "..", "..", "data", "guilds.json"); +const SELECT_GUILD = db.prepare("SELECT * FROM guilds WHERE guild_id = ?"); +const INSERT_GUILD = db.prepare("INSERT INTO guilds (guild_id) VALUES (?)"); +const SELECT_ALL_GUILDS = db.prepare("SELECT * FROM guilds"); -let dbInitialized = false; +function ensureGuildRow(guildId) { + const row = SELECT_GUILD.get(guildId); + if (row) return row; -/** - * Ensure the data directory and file exist (only runs once) - */ -function ensureDB() { - if (dbInitialized) return; - const dir = path.dirname(DB_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - if (!fs.existsSync(DB_PATH)) { - fs.writeFileSync(DB_PATH, "{}", "utf-8"); + INSERT_GUILD.run(guildId); + return SELECT_GUILD.get(guildId); +} + +function getGuildSettings(guildId) { + return rowToSettings(ensureGuildRow(guildId)); +} + +function setGuildSetting(guildId, key, value) { + const column = camelToColumn(key); + if (!column) { + throw new Error(`Unknown guild setting: ${key}`); } - dbInitialized = true; + + ensureGuildRow(guildId); + db.prepare(`UPDATE guilds SET ${column} = ? WHERE guild_id = ?`).run( + serializeValue(key, value), + guildId + ); } -/** - * Read the entire database - */ -function readDB() { - ensureDB(); +function setGuildSettings(guildId, settings) { + ensureGuildRow(guildId); + + db.exec("BEGIN"); try { - const raw = fs.readFileSync(DB_PATH, "utf-8"); - return JSON.parse(raw); + const columns = settingsToColumns(settings); + for (const [column, value] of Object.entries(columns)) { + db.prepare(`UPDATE guilds SET ${column} = ? WHERE guild_id = ?`).run( + value, + guildId + ); + } + db.exec("COMMIT"); } catch (err) { - return {}; + db.exec("ROLLBACK"); + throw err; } } -/** - * Write the entire database - */ -function writeDB(data) { - ensureDB(); - fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2), "utf-8"); +function deleteGuildSetting(guildId, key) { + const column = camelToColumn(key); + if (!column) return; + + db.prepare(`UPDATE guilds SET ${column} = NULL WHERE guild_id = ?`).run(guildId); } -/** - * Get guild settings from the database - */ -function getGuildSettings(guildId) { - const db = readDB(); - if (!db[guildId]) { - db[guildId] = {}; - writeDB(db); - } - return db[guildId]; +function persistGuildPlaybackSettings(guildId, guildData) { + setGuildSettings(guildId, { + defaultVolume: guildData.volume, + defaultAutoplay: guildData.autoplay, + }); } -/** - * Save a specific guild setting - */ -function setGuildSetting(guildId, key, value) { - const db = readDB(); - if (!db[guildId]) db[guildId] = {}; - db[guildId][key] = value; - writeDB(db); +function readDB() { + const result = {}; + for (const row of SELECT_ALL_GUILDS.all()) { + result[row.guild_id] = rowToSettings(row); + } + return result; } -/** - * Delete a specific guild setting - */ -function deleteGuildSetting(guildId, key) { - const db = readDB(); - if (db[guildId]) { - delete db[guildId][key]; - writeDB(db); +function writeDB(data) { + db.exec("BEGIN"); + try { + for (const [guildId, settings] of Object.entries(data)) { + ensureGuildRow(guildId); + const columns = settingsToColumns(settings); + for (const [column, value] of Object.entries(columns)) { + db.prepare(`UPDATE guilds SET ${column} = ? WHERE guild_id = ?`).run( + value, + guildId + ); + } + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; } } module.exports = { getGuildSettings, setGuildSetting, + setGuildSettings, deleteGuildSetting, + persistGuildPlaybackSettings, readDB, writeDB, }; diff --git a/src/utils/guildWelcome.js b/src/utils/guildWelcome.js new file mode 100644 index 0000000..10a53a8 --- /dev/null +++ b/src/utils/guildWelcome.js @@ -0,0 +1,168 @@ +const { + MessageFlags, + PermissionsBitField, + ChannelType, + TextDisplayBuilder, + SectionBuilder, + ButtonBuilder, + ButtonStyle, +} = require("discord.js"); + +const WELCOME_TTL_MS = 2 * 60 * 1000; +const welcomeDeleteTimers = new Map(); + +const TEXT_CHANNEL_TYPES = new Set([ + ChannelType.GuildText, + ChannelType.GuildAnnouncement, +]); + +function buildWelcomeBody(cmd) { + return ( + `## 👋 Welcome to Musicify!\n\n` + + `- Use ${cmd.chatplay} to enable **ChatPlay** — type song names in a channel to play instantly\n\n` + + `**Quick start**\n` + + `1. Join a voice channel\n` + + `2. Use ${cmd.play} \`\` — supports Spotify, SoundCloud, Deezer, Apple Music, Tidal, Qobuz & JioSaavn\n` + + `3. Control playback with interactive buttons or slash commands\n\n` + + `- If you need help or found a bug, join our [support server](https://discord.gg/MRjEUhDCpZ), run ${cmd.status} to see if the bot's systems are online, or check out the open source [GitHub repo](https://github.com/codebymitch/Musicify)!` + ); +} + +function buildWelcomeComponents(deleteAtUnix, cmd) { + return [ + new TextDisplayBuilder().setContent(buildWelcomeBody(cmd)), + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `-# This message will delete ` + ) + ) + .setButtonAccessory( + new ButtonBuilder() + .setCustomId("welcome_dismiss") + .setLabel("Delete message now") + .setStyle(ButtonStyle.Secondary) + ), + ]; +} + +async function resolveCommandMentions(client, names) { + const fallback = (name) => `\`/${name}\``; + const mentions = Object.fromEntries(names.map((name) => [name, fallback(name)])); + + try { + const commands = await client.application.commands.fetch(); + for (const name of names) { + const command = commands.find((entry) => entry.name === name); + if (command) { + mentions[name] = ``; + } + } + } catch (err) { + console.warn("[Musicify] Could not fetch command IDs for welcome message:", err.message); + } + + return mentions; +} + +async function resolveGuildContext(guild) { + if (guild.partial) { + await guild.fetch(); + } + + await guild.channels.fetch().catch((err) => { + console.warn(`[Musicify] Could not fetch channels for ${guild.id}:`, err.message); + }); + + let me = guild.members.me; + if (!me) { + me = await guild.members.fetchMe().catch(() => null); + } + + return me; +} + +function findWelcomeChannel(guild, me) { + if (!me) return null; + + const canSend = (channel) => { + if (!channel || !TEXT_CHANNEL_TYPES.has(channel.type)) return false; + const perms = channel.permissionsFor(me); + return ( + perms?.has(PermissionsBitField.Flags.ViewChannel) && + perms?.has(PermissionsBitField.Flags.SendMessages) + ); + }; + + if (guild.systemChannelId) { + const systemChannel = guild.channels.cache.get(guild.systemChannelId); + if (canSend(systemChannel)) return systemChannel; + } + + const sendable = guild.channels.cache + .filter((channel) => canSend(channel)) + .sort((a, b) => a.rawPosition - b.rawPosition); + + const preferred = sendable.find((channel) => + /general|welcome|chat|lounge/i.test(channel.name) + ); + if (preferred) return preferred; + + return sendable.first() ?? null; +} + +function cancelWelcomeDeleteTimer(messageId) { + const timer = welcomeDeleteTimers.get(messageId); + if (timer) { + clearTimeout(timer); + welcomeDeleteTimers.delete(messageId); + } +} + +async function sendGuildWelcome(client, guild) { + try { + // Guild data (especially channels) is often incomplete the instant guildCreate fires + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const me = await resolveGuildContext(guild); + const channel = findWelcomeChannel(guild, me); + + if (!channel) { + console.warn( + `[Musicify] No welcome channel in ${guild.name} (${guild.id}) — bot needs Send Messages in a text channel` + ); + return; + } + + const cmd = await resolveCommandMentions(client, ["play", "chatplay", "status"]); + const deleteAt = Math.floor((Date.now() + WELCOME_TTL_MS) / 1000); + const message = await channel.send({ + components: buildWelcomeComponents(deleteAt, cmd), + flags: MessageFlags.IsComponentsV2, + }); + + const timer = setTimeout(() => { + cancelWelcomeDeleteTimer(message.id); + message.delete().catch(() => {}); + }, WELCOME_TTL_MS); + + welcomeDeleteTimers.set(message.id, timer); + } catch (err) { + console.error(`[Musicify] Failed to send guild welcome in ${guild.id}:`, err); + } +} + +async function dismissWelcomeMessage(interaction) { + cancelWelcomeDeleteTimer(interaction.message.id); + try { + await interaction.deferUpdate(); + } catch { + // Message may already be gone + } + await interaction.message.delete().catch(() => {}); +} + +module.exports = { + sendGuildWelcome, + dismissWelcomeMessage, +}; diff --git a/src/utils/incidents.js b/src/utils/incidents.js index 2cfa205..56897f1 100644 --- a/src/utils/incidents.js +++ b/src/utils/incidents.js @@ -1,8 +1,6 @@ -const fs = require("fs"); -const path = require("path"); const config = require("../../config"); +const db = require("../db/sqlite"); -const INCIDENTS_PATH = path.join(__dirname, "..", "..", "data", "incidents.json"); const MAX_INCIDENTS = 50; const UNIMPORTANT_PATTERNS = [ @@ -12,33 +10,48 @@ const UNIMPORTANT_PATTERNS = [ "ECONNRESET", ]; -let incidents = []; -let loaded = false; - -function ensureLoaded() { - if (loaded) return; - - const dir = path.dirname(INCIDENTS_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (fs.existsSync(INCIDENTS_PATH)) { - try { - const parsed = JSON.parse(fs.readFileSync(INCIDENTS_PATH, "utf-8")); - incidents = Array.isArray(parsed) ? parsed : []; - } catch { - incidents = []; - } - } - - loaded = true; -} - -function saveIncidents() { - ensureLoaded(); - fs.writeFileSync(INCIDENTS_PATH, JSON.stringify(incidents, null, 2), "utf-8"); -} +const SELECT_RECENT = db.prepare(` + SELECT timestamp, component, description + FROM incidents + ORDER BY timestamp DESC + LIMIT ? +`); + +const SELECT_ALL = db.prepare(` + SELECT timestamp, component, description + FROM incidents + ORDER BY timestamp DESC +`); + +const SELECT_BY_COMPONENT = db.prepare(` + SELECT timestamp, component, description + FROM incidents + WHERE component = ? + ORDER BY timestamp DESC +`); + +const SELECT_DUPLICATE = db.prepare(` + SELECT 1 + FROM incidents + WHERE component = ? + AND description = ? + AND timestamp > ? + LIMIT 1 +`); + +const INSERT_INCIDENT = db.prepare(` + INSERT INTO incidents (timestamp, component, description) + VALUES (?, ?, ?) +`); + +const DELETE_OLDEST = db.prepare(` + DELETE FROM incidents + WHERE id IN ( + SELECT id FROM incidents + ORDER BY timestamp ASC + LIMIT ? + ) +`); function isImportantIncident(description) { const lowerDesc = description.toLowerCase(); @@ -55,60 +68,47 @@ function anonymizeNodeNames(description) { return result; } +function trimIncidents() { + const count = db.prepare("SELECT COUNT(*) AS count FROM incidents").get().count; + if (count > MAX_INCIDENTS) { + DELETE_OLDEST.run(count - MAX_INCIDENTS); + } +} + function recordIncident(component, description) { if (!isImportantIncident(description)) { return false; } - ensureLoaded(); - const anonymizedDesc = anonymizeNodeNames(description); const now = Date.now(); - const FIVE_MINUTES = 5 * 60 * 1000; - const isDuplicate = incidents.some( - (i) => - i.component === component && - i.description === anonymizedDesc && - now - i.timestamp < FIVE_MINUTES - ); - - if (isDuplicate) { - return false; - } - - incidents.unshift({ - timestamp: now, - component, - description: anonymizedDesc, - }); + const fiveMinutesAgo = now - 5 * 60 * 1000; - if (incidents.length > MAX_INCIDENTS) { - incidents.length = MAX_INCIDENTS; + if (SELECT_DUPLICATE.get(component, anonymizedDesc, fiveMinutesAgo)) { + return false; } - saveIncidents(); + INSERT_INCIDENT.run(now, component, anonymizedDesc); + trimIncidents(); return true; } function getIncidents() { - ensureLoaded(); - return [...incidents]; + return SELECT_ALL.all(); } function getIncidentsForComponent(component) { - ensureLoaded(); - return incidents.filter((i) => i.component === component); + return SELECT_BY_COMPONENT.all(component); } function formatIncidents(limit = 4) { - ensureLoaded(); + const incidents = SELECT_RECENT.all(limit); if (incidents.length === 0) { return "-# No incidents reported."; } return incidents - .slice(0, limit) .map( (i) => `-# · ${i.component}: ${i.description}` @@ -117,8 +117,7 @@ function formatIncidents(limit = 4) { } function formatPastIncidents(days = 7) { - ensureLoaded(); - + const incidents = SELECT_ALL.all(); const lines = []; const now = new Date(); diff --git a/src/utils/lavalink.js b/src/utils/lavalink.js new file mode 100644 index 0000000..91ad455 --- /dev/null +++ b/src/utils/lavalink.js @@ -0,0 +1,50 @@ +const config = require("../../config"); + +function getNodeList(client) { + const nodes = client.riffy?.nodeMap; + if (!nodes) return []; + + if (Array.isArray(nodes)) return nodes; + if (nodes instanceof Map) return [...nodes.values()]; + return Object.values(nodes || {}); +} + +function countConnectedNodes(client) { + const nodeList = getNodeList(client); + let connected = 0; + for (const configNode of config.nodes) { + const node = nodeList.find((n) => n.name === configNode.name); + if (node?.connected || node?.isConnected) connected++; + } + return connected; +} + +function isLavalinkAvailable(client) { + return countConnectedNodes(client) > 0; +} + +async function getStatusCommandRef(client) { + try { + const commands = await client.application.commands.fetch(); + const statusCmd = commands.find((c) => c.name === "status"); + return statusCmd ? `` : "`/status`"; + } catch { + return "`/status`"; + } +} + +async function getLavalinkUnavailableMessage(client) { + const statusRef = await getStatusCommandRef(client); + return ( + "❌ **Lavalink is unavailable** — music search can't run right now.\n" + + `-# Try again shortly or check ${statusRef} for node status.` + ); +} + +module.exports = { + getNodeList, + countConnectedNodes, + isLavalinkAvailable, + getStatusCommandRef, + getLavalinkUnavailableMessage, +}; diff --git a/src/utils/playerStore.js b/src/utils/playerStore.js index d14710d..d8173ab 100644 --- a/src/utils/playerStore.js +++ b/src/utils/playerStore.js @@ -9,6 +9,9 @@ class GuildData { this.chatPlayChannelId = null; this.chatPlayMessageId = null; this.chatPlayEnabled = false; + this.chatPlaySlowmode = true; + this.chatPlayDeleteMessages = true; + this.chatPlayPinPlayerMessage = true; this.autoplay = false; this.loop = "none"; // "none" | "track" | "queue" this.volume = 75; @@ -16,11 +19,13 @@ class GuildData { this.previousTracks = []; this.suggestions = []; this.twentyFourSeven = false; + this.boundVoiceChannelId = null; + this.aloneLeaveTimeout = null; + this.autoplayWatchdog = null; 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.stopConfirmPending = null; // user id awaiting stop confirmation } } diff --git a/src/utils/queueUtils.js b/src/utils/queueUtils.js new file mode 100644 index 0000000..577c7a7 --- /dev/null +++ b/src/utils/queueUtils.js @@ -0,0 +1,34 @@ +/** + * Find where a track URI appears in the player (now playing or queued). + * Queue positions are 1-indexed. + */ +function getTrackQueuePosition(player, uri) { + if (!player || !uri) return null; + + if (player.current?.info?.uri === uri) { + return { type: "playing" }; + } + + const index = player.queue.findIndex((t) => t.info?.uri === uri); + if (index >= 0) { + return { type: "queued", position: index + 1 }; + } + + return null; +} + +function formatDuplicateTrackMessage(title, position) { + const safeTitle = title || "Unknown"; + if (!position) return null; + + if (position.type === "playing") { + return `**${safeTitle}** is already playing!`; + } + + return `**${safeTitle}** is already in the queue at **#${position.position}**!`; +} + +module.exports = { + getTrackQueuePosition, + formatDuplicateTrackMessage, +}; diff --git a/src/utils/replies.js b/src/utils/replies.js new file mode 100644 index 0000000..509692c --- /dev/null +++ b/src/utils/replies.js @@ -0,0 +1,88 @@ +const { MessageFlags, ContainerBuilder, TextDisplayBuilder } = require("discord.js"); + +const INTERACTION_EXPIRED_CODE = 10062; + +function buildFeedbackContainer(content, { error = false } = {}) { + const container = new ContainerBuilder(); + container.addTextDisplayComponents(new TextDisplayBuilder().setContent(content)); + return container; +} + +function buildErrorContainer(body) { + return buildFeedbackContainer( + typeof body === "string" ? `### ❌ Error\n\n${body}` : body, + { error: true } + ); +} + +function buildSuccessContainer(body) { + return buildFeedbackContainer(typeof body === "string" ? body : body); +} + +function ephemeralV2(components) { + return { + components: Array.isArray(components) ? components : [components], + flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, + }; +} + +async function replyExpiredInteraction(interaction) { + const container = buildErrorContainer( + "**Timed out**\n-# That interaction expired — run the command again." + ); + try { + if (interaction.deferred || interaction.replied) { + await interaction.followUp(ephemeralV2(container)); + } else { + await interaction.reply(ephemeralV2(container)); + } + } catch (err) { + if (err.code !== INTERACTION_EXPIRED_CODE) { + console.error("[Musicify] Failed to send expiry notice:", err.message); + } + } +} + +function isInteractionExpired(error) { + return error?.code === INTERACTION_EXPIRED_CODE; +} + +async function safeInteractionUpdate(interaction, options) { + try { + if (interaction.deferred || interaction.replied) { + await interaction.editReply(options); + } else { + await interaction.update(options); + } + } catch (error) { + if (isInteractionExpired(error)) { + await replyExpiredInteraction(interaction); + return false; + } + throw error; + } + return true; +} + +async function replyError(interaction, body, { deferred = false } = {}) { + const payload = ephemeralV2(buildErrorContainer(body)); + if (deferred || interaction.deferred) { + await interaction.editReply(payload); + } else if (interaction.replied) { + await interaction.followUp(payload); + } else { + await interaction.reply(payload); + } +} + +module.exports = { + INTERACTION_EXPIRED_CODE, + buildFeedbackContainer, + buildErrorContainer, + buildSuccessContainer, + ephemeralV2, + replyExpiredInteraction, + isInteractionExpired, + safeInteractionUpdate, + replyError, +}; diff --git a/src/utils/resolveLimiter.js b/src/utils/resolveLimiter.js new file mode 100644 index 0000000..d6298d5 --- /dev/null +++ b/src/utils/resolveLimiter.js @@ -0,0 +1,94 @@ +const Bottleneck = require("bottleneck"); +const { enrichResolveResult } = require("./resolveResult"); + +const PRIORITY_PLAY = 5; +const PRIORITY_SUGGESTIONS = 1; + +const RATE_LIMIT_MESSAGE = + "⏳ **Slow down** you're sending requests too quickly.\n" + + "-# Wait a few seconds between songs. Channel slowmode may also apply."; + +class ResolveRateLimitError extends Error { + constructor(message = RATE_LIMIT_MESSAGE) { + super(message); + this.name = "ResolveRateLimitError"; + } +} + +function checkRateLimit(guildId, userId) { + const guildLimiter = getGuildLimiter(guildId); + const guildCounts = guildLimiter.counts(); + if (guildCounts.QUEUED >= 8) { + throw new ResolveRateLimitError(); + } + + if (userId) { + const userLimiter = getUserLimiter(userId); + const userCounts = userLimiter.counts(); + if (userCounts.RUNNING >= 2 || userCounts.QUEUED >= 1) { + throw new ResolveRateLimitError(); + } + } +} + +const globalLimiter = new Bottleneck({ + maxConcurrent: 25, + minTime: 25, +}); + +const guildLimiters = new Map(); +const userLimiters = new Map(); + +function getGuildLimiter(guildId) { + if (!guildLimiters.has(guildId)) { + guildLimiters.set( + guildId, + new Bottleneck({ + maxConcurrent: 10, + minTime: 100, + }) + ); + } + return guildLimiters.get(guildId); +} + +function getUserLimiter(userId) { + if (!userLimiters.has(userId)) { + userLimiters.set( + userId, + new Bottleneck({ + maxConcurrent: 3, + minTime: 500, + }) + ); + } + return userLimiters.get(userId); +} + +/** + * Run a Lavalink resolve through layered rate limiters. + * Jobs queue rather than fail — users may wait briefly under heavy load. + */ +function limitedResolve(client, { query, requester, guildId, userId, priority = PRIORITY_PLAY }) { + checkRateLimit(guildId, userId); + + const resolve = async () => { + const result = await client.riffy.resolve({ query, requester }); + return enrichResolveResult(client, query, result); + }; + + return globalLimiter.schedule({ priority }, () => + getGuildLimiter(guildId).schedule({ priority }, () => { + if (!userId) return resolve(); + return getUserLimiter(userId).schedule({ priority }, resolve); + }) + ); +} + +module.exports = { + limitedResolve, + PRIORITY_PLAY, + PRIORITY_SUGGESTIONS, + ResolveRateLimitError, + RATE_LIMIT_MESSAGE, +}; diff --git a/src/utils/resolveResult.js b/src/utils/resolveResult.js new file mode 100644 index 0000000..03622a5 --- /dev/null +++ b/src/utils/resolveResult.js @@ -0,0 +1,232 @@ +const { getTrackQueuePosition } = require("./queueUtils"); + +const COLLECTION_URL_PATTERN = + /(?:open\.spotify\.com\/(?:playlist|album|artist|collection)|spotify:(?:playlist|album|artist):|soundcloud\.com\/[^/?]+\/sets\/|(?:www\.)?deezer\.com\/(?:\w{2}\/)?(?:playlist|album|artist)|music\.apple\.com\/[^?]*\/(?:album|playlist)|tidal\.com\/browse\/(?:playlist|album|mix|artist)|(?:open\.)?qobuz\.com\/[^?]*\/(?:playlist|album|artist|track)|(?:www\.)?jiosaavn\.com\/(?:album|playlist|featured|song|show))/i; + +const PLUGIN_COLLECTION_TYPES = new Set(["playlist", "album", "artist", "recommendations"]); + +function isCollectionUrl(query) { + return COLLECTION_URL_PATTERN.test(query || ""); +} + +function normalizeLoadType(loadType) { + if (!loadType) return ""; + const lower = String(loadType).toLowerCase(); + if (lower === "playlist_loaded") return "playlist"; + if (lower === "search_result") return "search"; + if (lower === "track_loaded") return "track"; + return lower; +} + +function toHttpCollectionUrl(query) { + const trimmed = (query || "").trim(); + if (!trimmed) return null; + + if (/^https?:\/\//i.test(trimmed)) { + return trimmed.split("?")[0]; + } + + const spotifyMatch = trimmed.match(/^spotify:(playlist|album|artist):([a-zA-Z0-9]+)/i); + if (spotifyMatch) { + const segment = spotifyMatch[1] === "artist" ? "artist" : spotifyMatch[1]; + return `https://open.spotify.com/${segment}/${spotifyMatch[2]}`; + } + + return null; +} + +function extractNameFromPluginInfo(pluginInfo) { + if (!pluginInfo || typeof pluginInfo !== "object") return null; + + for (const key of ["name", "title", "playlistName", "albumName", "artistName"]) { + const value = pluginInfo[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return null; +} + +function extractNameFromLoadResponse(raw) { + if (!raw) return null; + + const loadType = normalizeLoadType(raw.loadType); + + if (loadType === "playlist") { + const name = raw.data?.info?.name ?? raw.playlistInfo?.name; + if (typeof name === "string" && name.trim()) { + return name.trim(); + } + } + + const pluginInfo = raw.data?.pluginInfo ?? raw.pluginInfo ?? null; + return extractNameFromPluginInfo(pluginInfo); +} + +async function fetchJson(url, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Fallback when Lavalink/LavaSrc returns a collection as search with no playlistInfo.name. + */ +async function fetchCollectionTitleFromUrl(query) { + const url = toHttpCollectionUrl(query); + if (!url) return null; + + if (/open\.spotify\.com\//i.test(url)) { + const data = await fetchJson( + `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}` + ); + return data?.title?.trim() || null; + } + + const deezerMatch = url.match(/deezer\.com\/(?:\w{2}\/)?playlist\/(\d+)/i); + if (deezerMatch) { + const data = await fetchJson(`https://api.deezer.com/playlist/${deezerMatch[1]}`); + return data?.title?.trim() || null; + } + + if (/soundcloud\.com\/[^/?]+\/sets\//i.test(url)) { + const data = await fetchJson( + `https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json` + ); + return data?.title?.trim() || null; + } + + return null; +} + +/** + * Decide whether a Lavalink resolve should queue multiple tracks. + * Spotify and other platform playlists sometimes come back as "search" instead of "playlist". + */ +function classifyResolveResult(result, query) { + const { loadType, tracks = [], playlistInfo, pluginInfo } = result; + const normalizedType = normalizeLoadType(loadType); + + if ( + normalizedType === "empty" || + loadType === "NO_MATCHES" || + normalizedType === "error" || + loadType === "LOAD_FAILED" || + !tracks.length + ) { + return { mode: "empty", tracks: [], playlistInfo: null }; + } + + const pluginType = pluginInfo?.type; + const isPluginCollection = PLUGIN_COLLECTION_TYPES.has(pluginType); + + if ( + normalizedType === "playlist" || + playlistInfo?.name || + isPluginCollection + ) { + return { mode: "playlist", tracks, playlistInfo }; + } + + if (isCollectionUrl(query) && tracks.length > 1) { + return { + mode: "playlist", + tracks, + playlistInfo, + }; + } + + return { mode: "single", tracks, playlistInfo: null }; +} + +function getPlaylistDisplayName(playlistInfo) { + const name = playlistInfo?.name?.trim(); + return name || "Playlist"; +} + +/** + * Riffy sometimes omits playlistInfo for collection URLs (especially loadType "search"). + * Re-fetch from Lavalink and fall back to platform oEmbed/API when needed. + */ +async function enrichResolveResult(client, query, result) { + if (result.playlistInfo?.name?.trim()) return result; + if (!isCollectionUrl(query)) return result; + + let name = + extractNameFromPluginInfo(result.pluginInfo) || + extractNameFromLoadResponse(result); + + let mergedPluginInfo = result.pluginInfo || {}; + + const node = client.riffy?.leastUsedNodes?.[0]; + if (!name && node) { + const identifier = /^https?:\/\//i.test(query) + ? query + : `${client.riffy.defaultSearchPlatform}:${query}`; + + try { + const raw = await node.rest.makeRequest( + "GET", + `/${node.rest.version}/loadtracks?identifier=${encodeURIComponent(identifier)}` + ); + + name = extractNameFromLoadResponse(raw); + const pluginInfo = raw?.data?.pluginInfo ?? raw?.pluginInfo ?? null; + if (pluginInfo) { + mergedPluginInfo = { ...mergedPluginInfo, ...pluginInfo }; + } + } catch (err) { + console.error("[Musicify] Failed to enrich playlist info:", err.message); + } + } + + if (!name) { + name = await fetchCollectionTitleFromUrl(query); + } + + if (!name) return result; + + return { + ...result, + playlistInfo: { ...(result.playlistInfo || {}), name }, + pluginInfo: mergedPluginInfo, + }; +} + +function queueResolvedTracks(player, tracks, requester) { + const duplicates = []; + const addedTracks = []; + + for (const track of tracks) { + track.info.requester = requester; + + const position = getTrackQueuePosition(player, track.info.uri); + + if (position) { + duplicates.push(track.info.title || "Unknown"); + } else { + player.queue.add(track); + addedTracks.push(track.info.title || "Unknown"); + } + } + + return { duplicates, addedTracks }; +} + +module.exports = { + isCollectionUrl, + classifyResolveResult, + enrichResolveResult, + getPlaylistDisplayName, + queueResolvedTracks, +}; diff --git a/src/utils/voiceChannel.js b/src/utils/voiceChannel.js new file mode 100644 index 0000000..73407d3 --- /dev/null +++ b/src/utils/voiceChannel.js @@ -0,0 +1,37 @@ +/** + * Detect when a user tries to play from a different VC than the bot (or 24/7 bound channel). + */ +function getVoiceChannelMismatch(guildData, userVoiceChannelId, player) { + if ( + guildData.twentyFourSeven && + guildData.boundVoiceChannelId && + guildData.boundVoiceChannelId !== userVoiceChannelId + ) { + return { type: "247", channelId: guildData.boundVoiceChannelId }; + } + + if (player?.voiceChannel && player.voiceChannel !== userVoiceChannelId) { + return { type: "player", channelId: player.voiceChannel }; + } + + return null; +} + +function formatVoiceChannelMismatch(guild, mismatch) { + const channel = guild.channels.cache.get(mismatch.channelId); + const name = channel?.name || "another voice channel"; + + if (mismatch.type === "247") { + return ( + `❌ 24/7 mode is connected to **${name}**.\n` + + "-# Join that channel or use `/247` to disable 24/7 before playing here." + ); + } + + return ( + `❌ I'm already in **${name}**.\n` + + "-# Join that voice channel to request songs." + ); +} + +module.exports = { getVoiceChannelMismatch, formatVoiceChannelMismatch }; diff --git a/src/utils/voicePermissions.js b/src/utils/voicePermissions.js new file mode 100644 index 0000000..b5e5e91 --- /dev/null +++ b/src/utils/voicePermissions.js @@ -0,0 +1,59 @@ +const { PermissionFlagsBits } = require("discord.js"); + +function getVoicePermissionError(member, guild) { + const voiceChannel = member?.voice?.channel; + if (!voiceChannel) { + return { + code: "NO_VC", + message: "❌ You need to join a voice channel first!", + }; + } + + const userPerms = voiceChannel.permissionsFor(member); + if (!userPerms?.has(PermissionFlagsBits.Connect)) { + return { + code: "USER_CONNECT", + message: + "❌ You don't have **Connect** permission in that voice channel.\n" + + "-# Join a channel you can connect to, or ask a moderator for access.", + }; + } + + const botMember = guild.members.me; + if (!botMember) { + return { + code: "BOT_MISSING", + message: "❌ Something went wrong checking bot permissions. Try again.", + }; + } + + const botPerms = voiceChannel.permissionsFor(botMember); + if (!botPerms?.has(PermissionFlagsBits.ViewChannel)) { + return { + code: "BOT_VIEW", + message: + "❌ I can't see that voice channel.\n" + + "-# Grant **View Channel** for the bot in that channel.", + }; + } + if (!botPerms?.has(PermissionFlagsBits.Connect)) { + return { + code: "BOT_CONNECT", + message: + "❌ I don't have **Connect** permission in your voice channel.\n" + + "-# Ask a moderator to grant Connect (and Speak) for Musicify.", + }; + } + if (!botPerms?.has(PermissionFlagsBits.Speak)) { + return { + code: "BOT_SPEAK", + message: + "❌ I don't have **Speak** permission in your voice channel.\n" + + "-# Ask a moderator to grant Speak for Musicify.", + }; + } + + return null; +} + +module.exports = { getVoicePermissionError };