diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 73cb5f2..d411cf4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -1,20 +1,13 @@ name: Docker -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - on: push: - branches: ["main"] + branches: ["*"] # Publish semver tags as releases. # tags: [ 'v*.*.*' ] env: - # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io - # github.repository as / IMAGE_NAME: ${{ github.repository }} jobs: @@ -23,30 +16,21 @@ jobs: permissions: contents: read packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. id-token: write steps: - name: Checkout repository uses: actions/checkout@v3 - # Install the cosign tool except on PR - # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 with: cosign-release: "v2.1.1" - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - # Login against a Docker registry except on PR - # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 @@ -55,16 +39,19 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 diff --git a/Dockerfile b/Dockerfile index 96fcb68..b8b960f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,14 @@ -FROM node:current-alpine +FROM node:24-alpine -WORKDIR /usr/src/app +WORKDIR /app + +RUN apk update && \ + apk add --no-cache \ + ffmpeg \ + build-base \ + python3 \ + git \ + && rm -rf /var/cache/apk/* COPY package*.json ./ @@ -8,4 +16,6 @@ RUN npm install COPY . . -CMD ["node", "."] +ENV NODE_ENV=production + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE index f288702..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see +For more information on this, and how to apply and follow the GNU AGPL, see . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index 985f845..f8c82b8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Discord Multipurpose Bot +# Circuitrix Discord Bot --- @@ -8,32 +8,72 @@ This bot includes game statistics functionality (currently supports Valorant, CS --- -Welcome to the **Discord Multipurpose Bot**! This bot manages user verification for Discord servers through email authentication, includes a fun trivia game feature, and provides role management and leaderboard tracking functionalities. +Welcome to **Circuitrix Discord Bot**! A powerful multipurpose Discord bot featuring email verification, trivia games, music functionality, and comprehensive server management tools. -## Features +## โœจ Features -- **Email Verification**: Users receive a verification code via email and must enter it in Discord to verify their account. -- **Role Management**: Automatically assigns a specific role to users once they have been verified. -- **Trivia Game**: Play a video game-themed trivia game with various categories such as Anime & Manga, Computers, Board Games, Comics, Cartoons & Animations, Film, General Knowledge, Science, Animals, Music, History, Mythology, and Geography & Nature. -- **Leaderboard**: Displays the top players based on correct trivia answers. -- **User Information**: Retrieve information about a specific user or yourself, including roles and account details. -- **Warning System**: Issue warnings to users with a reason logged for future reference. -- **Message Purge**: Moderators can delete messages from a channel. -- **Ping and Uptime**: Check the bot's latency and how long it has been running. -- **Admin Log**: Admins can review logs of verification attempts and trivia games in a designated channel. -- **Customizable Settings**: Configure email domains, channels, roles, and more to suit your server. -- **Help Command**: List all available commands and their descriptions for easy reference. +### ๐Ÿ” Verification System -### Installation +- **Email Verification**: Secure verification system where users receive a verification code via email +- **Role Management**: Automatically assigns roles to verified users +- **Customizable Settings**: Configure allowed email domains, verification channels, and roles + +### ๐ŸŽฎ Entertainment + +- **Trivia Game**: Video game-themed trivia with multiple categories: + + - Anime & Manga + - Computers & Technology + - Board Games + - Comics + - Film & TV + - General Knowledge + - Science & Nature + - Music + - History & Mythology + - Geography + +- **Music System**: Advanced music player with support for multiple platforms: + - YouTube, Spotify, SoundCloud integration + - High-quality audio playback + - Queue management + - Volume control + - Loop functionality (track/queue) + - Lyrics display + - Live synchronized lyrics + +### โš™๏ธ Moderation Tools + +- **Warning System**: Issue and track user warnings with reasons +- **Message Purge**: Bulk delete messages from channels +- **User Information**: Detailed user profiles with role information +- **Admin Logs**: Comprehensive logging of moderation actions + +### ๐Ÿ“Š Statistics & Tracking + +- **Leaderboard System**: Track top trivia players +- **Game Statistics**: Valorant, CS2, and TFT stats (requires API access) +- **Server Analytics**: Monitor server activity and usage + +### ๐ŸŽต Music Commands + +- `/play` - Play songs from YouTube, Spotify, or SoundCloud +- `/skip` - Skip the current song +- `/stop` - Stop playback and clear queue +- `/queue` - View current music queue +- `/volume` - Adjust playback volume +- `/loop` - Loop current track or entire queue +- `/lyrics` - Display lyrics for current or specified song +- `/pause` / `/resume` - Control playback +- `/nowplaying` - Show current track information + +## ๐Ÿš€ Installation 1. **Clone the Repository** ```sh -git clone git@github.com:aydenjahola/discord-multipurpose-bot.git -``` - -```sh -cd discord-multipurpose-bot +git clone git@github.com:aydenjahola/circuitrix.git +cd circuitrix ``` 2. **Install Dependencies** @@ -42,24 +82,75 @@ cd discord-multipurpose-bot npm install ``` -3. **Set Up Environment Variables** +3. **Environment Configuration** -rename the [`.env.example`](./.env.example) to `.env` and fill in the required environments + - Rename `.env.example` to `.env` + - Fill in all required environment variables: + - Discord Bot Token + - MongoDB Connection URI + - Email Service Credentials + - Genius API Key (for lyrics functionality) + - Custom API Keys for game statistics -4. **Run the Bot** +4. **Start the Bot** ```sh -node bot.js +node index.js ``` -## Setup +## โš™๏ธ Setup -make sure to run `/setup` or otherwise the verification process wont work. +After inviting the bot to your server, run `/setup` to configure the verification system and other essential settings. -## Usage +## ๐Ÿ’ก Usage -run `/help` command to get list of all avaiable commands, or visit the [commands](./commands/) directory to view them. +Use `/help` to view all available commands and their descriptions. For detailed information about specific commands, visit the [commands directory](./commands/). -## Dashboard (WIP) +## ๐ŸŽต Music Setup -I am currently working on a dashboard to manage the bot as well, currently this is in the `dashboard` branch and still work in progress. if you know how to build discord bot dashboards then please feel free to contribute. +The bot requires FFmpeg for audio processing. Ensure FFmpeg is installed on your system: + +**Windows:** + +```sh +choco install ffmpeg +``` + +**macOS:** + +```sh +brew install ffmpeg +``` + +**Linux (Ubuntu/Debian):** + +```sh +sudo apt install ffmpeg +``` + +## ๐Ÿ”ง Docker Deployment + +The bot includes Docker support for easy deployment: + +```sh +docker-compose up --build +``` + +## ๐Ÿ“Š Dashboard (Work in Progress) + +I'm currently developing a web dashboard for bot management in the `dashboard` branch. Contributors with experience in Discord bot dashboards are welcome to help! + +## ๐Ÿค Contributing + +Contributions are welcome! Please feel free to submit pull requests or open issues for bugs and feature requests. + +## ๐Ÿ“ž Support + +For questions about the game statistics API or general support: + +- Email: [info@aydenjahola.com](mailto:info@aydenjahola.com) +- GitHub Issues: [Create an issue](https://github.com/aydenjahola/discord-multipurpose-bot/issues) + +--- + +**Circuitrix Discord Bot** - Developed with โค๏ธ by [Ayden Jahola](https://github.com/aydenjahola) diff --git a/commands/admin/music.js b/commands/admin/music.js new file mode 100644 index 0000000..619577c --- /dev/null +++ b/commands/admin/music.js @@ -0,0 +1,178 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { ensure, set } = require("../../utils/musicSettings"); + +function ok(s) { + return `โœ… ${s}`; +} +function err(s) { + return `โŒ ${s}`; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("music") + .setDescription("Configure music settings for this server.") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((sc) => + sc.setName("show").setDescription("Show current music settings.") + ) + .addSubcommand((sc) => + sc + .setName("set") + .setDescription("Set basic music defaults.") + .addIntegerOption((o) => + o.setName("volume").setDescription("Default volume (0โ€“200)") + ) + .addBooleanOption((o) => + o + .setName("autoplay") + .setDescription("Autoplay when queue ends (true/false)") + ) + .addIntegerOption((o) => + o.setName("maxqueue").setDescription("Max queue size (1โ€“5000)") + ) + .addIntegerOption((o) => + o + .setName("maxplaylist") + .setDescription("Max playlist import size (1โ€“2000)") + ) + ) + .addSubcommand((sc) => + sc + .setName("djrole-add") + .setDescription("Allow a role to use DJ commands.") + .addRoleOption((o) => + o.setName("role").setDescription("Role").setRequired(true) + ) + ) + .addSubcommand((sc) => + sc + .setName("djrole-remove") + .setDescription("Remove a DJ role.") + .addRoleOption((o) => + o.setName("role").setDescription("Role").setRequired(true) + ) + ) + .addSubcommand((sc) => + sc + .setName("channel-allow") + .setDescription( + "Restrict music commands to a text channel (call multiple times)." + ) + .addChannelOption((o) => + o.setName("channel").setDescription("Text channel").setRequired(true) + ) + ) + .addSubcommand((sc) => + sc + .setName("channel-remove") + .setDescription("Remove an allowed text channel.") + .addChannelOption((o) => + o.setName("channel").setDescription("Text channel").setRequired(true) + ) + ) + .addSubcommand((sc) => + sc + .setName("channel-clear") + .setDescription("Allow music commands in all text channels.") + ), + category: "Admin", + + async execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + const guildId = interaction.guildId; + + const sub = interaction.options.getSubcommand(); + const settings = await ensure(guildId); + + if (sub === "show") { + const lines = [ + `**Default Volume:** ${settings.defaultVolume}%`, + `**Autoplay:** ${settings.autoplay ? "On" : "Off"}`, + `**Max Queue:** ${settings.maxQueue}`, + `**Max Playlist Import:** ${settings.maxPlaylistImport}`, + `**DJ Roles:** ${ + settings.djRoleIds.length + ? settings.djRoleIds.map((id) => `<@&${id}>`).join(", ") + : "*none*" + }`, + `**Allowed Text Channels:** ${ + settings.allowedTextChannelIds.length + ? settings.allowedTextChannelIds.map((id) => `<#${id}>`).join(", ") + : "*all*" + }`, + ]; + return interaction.followUp(lines.join("\n")); + } + + // mutate helpers + const update = {}; + if (sub === "set") { + const vol = interaction.options.getInteger("volume"); + const autoplay = interaction.options.getBoolean("autoplay"); + const maxQ = interaction.options.getInteger("maxqueue"); + const maxP = interaction.options.getInteger("maxplaylist"); + + if (vol !== null) { + if (vol < 0 || vol > 200) + return interaction.followUp(err("Volume must be 0โ€“200.")); + update.defaultVolume = vol; + } + if (autoplay !== null) update.autoplay = autoplay; + if (maxQ !== null) { + if (maxQ < 1 || maxQ > 5000) + return interaction.followUp(err("Max queue must be 1โ€“5000.")); + update.maxQueue = maxQ; + } + if (maxP !== null) { + if (maxP < 1 || maxP > 2000) + return interaction.followUp(err("Max playlist must be 1โ€“2000.")); + update.maxPlaylistImport = maxP; + } + + await set(guildId, update); + return interaction.followUp(ok("Settings updated.")); + } + + if (sub === "djrole-add") { + const role = interaction.options.getRole("role", true); + const setDoc = new Set(settings.djRoleIds || []); + setDoc.add(role.id); + await set(guildId, { djRoleIds: Array.from(setDoc) }); + return interaction.followUp(ok(`Added DJ role ${role}.`)); + } + + if (sub === "djrole-remove") { + const role = interaction.options.getRole("role", true); + const setDoc = new Set(settings.djRoleIds || []); + setDoc.delete(role.id); + await set(guildId, { djRoleIds: Array.from(setDoc) }); + return interaction.followUp(ok(`Removed DJ role ${role}.`)); + } + + if (sub === "channel-allow") { + const ch = interaction.options.getChannel("channel", true); + const setDoc = new Set(settings.allowedTextChannelIds || []); + setDoc.add(ch.id); + await set(guildId, { allowedTextChannelIds: Array.from(setDoc) }); + return interaction.followUp(ok(`Allowed ${ch} for music commands.`)); + } + + if (sub === "channel-remove") { + const ch = interaction.options.getChannel("channel", true); + const setDoc = new Set(settings.allowedTextChannelIds || []); + setDoc.delete(ch.id); + await set(guildId, { allowedTextChannelIds: Array.from(setDoc) }); + return interaction.followUp(ok(`Removed ${ch} from allowed channels.`)); + } + + if (sub === "channel-clear") { + await set(guildId, { allowedTextChannelIds: [] }); + return interaction.followUp( + ok("Cleared channel restrictions (music commands allowed everywhere).") + ); + } + + return interaction.followUp(err("Unknown subcommand.")); + }, +}; diff --git a/commands/ai/chat.js b/commands/ai/chat.js index 66ddfce..4bcb1d4 100644 --- a/commands/ai/chat.js +++ b/commands/ai/chat.js @@ -12,6 +12,8 @@ module.exports = { .setDescription("Your message to the AI") .setRequired(true) ), + category: "AI", + async execute(interaction) { await interaction.deferReply(); // Defer initial response diff --git a/commands/core/help.js b/commands/core/help.js index ccde2c4..d4bcc98 100644 --- a/commands/core/help.js +++ b/commands/core/help.js @@ -8,6 +8,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("help") .setDescription("Lists all available commands"), + category: "Core", async execute(interaction, client) { try { @@ -16,16 +17,19 @@ module.exports = { ); const serverName = interaction.guild.name; - const generalCommands = []; - const modCommands = []; - // Categorize commands + // Group commands by category + const categories = {}; client.commands.forEach((command) => { + const category = command.category || "Uncategorized"; // Default to "Uncategorized" + if (!categories[category]) { + categories[category] = []; + } + const commandLine = `/${command.data.name} - ${command.data.description}`; - if (!command.isModOnly) { - generalCommands.push(commandLine); - } else if (isMod) { - modCommands.push(`${commandLine} (Mods only)`); + // Check if command is mod-only and user has permissions + if (!command.isModOnly || (command.isModOnly && isMod)) { + categories[category].push(commandLine); } }); @@ -43,47 +47,37 @@ module.exports = { // Function to split commands into fields under 1024 characters const addCommandFields = (embed, commands, title) => { + if (commands.length === 0) return; + let commandChunk = ""; let chunkCount = 1; commands.forEach((command) => { - // Check if adding this command will exceed the 1024 character limit - if ((commandChunk + command).length > 1024) { - // Add current chunk as a new field + if ((commandChunk + command + "\n").length > 1024) { embed.addFields({ name: `${title} (Part ${chunkCount})`, value: commandChunk, }); - commandChunk = ""; // Reset chunk for new field + commandChunk = ""; chunkCount += 1; } - // Append command to the current chunk commandChunk += command + "\n"; }); - // Add any remaining commands in the last chunk if (commandChunk) { embed.addFields({ - name: `${title} (Part ${chunkCount})`, + name: chunkCount > 1 ? `${title} (Part ${chunkCount})` : title, value: commandChunk, }); } }; - // Add general commands in fields - if (generalCommands.length > 0) { - addCommandFields(helpEmbed, generalCommands, "General Commands"); + // Add commands for each category + for (const [categoryName, commands] of Object.entries(categories)) { + addCommandFields(helpEmbed, commands, `${categoryName} Commands`); } - // Add mod-only commands in fields, if user is a mod - if (isMod && modCommands.length > 0) { - addCommandFields(helpEmbed, modCommands, "Mod-Only Commands"); - } - - // Send the single embed - await interaction.reply({ - embeds: [helpEmbed], - }); + await interaction.reply({ embeds: [helpEmbed] }); } catch (error) { console.error("Error executing the help command:", error); await interaction.reply({ diff --git a/commands/core/invite.js b/commands/core/invite.js index 5a92a93..baaf998 100644 --- a/commands/core/invite.js +++ b/commands/core/invite.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("invite") .setDescription("Provides an invite link to add the bot to your server."), + category: "Core", async execute(interaction, client) { try { diff --git a/commands/core/ping.js b/commands/core/ping.js index 61ca06d..8ba7bfa 100644 --- a/commands/core/ping.js +++ b/commands/core/ping.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("ping") .setDescription("Replies with Pong! and bot latency"), + category: "Core", async execute(interaction, client) { try { diff --git a/commands/core/uptime.js b/commands/core/uptime.js index 9d08fb0..6cf72a3 100644 --- a/commands/core/uptime.js +++ b/commands/core/uptime.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("uptime") .setDescription("Shows how long the bot has been running"), + category: "Core", async execute(interaction, client) { try { diff --git a/commands/economy/balance.js b/commands/economy/balance.js index 45ceb61..e107fb1 100644 --- a/commands/economy/balance.js +++ b/commands/economy/balance.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("balance") .setDescription("Check your balance."), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/daily.js b/commands/economy/daily.js index 1bf3ffa..b1afa5f 100644 --- a/commands/economy/daily.js +++ b/commands/economy/daily.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("daily") .setDescription("Claim your daily reward and start a streak!"), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/inventory.js b/commands/economy/inventory.js index a9ec5ff..7bc0dcf 100644 --- a/commands/economy/inventory.js +++ b/commands/economy/inventory.js @@ -6,6 +6,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("inventory") .setDescription("View your inventory with item rarity"), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/sell.js b/commands/economy/sell.js index 27276c6..f0422fc 100644 --- a/commands/economy/sell.js +++ b/commands/economy/sell.js @@ -12,6 +12,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("sell") .setDescription("Sell an item from your inventory."), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/shop.js b/commands/economy/shop.js index 007a644..7544dbf 100644 --- a/commands/economy/shop.js +++ b/commands/economy/shop.js @@ -13,6 +13,7 @@ module.exports = { .setDescription("The item you want to buy (use item name)") .setRequired(false) ), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/trade.js b/commands/economy/trade.js index 3426b07..b89e3f1 100644 --- a/commands/economy/trade.js +++ b/commands/economy/trade.js @@ -31,6 +31,7 @@ module.exports = { .setDescription("Amount of coins to trade") .setRequired(false) ), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/economy/work.js b/commands/economy/work.js index ddea4da..0b54cc7 100644 --- a/commands/economy/work.js +++ b/commands/economy/work.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("work") .setDescription("Work to earn coins and experience random events!"), + category: "Economy", async execute(interaction) { const { user, guild } = interaction; diff --git a/commands/events/createEvent.js b/commands/events/createEvent.js index afb9c86..fe6f19c 100644 --- a/commands/events/createEvent.js +++ b/commands/events/createEvent.js @@ -60,6 +60,8 @@ module.exports = { { name: "Monthly", value: "monthly" } ) ), + category: "Events", + async execute(interaction) { const name = interaction.options.getString("name"); const description = interaction.options.getString("description"); diff --git a/commands/events/editEvent.js b/commands/events/editEvent.js index 6857881..2775e0b 100644 --- a/commands/events/editEvent.js +++ b/commands/events/editEvent.js @@ -55,6 +55,7 @@ module.exports = { { name: "Monthly", value: "monthly" } ) ), + category: "Events", async execute(interaction) { const name = interaction.options.getString("name"); diff --git a/commands/events/joinEvent.js b/commands/events/joinEvent.js index 306a70c..fbd75fc 100644 --- a/commands/events/joinEvent.js +++ b/commands/events/joinEvent.js @@ -13,6 +13,7 @@ module.exports = { .setDescription("Name of the event to join") .setRequired(true) ), + category: "Events", async execute(interaction) { const eventName = interaction.options.getString("event_name"); diff --git a/commands/events/leaveEvent.js b/commands/events/leaveEvent.js index 73e4031..43e71c6 100644 --- a/commands/events/leaveEvent.js +++ b/commands/events/leaveEvent.js @@ -12,6 +12,7 @@ module.exports = { .setDescription("Name of the event to leave") .setRequired(true) ), + category: "Events", async execute(interaction) { const eventName = interaction.options.getString("event_name"); diff --git a/commands/events/listEvents.js b/commands/events/listEvents.js index 3e2be9d..4bfd619 100644 --- a/commands/events/listEvents.js +++ b/commands/events/listEvents.js @@ -6,6 +6,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("listevents") .setDescription("List all upcoming events."), + category: "Events", async execute(interaction) { const { user } = interaction; diff --git a/commands/fun/bored.js b/commands/fun/bored.js index 840d8e4..98d5486 100644 --- a/commands/fun/bored.js +++ b/commands/fun/bored.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("bored") .setDescription("Get a random activity to do."), + category: "Fun", async execute(interaction) { try { diff --git a/commands/fun/purr.js b/commands/fun/purr.js index 2f0dd98..bd60ec4 100644 --- a/commands/fun/purr.js +++ b/commands/fun/purr.js @@ -49,6 +49,7 @@ module.exports = { ) .setRequired(false) ), + category: "Fun", async execute(interaction) { const category = interaction.options.getString("category"); diff --git a/commands/fun/randomfact.js b/commands/fun/randomfact.js index 14c4d1e..422c993 100644 --- a/commands/fun/randomfact.js +++ b/commands/fun/randomfact.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("randomfact") .setDescription("Get a random fun fact"), + category: "Fun", async execute(interaction) { try { diff --git a/commands/fun/uwu.js b/commands/fun/uwu.js index 03ae225..ce09a27 100644 --- a/commands/fun/uwu.js +++ b/commands/fun/uwu.js @@ -11,6 +11,8 @@ module.exports = { .setDescription("The text to uwufy") .setRequired(true) ), + category: "Fun", + async execute(interaction) { const inputText = interaction.options.getString("text"); diff --git a/commands/games/coin-flip.js b/commands/games/coin-flip.js index 4d16289..ddb8641 100644 --- a/commands/games/coin-flip.js +++ b/commands/games/coin-flip.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("coinflip") .setDescription("Flip a coin!"), + category: "Games", async execute(interaction) { const result = Math.random() < 0.5 ? "Heads" : "Tails"; diff --git a/commands/games/dice-roll.js b/commands/games/dice-roll.js index c161a1d..1ab34f6 100644 --- a/commands/games/dice-roll.js +++ b/commands/games/dice-roll.js @@ -12,6 +12,7 @@ module.exports = { .setMinValue(2) .setMaxValue(100) ), + category: "Games", async execute(interaction) { const sides = interaction.options.getInteger("sides") || 6; diff --git a/commands/games/rock-paper-scissors.js b/commands/games/rock-paper-scissors.js index 04b9c78..2710085 100644 --- a/commands/games/rock-paper-scissors.js +++ b/commands/games/rock-paper-scissors.js @@ -15,6 +15,7 @@ module.exports = { { name: "Scissors", value: "scissors" } ) ), + category: "Games", async execute(interaction) { const userChoice = interaction.options.getString("choice"); diff --git a/commands/games/scramble.js b/commands/games/scramble.js index b69188e..2c4ece3 100644 --- a/commands/games/scramble.js +++ b/commands/games/scramble.js @@ -102,6 +102,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("scramble") .setDescription("Play a word scramble game"), + category: "Games", async execute(interaction) { const userId = interaction.user.id; diff --git a/commands/games/spyfall.js b/commands/games/spyfall.js index c897821..4eaf7e5 100644 --- a/commands/games/spyfall.js +++ b/commands/games/spyfall.js @@ -13,6 +13,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("spyfall") .setDescription("Start a game of Spyfall."), + category: "Games", async execute(interaction) { const guildId = interaction.guild.id; diff --git a/commands/games/stopspyfall.js b/commands/games/stopspyfall.js index eeba7a8..b75d288 100644 --- a/commands/games/stopspyfall.js +++ b/commands/games/stopspyfall.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("stopspyfall") .setDescription("Stop the current Spyfall game in this server."), + category: "Games", async execute(interaction) { try { diff --git a/commands/games/trivia.js b/commands/games/trivia.js index 005d09f..e3761aa 100644 --- a/commands/games/trivia.js +++ b/commands/games/trivia.js @@ -293,6 +293,7 @@ module.exports = { })) ) ), + category: "Games", async execute(interaction, client) { const userId = interaction.user.id; diff --git a/commands/general/dictionary.js b/commands/general/dictionary.js index b1fa9cb..57ecee1 100644 --- a/commands/general/dictionary.js +++ b/commands/general/dictionary.js @@ -26,6 +26,8 @@ module.exports = { .setDescription("Whether the response should be ephemeral") .setRequired(false) ), + category: "General", + async execute(interaction) { const word = interaction.options.getString("word").toLowerCase(); const isEphemeral = interaction.options.getBoolean("ephemeral") || false; diff --git a/commands/general/thisDayInHistory.js b/commands/general/thisDayInHistory.js index 65a4f87..20109be 100644 --- a/commands/general/thisDayInHistory.js +++ b/commands/general/thisDayInHistory.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("thisdayinhistory") .setDescription("Shows historical events that happened on this day."), + category: "General", async execute(interaction) { try { diff --git a/commands/general/urbanDictionary.js b/commands/general/urbanDictionary.js index 4242513..6519a74 100644 --- a/commands/general/urbanDictionary.js +++ b/commands/general/urbanDictionary.js @@ -12,6 +12,8 @@ module.exports = { .setDescription("The term to look up") .setRequired(true) ), + category: "General", + async execute(interaction, client) { const term = interaction.options.getString("term").toLowerCase(); const guild = interaction.guild; diff --git a/commands/general/wordassociation.js b/commands/general/wordassociation.js index 15c27f2..34fdb5e 100644 --- a/commands/general/wordassociation.js +++ b/commands/general/wordassociation.js @@ -83,6 +83,7 @@ module.exports = { .setDescription("The word to find associations for") .setRequired(true) ), + category: "General", async execute(interaction) { const word = interaction.options.getString("word"); diff --git a/commands/information/botinfo.js b/commands/information/botinfo.js index 8a1562b..0b60380 100644 --- a/commands/information/botinfo.js +++ b/commands/information/botinfo.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("botinfo") .setDescription("Displays information about the bot"), + category: "Information", async execute(interaction, client) { try { diff --git a/commands/information/serverinfo.js b/commands/information/serverinfo.js index 091a579..9090003 100644 --- a/commands/information/serverinfo.js +++ b/commands/information/serverinfo.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("serverinfo") .setDescription("Displays information about the server"), + category: "Information", async execute(interaction) { try { diff --git a/commands/minecraft/whitelist.js b/commands/minecraft/whitelist.js index 5a4f17f..a43d300 100644 --- a/commands/minecraft/whitelist.js +++ b/commands/minecraft/whitelist.js @@ -12,6 +12,7 @@ module.exports = { .setRequired(true) ), isModOnly: true, + category: "Minecraft", async execute(interaction) { const username = interaction.options.getString("username"); diff --git a/commands/moderation/Servers.js b/commands/moderation/Servers.js index 560cce9..84f94b0 100644 --- a/commands/moderation/Servers.js +++ b/commands/moderation/Servers.js @@ -9,6 +9,7 @@ module.exports = { .setName("servers") .setDescription("Displays a list of servers the bot is currently in"), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/moderation/actionItems.js b/commands/moderation/actionItems.js index e83aaa1..aa7b392 100644 --- a/commands/moderation/actionItems.js +++ b/commands/moderation/actionItems.js @@ -15,6 +15,7 @@ module.exports = { ) .setRequired(true) ), + category: "Moderation", async execute(interaction) { const serverSettings = await ServerSettings.findOne({ diff --git a/commands/moderation/ban.js b/commands/moderation/ban.js index 218ed3d..383a178 100644 --- a/commands/moderation/ban.js +++ b/commands/moderation/ban.js @@ -19,6 +19,7 @@ module.exports = { .setRequired(true) ), isModOnly: true, + category: "Moderation", async execute(interaction) { let replySent = false; diff --git a/commands/moderation/clearLeaderboard.js b/commands/moderation/clearLeaderboard.js index b38fb64..cfb3d5e 100644 --- a/commands/moderation/clearLeaderboard.js +++ b/commands/moderation/clearLeaderboard.js @@ -6,6 +6,7 @@ module.exports = { .setName("clearleaderboard") .setDescription("Clears all entries in the trivia leaderboard"), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/moderation/kick.js b/commands/moderation/kick.js index 128e817..a3dd1ed 100644 --- a/commands/moderation/kick.js +++ b/commands/moderation/kick.js @@ -22,6 +22,7 @@ module.exports = { .setRequired(true) ), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/moderation/purge.js b/commands/moderation/purge.js index 6a547f1..71ccd7b 100644 --- a/commands/moderation/purge.js +++ b/commands/moderation/purge.js @@ -27,6 +27,7 @@ module.exports = { .setMaxValue(100) ), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/moderation/serverSettings.js b/commands/moderation/serverSettings.js index 56d428a..7e0ad80 100644 --- a/commands/moderation/serverSettings.js +++ b/commands/moderation/serverSettings.js @@ -11,6 +11,7 @@ module.exports = { .setDescription("Displays the current server settings"), isModOnly: true, + category: "Moderation", async execute(interaction) { let replySent = false; diff --git a/commands/moderation/setup.js b/commands/moderation/setup.js index aeaa10b..8ee3ceb 100644 --- a/commands/moderation/setup.js +++ b/commands/moderation/setup.js @@ -51,6 +51,7 @@ module.exports = { ) .setRequired(false) ), + category: "Moderation", async execute(interaction) { // Check if the user has admin permissions diff --git a/commands/moderation/timeout.js b/commands/moderation/timeout.js index 000465c..0149e3d 100644 --- a/commands/moderation/timeout.js +++ b/commands/moderation/timeout.js @@ -28,6 +28,7 @@ module.exports = { .setRequired(true) ), isModOnly: true, + category: "Moderation", async execute(interaction) { let replySent = false; diff --git a/commands/moderation/userinfo.js b/commands/moderation/userinfo.js index 4e62681..04df06f 100644 --- a/commands/moderation/userinfo.js +++ b/commands/moderation/userinfo.js @@ -15,6 +15,7 @@ module.exports = { .setRequired(false) ), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/moderation/warn.js b/commands/moderation/warn.js index 63b9140..37444c2 100644 --- a/commands/moderation/warn.js +++ b/commands/moderation/warn.js @@ -22,6 +22,7 @@ module.exports = { .setRequired(true) ), isModOnly: true, + category: "Moderation", async execute(interaction) { try { diff --git a/commands/music/livelyrics.js b/commands/music/livelyrics.js new file mode 100644 index 0000000..d0149c2 --- /dev/null +++ b/commands/music/livelyrics.js @@ -0,0 +1,81 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("livelyrics") + .setDescription( + "Show synced lyrics in a live thread for the current track." + ) + .addSubcommand((sc) => + sc + .setName("start") + .setDescription("Start live lyrics for the currently playing song.") + .addBooleanOption((o) => + o + .setName("newthread") + .setDescription( + "Force a new thread even if one exists (default: reuse)." + ) + ) + ) + .addSubcommand((sc) => + sc + .setName("stop") + .setDescription("Stop live lyrics and remove the thread.") + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + const sub = interaction.options.getSubcommand(); + + // Make sure user is in/with the VC and there is a queue + requireVC(interaction); + const queue = requireQueue(client, interaction); + + // Lazy-load manager to avoid circular requires on startup + const live = require("../../utils/liveLyricsManager"); + + if (sub === "start") { + const forceNew = interaction.options.getBoolean("newthread") ?? false; + + // If a thread is already running and user didn't force, just re-sync and say it's on + if (!forceNew) { + // Re-sync to current time if already active (no-op otherwise) + await live.resume(queue); + await live.seek(queue); + } else { + // Ensure any old thread is removed before starting a new one + await live.stop(queue.id, { deleteThread: true }); + } + + const song = queue.songs?.[0]; + if (!song) { + return interaction.followUp("โŒ Nothing is playing."); + } + + await live.start(queue, song); + return interaction.followUp( + "๐ŸŽค Live lyrics started. Iโ€™ll post lines in a thread (or here if I canโ€™t create one)." + ); + } else if (sub === "stop") { + await live.stop(queue.id, { deleteThread: true }); + return interaction.followUp( + "๐Ÿงน Stopped live lyrics and removed the thread." + ); + } + + return interaction.followUp("โŒ Unknown subcommand."); + } catch (e) { + const msg = e?.message ?? "โŒ Failed to run /livelyrics."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } + }, +}; diff --git a/commands/music/loop.js b/commands/music/loop.js new file mode 100644 index 0000000..a95db46 --- /dev/null +++ b/commands/music/loop.js @@ -0,0 +1,71 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("loop") + .setDescription("Loop the current song or entire queue") + .addStringOption((option) => + option + .setName("mode") + .setDescription("Loop mode") + .setRequired(true) + .addChoices( + { name: "Track (Current Song)", value: "track" }, + { name: "Queue (All Songs)", value: "queue" }, + { name: "Off (Disable Loop)", value: "off" } + ) + ), + category: "Music", + + async execute(interaction, client) { + await interaction.deferReply(); + + const queue = client.distube.getQueue(interaction.guildId); + const mode = interaction.options.getString("mode"); + + if (!queue || !queue.songs.length) { + return interaction.followUp("โŒ There is no music playing!"); + } + + try { + let modeValue; + let modeText; + + switch (mode) { + case "track": + modeValue = 1; + modeText = "๐Ÿ”‚ Track Loop"; + break; + case "queue": + modeValue = 2; + modeText = "๐Ÿ” Queue Loop"; + break; + case "off": + modeValue = 0; + modeText = "โ–ถ๏ธ Loop Disabled"; + break; + default: + modeValue = 0; + modeText = "โ–ถ๏ธ Loop Disabled"; + } + + await queue.setRepeatMode(modeValue); + + const embed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle("๐Ÿ” Loop Mode Updated") + .setDescription( + `**${modeText}**\n\nCurrent song: **${queue.songs[0].name}**` + ) + .setFooter({ text: `Requested by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.followUp({ embeds: [embed] }); + } catch (error) { + console.error("Error setting loop mode:", error); + await interaction.followUp( + "โŒ Failed to set loop mode. Please try again." + ); + } + }, +}; diff --git a/commands/music/lyrics.js b/commands/music/lyrics.js new file mode 100644 index 0000000..25269b9 --- /dev/null +++ b/commands/music/lyrics.js @@ -0,0 +1,91 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const Genius = require("genius-lyrics"); // Correct import + +const geniusClient = new Genius.Client(process.env.GENIUS_API_TOKEN); + +module.exports = { + data: new SlashCommandBuilder() + .setName("lyrics") + .setDescription("Get lyrics for the current song or a specific song") + .addStringOption((option) => + option + .setName("song") + .setDescription("Song name to search lyrics for (optional)") + .setRequired(false) + ), + category: "Music", + + async execute(interaction, client) { + await interaction.deferReply(); + + let songQuery = interaction.options.getString("song"); + const queue = client.distube.getQueue(interaction.guildId); + + if (!songQuery && queue && queue.songs.length > 0) { + songQuery = queue.songs[0].name; + songQuery = songQuery.replace(/\([^)]*\)|\[[^\]]*\]/g, "").trim(); + } + + if (!songQuery) { + return interaction.followUp( + "โŒ Please specify a song name or play a song first!" + ); + } + + try { + const searches = await geniusClient.songs.search(songQuery); + if (searches.length === 0) { + return interaction.followUp("โŒ No lyrics found for this song!"); + } + + const song = searches[0]; + const lyrics = await song.lyrics(); + + if (lyrics.length > 4096) { + const lyricChunks = []; + for (let i = 0; i < lyrics.length; i += 4090) { + lyricChunks.push(lyrics.substring(i, i + 4090)); + } + + const firstEmbed = new EmbedBuilder() + .setColor("#FF0000") + .setTitle(`Lyrics for: ${song.title}`) + .setDescription(lyricChunks[0]) + .setThumbnail(song.thumbnail) + .setFooter({ + text: `Part 1/${lyricChunks.length} - Powered by Genius API`, + }); + + await interaction.followUp({ embeds: [firstEmbed] }); + + for (let i = 1; i < lyricChunks.length; i++) { + const chunkEmbed = new EmbedBuilder() + .setColor("#FF0000") + .setDescription(lyricChunks[i]) + .setFooter({ + text: `Part ${i + 1}/${ + lyricChunks.length + } - Powered by Genius API`, + }); + + await interaction.followUp({ embeds: [chunkEmbed] }); + } + return; + } + + const embed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle(`Lyrics for: ${song.title}`) + .setDescription(lyrics) + .setThumbnail(song.thumbnail) + .setFooter({ text: "Powered by Genius API" }); + + await interaction.followUp({ embeds: [embed] }); + } catch (error) { + console.error("Error fetching lyrics:", error); + await interaction.followUp( + "โŒ Failed to fetch lyrics. Please try again later." + ); + } + }, +}; diff --git a/commands/music/nowplaying.js b/commands/music/nowplaying.js new file mode 100644 index 0000000..2b3f7be --- /dev/null +++ b/commands/music/nowplaying.js @@ -0,0 +1,86 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +function fmtTime(totalSeconds = 0) { + const s = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` + : `${m}:${String(sec).padStart(2, "0")}`; +} + +function makeBar(current, duration, size = 20) { + if (!Number.isFinite(duration) || duration <= 0) { + // For live/unknown duration, just show a moving head at start + const head = "๐Ÿ”˜"; + const rest = "โ”€".repeat(size - 1); + return `${head}${rest}`; + } + const ratio = Math.min(1, Math.max(0, current / duration)); + const filled = Math.round(ratio * size); + const head = "๐Ÿ”˜"; + const left = "โ”€".repeat(Math.max(0, filled - 1)); + const right = "โ”€".repeat(Math.max(0, size - filled)); + return `${left}${head}${right}`; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("nowplaying") + .setDescription("Shows information about the current song"), + category: "Music", + + async execute(interaction, client) { + try { + const queue = client.distube.getQueue(interaction.guildId); + if (!queue || !queue.songs?.length) { + return interaction.reply("โŒ There is no music playing!"); + } + + const song = queue.songs[0]; + const current = Math.floor(queue.currentTime ?? 0); // seconds + const duration = Number.isFinite(song.duration) + ? Math.floor(song.duration) + : null; + + const positionStr = + duration != null + ? `${fmtTime(current)} / ${fmtTime(duration)}` + : `${fmtTime(current)} / LIVE`; + + const bar = makeBar(current, duration ?? 0, 20); + + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle("๐ŸŽต Now Playing") + .setDescription( + [ + `**${song.name || "Unknown title"}**`, + "", + `\`\`\`${bar}\`\`\``, + `**Position:** \`${positionStr}\``, + ].join("\n") + ) + .addFields( + { + name: "Requested by", + value: song.user?.toString?.() || "Unknown", + inline: true, + }, + { name: "Volume", value: `${queue.volume ?? 100}%`, inline: true }, + { name: "URL", value: song.url || "No URL available", inline: false } + ) + .setThumbnail(song.thumbnail || null); + + return interaction.reply({ embeds: [embed] }); + } catch (e) { + console.error("nowplaying failed:", e); + const msg = "โŒ Failed to show now playing info."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/pause.js b/commands/music/pause.js new file mode 100644 index 0000000..fee5382 --- /dev/null +++ b/commands/music/pause.js @@ -0,0 +1,36 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("pause") + .setDescription("Pauses the current song."), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply({ ephemeral: false }); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + if (queue.paused) { + return interaction.followUp({ + content: "โธ๏ธ Music is already paused.", + ephemeral: true, + }); + } + + queue.pause(); + return interaction.followUp("โธ๏ธ Paused the current song!"); + } catch (e) { + console.error("pause command failed:", e); + const msg = e?.message || "โŒ Failed to pause the music."; + // If something above threw (e.g., guards), ensure user gets a response + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/play.js b/commands/music/play.js new file mode 100644 index 0000000..a704984 --- /dev/null +++ b/commands/music/play.js @@ -0,0 +1,58 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { requireVC } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("play") + .setDescription( + "Play a song or playlist (YouTube by default; Spotify supported)." + ) + .addStringOption((option) => + option + .setName("query") + .setDescription("URL or search query") + .setRequired(true) + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + const vc = requireVC(interaction); + + // Early permission check for better UX + const me = interaction.guild.members.me; + const perms = vc.permissionsFor(me); + if ( + !perms?.has(PermissionFlagsBits.Connect) || + !perms?.has(PermissionFlagsBits.Speak) + ) { + throw new Error( + "โŒ I don't have permission to **Connect**/**Speak** in your voice channel." + ); + } + + const query = interaction.options.getString("query", true).trim(); + if (!query) throw new Error("โŒ Give me something to play."); + + // Avoid insanely long strings + if (query.length > 2000) throw new Error("โŒ Query too long."); + + // Play! (YouTube is default via plugin order) + await client.distube.play(vc, query, { + textChannel: interaction.channel, + member: interaction.member, + }); + + await interaction.followUp(`๐Ÿ” Searching **${query.slice(0, 128)}**โ€ฆ`); + } catch (e) { + const msg = e?.message ?? "โŒ Failed to play."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } + }, +}; diff --git a/commands/music/queue.js b/commands/music/queue.js new file mode 100644 index 0000000..8af54d4 --- /dev/null +++ b/commands/music/queue.js @@ -0,0 +1,269 @@ +const { + SlashCommandBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + StringSelectMenuBuilder, + ComponentType, +} = require("discord.js"); +const { requireQueue } = require("../../utils/musicGuards"); + +const PAGE_SIZE = 10; +const COLLECTOR_IDLE_MS = 60_000; // stop listening after 60s idle +const REFRESH_INTERVAL_MS = 2000; // live refresh throttle + +function fmtHMS(totalSeconds = 0) { + const s = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` + : `${m}:${String(sec).padStart(2, "0")}`; +} + +function safe(text, max = 128) { + if (!text) return ""; + text = String(text); + return text.length > max ? text.slice(0, max - 1) + "โ€ฆ" : text; +} + +function sumDurations(songs) { + let total = 0; + for (const s of songs) if (Number.isFinite(s?.duration)) total += s.duration; + return total; +} + +function queueFingerprint(q) { + // A tiny hash-ish snapshot: current song id/url + length + volume + const now = q.songs?.[0]; + const id = now?.id || now?.url || now?.name || ""; + return `${id}|len:${q.songs?.length || 0}|vol:${q.volume || 0}|t:${Math.floor( + q.currentTime || 0 + )}`; +} + +/** Build a single queue page embed */ +function buildEmbed(queue, page, totalPages) { + const now = queue.songs[0]; + const upcoming = queue.songs.slice(1); + + const start = page * PAGE_SIZE; + const end = Math.min(start + PAGE_SIZE, upcoming.length); + const chunk = upcoming.slice(start, end); + + const lines = chunk.map((song, i) => { + const index = start + i + 1; + const dur = Number.isFinite(song.duration) ? fmtHMS(song.duration) : "LIVE"; + const requester = song.user?.id || song.member?.id; + return `**${index}.** ${safe(song.name, 90)} โ€” \`${dur}\`${ + requester ? ` โ€ข <@${requester}>` : "" + }`; + }); + + const totalLen = sumDurations(queue.songs); + const footerParts = [ + `Page ${page + 1}/${totalPages}`, + `Volume ${queue.volume ?? 100}%`, + `Total: ${fmtHMS(totalLen)} โ€ข ${queue.songs.length} track${ + queue.songs.length === 1 ? "" : "s" + }`, + ]; + + const embed = new EmbedBuilder() + .setColor(0x00a2ff) + .setTitle("๐ŸŽถ Music Queue") + .setDescription( + [ + `**Now Playing**`, + `${safe(now?.name ?? "Nothing", 128)} โ€” \`${ + Number.isFinite(now?.duration) ? fmtHMS(now.duration) : "LIVE" + }\`${now?.user?.id ? ` โ€ข <@${now.user.id}>` : ""}`, + "", + chunk.length ? "**Up Next**" : "*No more songs queued.*", + lines.join("\n") || "", + ].join("\n") + ) + .setFooter({ text: footerParts.join(" โ€ข ") }); + + if (now?.thumbnail) embed.setThumbnail(now.thumbnail); + return embed; +} + +function buildButtons(page, totalPages, disabled = false) { + const first = new ButtonBuilder() + .setCustomId("queue_first") + .setEmoji("โฎ๏ธ") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page === 0); + const prev = new ButtonBuilder() + .setCustomId("queue_prev") + .setEmoji("โ—€๏ธ") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page === 0); + const next = new ButtonBuilder() + .setCustomId("queue_next") + .setEmoji("โ–ถ๏ธ") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page >= totalPages - 1); + const last = new ButtonBuilder() + .setCustomId("queue_last") + .setEmoji("โญ๏ธ") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page >= totalPages - 1); + const stop = new ButtonBuilder() + .setCustomId("queue_stop") + .setEmoji("๐Ÿ›‘") + .setStyle(ButtonStyle.Danger) + .setDisabled(disabled); + + return new ActionRowBuilder().addComponents(first, prev, next, last, stop); +} + +function buildJumpMenu(page, totalPages, disabled = false) { + // Up to 25 options allowed by Discord โ€” group pages in chunks + const options = []; + for (let p = 0; p < totalPages && options.length < 25; p++) { + options.push({ + label: `Page ${p + 1}`, + value: String(p), + description: `Tracks ${p * PAGE_SIZE + 1}โ€“${Math.min( + (p + 1) * PAGE_SIZE, + Math.max(0, totalPages * PAGE_SIZE) + )}`, + default: p === page, + }); + } + const menu = new StringSelectMenuBuilder() + .setCustomId("queue_jump") + .setPlaceholder("Jump to pageโ€ฆ") + .setDisabled(disabled) + .addOptions(options); + + return new ActionRowBuilder().addComponents(menu); +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("queue") + .setDescription("Show the current music queue (live, paginated)."), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + const queue = requireQueue(client, interaction); + const upcomingCount = Math.max(0, queue.songs.length - 1); + let totalPages = Math.max(1, Math.ceil(upcomingCount / PAGE_SIZE)); + let page = 0; + + let fingerprint = queueFingerprint(queue); + + const embed = buildEmbed(queue, page, totalPages); + const rowButtons = buildButtons(page, totalPages); + const rowJump = buildJumpMenu(page, totalPages); + + const message = await interaction.followUp({ + embeds: [embed], + components: [rowButtons, rowJump], + }); + + // Live refresh loop (throttled) + let stopped = false; + const interval = setInterval(async () => { + if (stopped) return; + const q = client.distube.getQueue(interaction.guildId); + if (!q) return; // might have ended + const fp = queueFingerprint(q); + if (fp !== fingerprint) { + fingerprint = fp; + // recompute pagination info if size changed + const upCount = Math.max(0, q.songs.length - 1); + totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE)); + if (page > totalPages - 1) page = totalPages - 1; + + const newEmbed = buildEmbed(q, page, totalPages); + const newButtons = buildButtons(page, totalPages); + const newJump = buildJumpMenu(page, totalPages); + try { + await message.edit({ + embeds: [newEmbed], + components: [newButtons, newJump], + }); + } catch {} + } + }, REFRESH_INTERVAL_MS); + + // Component collector (buttons + select menu) + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.MessageComponent, + filter: (i) => i.user.id === interaction.user.id, + idle: COLLECTOR_IDLE_MS, + time: COLLECTOR_IDLE_MS * 3, + }); + + collector.on("collect", async (i) => { + try { + if (i.customId === "queue_stop") { + collector.stop("stopped"); + return i.update({ + components: [ + buildButtons(page, totalPages, true), + buildJumpMenu(page, totalPages, true), + ], + }); + } + + if (i.customId === "queue_jump" && i.isStringSelectMenu()) { + const choice = Number(i.values?.[0] ?? 0); + page = Math.min(Math.max(0, choice), totalPages - 1); + } else if (i.customId === "queue_first") page = 0; + else if (i.customId === "queue_prev") page = Math.max(0, page - 1); + else if (i.customId === "queue_next") + page = Math.min(totalPages - 1, page + 1); + else if (i.customId === "queue_last") page = totalPages - 1; + + const q = client.distube.getQueue(interaction.guildId) ?? queue; + const upCount = Math.max(0, q.songs.length - 1); + totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE)); + if (page > totalPages - 1) page = totalPages - 1; + + const newEmbed = buildEmbed(q, page, totalPages); + const newButtons = buildButtons(page, totalPages); + const newJump = buildJumpMenu(page, totalPages); + await i.update({ + embeds: [newEmbed], + components: [newButtons, newJump], + }); + } catch (err) { + console.error("queue component update failed:", err); + try { + await i.deferUpdate(); + } catch {} + } + }); + + collector.on("end", async () => { + stopped = true; + clearInterval(interval); + try { + await message.edit({ + components: [ + buildButtons(page, totalPages, true), + buildJumpMenu(page, totalPages, true), + ], + }); + } catch {} + }); + } catch (e) { + const msg = e?.message ?? "โŒ Failed to show queue."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } + }, +}; diff --git a/commands/music/resume.js b/commands/music/resume.js new file mode 100644 index 0000000..555f178 --- /dev/null +++ b/commands/music/resume.js @@ -0,0 +1,42 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("resume") + .setDescription("Resumes the paused song."), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + if (!queue.paused) { + return interaction.followUp({ + content: "โ–ถ๏ธ Music is not paused.", + ephemeral: true, + }); + } + + queue.resume(); + + // If you use live lyrics, resume the scheduler to stay in sync + try { + const live = require("../../utils/liveLyricsManager"); + await live.resume(queue); + } catch {} + + return interaction.followUp("โ–ถ๏ธ Resumed the music!"); + } catch (e) { + console.error("resume command failed:", e); + const msg = e?.message || "โŒ Failed to resume the music."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/seek.js b/commands/music/seek.js new file mode 100644 index 0000000..8ed6b78 --- /dev/null +++ b/commands/music/seek.js @@ -0,0 +1,98 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +/** Parse "90", "1:30", "01:02:03", "+30", "-10", "+1:00", "-0:30" */ +function parseTime(input) { + const t = input.trim(); + const sign = t.startsWith("+") ? 1 : t.startsWith("-") ? -1 : 0; + const core = sign ? t.slice(1) : t; + + if (/^\d+$/.test(core)) { + const secs = Number(core); + return { seconds: sign ? sign * secs : secs, relative: Boolean(sign) }; + } + + if (/^\d{1,2}:\d{1,2}(:\d{1,2})?$/.test(core)) { + const parts = core.split(":").map(Number); + let secs = 0; + if (parts.length === 3) { + const [hh, mm, ss] = parts; + secs = hh * 3600 + mm * 60 + ss; + } else { + const [mm, ss] = parts; + secs = mm * 60 + ss; + } + return { seconds: sign ? sign * secs : secs, relative: Boolean(sign) }; + } + + return null; +} + +function fmt(seconds) { + seconds = Math.max(0, Math.floor(seconds)); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return h > 0 + ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` + : `${m}:${String(s).padStart(2, "0")}`; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("seek") + .setDescription("Seek to a timestamp or jump by seconds.") + .addStringOption((opt) => + opt + .setName("to") + .setDescription("e.g. 90, 1:30, 01:02:03, +30, -10") + .setRequired(true) + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + const song = queue.songs?.[0]; + if (!song || !Number.isFinite(song.duration) || song.isLive) { + throw new Error("โŒ This stream/track doesnโ€™t support seeking."); + } + + const input = interaction.options.getString("to", true); + const parsed = parseTime(input); + if (!parsed) { + throw new Error( + "โŒ Invalid time. Use `90`, `1:30`, `01:02:03`, `+30`, or `-10`." + ); + } + + const current = Math.floor(queue.currentTime ?? 0); + const duration = Math.floor(song.duration); + + let target = parsed.relative ? current + parsed.seconds : parsed.seconds; + target = Math.max(0, Math.min(duration - 1, Math.floor(target))); + + await queue.seek(target); + + try { + const live = require("../../utils/liveLyricsManager"); + live.seek(queue, target); + } catch {} + + await interaction.followUp( + `โญ๏ธ Seeked to **${fmt(target)}** (track length \`${fmt(duration)}\`).` + ); + } catch (e) { + const msg = e?.message ?? "โŒ Failed to seek."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content: msg, ephemeral: true }); + } else { + await interaction.reply({ content: msg, ephemeral: true }); + } + } + }, +}; diff --git a/commands/music/shuffle.js b/commands/music/shuffle.js new file mode 100644 index 0000000..e254812 --- /dev/null +++ b/commands/music/shuffle.js @@ -0,0 +1,79 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +function fisherYates(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("shuffle") + .setDescription("Shuffles the up-next songs (keeps the current track).") + .addIntegerOption((o) => + o + .setName("amount") + .setDescription( + "Only shuffle the first N upcoming songs (default: all)." + ) + .setMinValue(2) + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + const total = queue.songs.length; + if (total <= 2) { + // 0 or 1 upcoming track is not worth shuffling + return interaction.followUp({ + content: "โŒ Not enough songs to shuffle.", + ephemeral: true, + }); + } + + const upcoming = queue.songs.slice(1); // exclude the currently playing track + const amountOpt = interaction.options.getInteger("amount"); + const amount = Math.min( + Math.max(amountOpt ?? upcoming.length, 2), + upcoming.length + ); + + // If user didn't specify an amount, prefer DisTube's built-in shuffle + if (amountOpt == null && typeof queue.shuffle === "function") { + // DisTube's shuffle shuffles upcoming by default (keeps current) + queue.shuffle(); + return interaction.followUp( + `๐Ÿ”€ Shuffled **${upcoming.length}** upcoming track(s)!` + ); + } + + // Manual partial shuffle (first N upcoming) + const head = upcoming.slice(0, amount); + const tail = upcoming.slice(amount); + fisherYates(head); + + // Splice back into queue: [ current, ...shuffledHead, ...tail ] + queue.songs.splice(1, amount, ...head); + // tail is already in place so no need to modify if amount === upcoming.length + + return interaction.followUp( + `๐Ÿ”€ Shuffled the next **${amount}** track(s).` + ); + } catch (e) { + console.error("shuffle command failed:", e); + const msg = e?.message || "โŒ Failed to shuffle the queue."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/skip.js b/commands/music/skip.js new file mode 100644 index 0000000..7e86f26 --- /dev/null +++ b/commands/music/skip.js @@ -0,0 +1,140 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("skip") + .setDescription( + "Skips the current song (or jump to a specific upcoming track)." + ) + .addIntegerOption((o) => + o + .setName("to") + .setDescription( + "Queue index to jump to (as shown in /queue). 1 = next song." + ) + .setMinValue(1) + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + const toIndex = interaction.options.getInteger("to"); // 1-based (1 = next) + const total = queue.songs.length; + const upcomingCount = Math.max(0, total - 1); + + if (upcomingCount === 0 && !queue.autoplay) { + // Nothing to skip to, and autoplay is off -> stop and leave + try { + queue.stop(); + } catch {} + client.distube.voices.leave(interaction.guildId); + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + return interaction.followUp( + "๐Ÿ No more songs โ€” stopped and left the voice channel." + ); + } + + // If user specified a target index (jump) + if (toIndex != null) { + if (toIndex > upcomingCount) { + // Jumping past the end + // Clear all upcoming; then behave like โ€œno more songsโ€ + queue.songs.splice(1); // remove all upcoming + if (!queue.autoplay) { + try { + queue.stop(); + } catch {} + client.distube.voices.leave(interaction.guildId); + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + return interaction.followUp( + `โญ๏ธ Skipped past the end (${toIndex}). Queue empty โ€” stopped and left.` + ); + } + // Autoplay on: try to skip and let autoplay find a related track + try { + queue.skip(); // will trigger autoplay resolution + } catch {} + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + return interaction.followUp( + `โญ๏ธ Skipped past the end (${toIndex}). Autoplay will pick something.` + ); + } + + // Remove the tracks between current and target (keep current at index 0) + // Example: toIndex=1 -> remove none, weโ€™ll just skip once below. + if (toIndex > 1) { + // delete from position 1..(toIndex-1) + queue.songs.splice(1, toIndex - 1); + } + + // Now the desired target is at index 1, skip once to play it + try { + queue.skip(); + } catch (e) { + // If skip throws but autoplay is on, let autoplay do its thing + if (!queue.autoplay) throw e; + } + + // Stop any active live-lyrics thread for the current song + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + + return interaction.followUp( + `โญ๏ธ Jumped to track **#${toIndex}** in the queue.` + ); + } + + // Simple โ€œskip nextโ€ + try { + queue.skip(); + } catch (e) { + // If thereโ€™s no next but autoplay is on, allow autoplay + if (!queue.autoplay) { + // Fallback: nothing to skip to + try { + queue.stop(); + } catch {} + client.distube.voices.leave(interaction.guildId); + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + return interaction.followUp( + "๐Ÿ No more songs โ€” stopped and left the voice channel." + ); + } + } + + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + + return interaction.followUp("โญ๏ธ Skipped the current song!"); + } catch (e) { + console.error("skip command failed:", e); + const msg = e?.message || "โŒ Failed to skip."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/stop.js b/commands/music/stop.js new file mode 100644 index 0000000..f54aa64 --- /dev/null +++ b/commands/music/stop.js @@ -0,0 +1,51 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC } = require("../../utils/musicGuards"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("stop") + .setDescription( + "Stops playback, clears the queue, and leaves the voice channel." + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + const vc = requireVC(interaction); + const queue = client.distube.getQueue(interaction.guildId); + + if (!queue) { + return interaction.followUp({ + content: "โ„น๏ธ Nothing is playing.", + ephemeral: true, + }); + } + + // Clear the queue and stop playback + // In DisTube v5, `queue.stop()` stops playback and clears upcoming songs. + queue.stop(); + + // Leave the voice channel via manager (recommended) + client.distube.voices.leave(interaction.guildId); + + // If you use live lyrics, clean up the thread + try { + const live = require("../../utils/liveLyricsManager"); + await live.stop(interaction.guildId, { deleteThread: true }); + } catch {} + + return interaction.followUp( + "โน๏ธ Stopped playback, cleared the queue, and left the voice channel." + ); + } catch (e) { + console.error("stop command failed:", e); + const msg = e?.message || "โŒ Failed to stop playback."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/music/volume.js b/commands/music/volume.js new file mode 100644 index 0000000..caa71c5 --- /dev/null +++ b/commands/music/volume.js @@ -0,0 +1,131 @@ +const { SlashCommandBuilder } = require("discord.js"); +const { requireVC, requireQueue } = require("../../utils/musicGuards"); + +function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)); +} + +module.exports = { + data: new SlashCommandBuilder() + .setName("volume") + .setDescription("Manage playback volume (0โ€“200).") + .addSubcommand((sc) => + sc.setName("show").setDescription("Show the current volume.") + ) + .addSubcommand((sc) => + sc + .setName("set") + .setDescription("Set the volume to a specific level (0โ€“200).") + .addIntegerOption((o) => + o + .setName("level") + .setDescription("Volume percent (0โ€“200)") + .setRequired(true) + .setMinValue(0) + .setMaxValue(200) + ) + ) + .addSubcommand((sc) => + sc + .setName("up") + .setDescription("Turn the volume up by N (default 10).") + .addIntegerOption((o) => + o + .setName("by") + .setDescription("Percent to increase (1โ€“100)") + .setMinValue(1) + .setMaxValue(100) + ) + ) + .addSubcommand((sc) => + sc + .setName("down") + .setDescription("Turn the volume down by N (default 10).") + .addIntegerOption((o) => + o + .setName("by") + .setDescription("Percent to decrease (1โ€“100)") + .setMinValue(1) + .setMaxValue(100) + ) + ) + .addSubcommand((sc) => + sc.setName("mute").setDescription("Set volume to 0%.") + ) + .addSubcommand((sc) => + sc + .setName("unmute") + .setDescription("Restore volume to 100% (or specify level).") + .addIntegerOption((o) => + o + .setName("level") + .setDescription("Volume percent (1โ€“200)") + .setMinValue(1) + .setMaxValue(200) + ) + ), + category: "Music", + + async execute(interaction, client) { + try { + await interaction.deferReply(); + + requireVC(interaction); + const queue = requireQueue(client, interaction); + + const sub = interaction.options.getSubcommand(); + const current = clamp(Number(queue.volume ?? 100), 0, 200); + + // Helper to apply and confirm + const apply = (val) => { + const v = clamp(Math.round(val), 0, 200); + queue.setVolume(v); + const advisory = v > 100 ? " *(warning: may distort >100%)*" : ""; + return interaction.followUp(`๐Ÿ”Š Volume set to **${v}%**${advisory}`); + }; + + if (sub === "show") { + const advisory = current > 100 ? " *(>100% may distort)*" : ""; + return interaction.followUp( + `๐Ÿ”Š Current volume: **${current}%**${advisory}` + ); + } + + if (sub === "set") { + const level = interaction.options.getInteger("level", true); + return apply(level); + } + + if (sub === "up") { + const step = interaction.options.getInteger("by") ?? 10; + return apply(current + step); + } + + if (sub === "down") { + const step = interaction.options.getInteger("by") ?? 10; + return apply(current - step); + } + + if (sub === "mute") { + return apply(0); + } + + if (sub === "unmute") { + const level = interaction.options.getInteger("level") ?? 100; + return apply(level); + } + + return interaction.followUp({ + content: "โŒ Unknown subcommand.", + ephemeral: true, + }); + } catch (e) { + console.error("volume command failed:", e); + const msg = e?.message || "โŒ Failed to adjust volume."; + if (interaction.deferred || interaction.replied) { + return interaction.followUp({ content: msg, ephemeral: true }); + } + return interaction.reply({ content: msg, ephemeral: true }); + } + }, +}; diff --git a/commands/stats/cs2.js b/commands/stats/cs2.js index e69ec94..5796ff8 100644 --- a/commands/stats/cs2.js +++ b/commands/stats/cs2.js @@ -11,6 +11,7 @@ module.exports = { .setDescription("The Steam ID to fetch stats for.") .setRequired(true) ), + category: "Stats", async execute(interaction) { const steamId = interaction.options.getString("steam_id"); diff --git a/commands/stats/tft.js b/commands/stats/tft.js index 9d440f3..e8f41a2 100644 --- a/commands/stats/tft.js +++ b/commands/stats/tft.js @@ -13,6 +13,7 @@ module.exports = { ) .setRequired(true) ), + category: "Stats", async execute(interaction) { const username = interaction.options.getString("username"); diff --git a/commands/stats/valorant.js b/commands/stats/valorant.js index a4a4d31..264b045 100644 --- a/commands/stats/valorant.js +++ b/commands/stats/valorant.js @@ -41,6 +41,8 @@ module.exports = { .setDescription("Include roles stats?") .setRequired(false) ), + category: "Stats", + async execute(interaction) { // Immediately defer the reply await interaction.deferReply(); diff --git a/commands/utils/leaderboard.js b/commands/utils/leaderboard.js index 0932a15..ba1ea7c 100644 --- a/commands/utils/leaderboard.js +++ b/commands/utils/leaderboard.js @@ -5,6 +5,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("leaderboard") .setDescription("Displays the trivia leaderboard"), + category: "Utils", async execute(interaction, client) { const guild = interaction.guild; diff --git a/commands/utils/stats.js b/commands/utils/stats.js index 30c47f7..94b1e54 100644 --- a/commands/utils/stats.js +++ b/commands/utils/stats.js @@ -4,6 +4,7 @@ module.exports = { data: new SlashCommandBuilder() .setName("stats") .setDescription("Displays server statistics."), + category: "Utils", async execute(interaction) { try { diff --git a/commands/verification/code.js b/commands/verification/code.js index 339fe70..4afae7e 100644 --- a/commands/verification/code.js +++ b/commands/verification/code.js @@ -12,6 +12,7 @@ module.exports = { .setDescription("Your verification code") .setRequired(true) ), + category: "Verification", async execute(interaction, client) { // Fetch server settings from the database diff --git a/commands/verification/verify.js b/commands/verification/verify.js index 74947f4..299acc9 100644 --- a/commands/verification/verify.js +++ b/commands/verification/verify.js @@ -21,6 +21,7 @@ module.exports = { .setDescription("Your DCU email address") .setRequired(true) ), + category: "Verification", async execute(interaction, client) { // Fetch the server settings from the database using guild ID diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3036306 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +--- +services: + bot: + image: ghcr.io/aydenjahola/circuitrix:latest + container_name: discord-bot + restart: unless-stopped + env_file: .env diff --git a/events/distubeEvents.js b/events/distubeEvents.js new file mode 100644 index 0000000..85d522c --- /dev/null +++ b/events/distubeEvents.js @@ -0,0 +1,197 @@ +const { EmbedBuilder } = require("discord.js"); +const { ensure: ensureMusicSettings } = require("../utils/musicSettings"); +const live = require("../utils/liveLyricsManager"); // we still use this to sync/cleanup + +module.exports = (distube, botName) => { + const footerConfig = { + text: `Powered by ${botName}, developed with โค๏ธ by Ayden`, + iconURL: "https://github.com/aydenjahola.png", + }; + + const createEmbed = (color, title, description, thumbnail = null) => { + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(title) + .setDescription(description) + .setFooter(footerConfig); + + if (thumbnail) embed.setThumbnail(thumbnail); + return embed; + }; + + distube + .on("initQueue", async (queue) => { + try { + const settings = await ensureMusicSettings(queue.id); + queue.volume = Math.max( + 0, + Math.min(200, settings.defaultVolume ?? 100) + ); + queue.autoplay = !!settings.autoplay; + + const maxQ = settings.maxQueue ?? 1000; + if (Array.isArray(queue.songs) && queue.songs.length > maxQ) { + queue.songs.length = maxQ; + } + } catch (e) { + console.error("initQueue settings apply failed:", e); + queue.volume = 100; + queue.autoplay = false; + } + }) + + .on("playSong", async (queue, song) => { + // โ—๏ธ NO auto-start of live lyrics here anymore + const embed = createEmbed( + 0x0099ff, + "๐ŸŽถ Now Playing", + `**${song.name}** - \`${song.formattedDuration}\``, + song.thumbnail + ); + queue.textChannel?.send({ embeds: [embed] }); + // If /livelyrics was already started manually, keep it aligned after a track change: + live.seek(queue).catch(() => {}); + }) + + .on("addSong", (queue, song) => { + const embed = createEmbed( + 0x00ff00, + "โœ… Song Added", + `**${song.name}** - \`${song.formattedDuration}\``, + song.thumbnail + ); + queue.textChannel?.send({ embeds: [embed] }); + }) + + .on("addList", (queue, playlist) => { + const embed = createEmbed( + 0x00ccff, + "๐Ÿ“š Playlist Added", + `**${playlist.name}** with **${playlist.songs.length}** tracks has been queued.` + ); + queue.textChannel?.send({ embeds: [embed] }); + }) + + .on("pause", (queue) => { + const embed = createEmbed( + 0xffff00, + "โธ๏ธ Music Paused", + "Playback has been paused." + ); + queue.textChannel?.send({ embeds: [embed] }); + // If live lyrics are running, pause scheduling (no-op otherwise) + live.pause(queue.id).catch(() => {}); + }) + + .on("resume", (queue) => { + const embed = createEmbed( + 0x00ff00, + "โ–ถ๏ธ Music Resumed", + "Playback has been resumed." + ); + queue.textChannel?.send({ embeds: [embed] }); + // If live lyrics are running, resume scheduling (no-op otherwise) + live.resume(queue).catch(() => {}); + }) + + .on("seek", (queue, time) => { + // Keep live thread synced if itโ€™s running (no-op otherwise) + live.seek(queue, time).catch(() => {}); + }) + + .on("volumeChange", (queue, volume) => { + const embed = createEmbed( + 0x0099ff, + "๐Ÿ”Š Volume Changed", + `Volume set to ${volume}%` + ); + queue.textChannel?.send({ embeds: [embed] }); + }) + + .on("finishSong", (queue, song) => { + // If a manual /livelyrics is active, stop the current thread for this song. + // If the next song plays and the user wants lyrics again, they can run /livelyrics start. + live.stop(queue.id, { deleteThread: true }).catch(() => {}); + // If nothing left and autoplay is off, leave now. + setImmediate(() => { + try { + const remaining = Array.isArray(queue.songs) ? queue.songs.length : 0; + if (remaining === 0 && !queue.autoplay) { + queue.distube.voices.leave(queue.id); + queue.textChannel?.send({ + embeds: [ + createEmbed( + 0x0099ff, + "๐Ÿ No More Songs", + `Finished **${ + song?.name ?? "track" + }** โ€” nothing left, disconnecting.` + ), + ], + }); + } + } catch (e) { + console.error("finishSong immediate-leave failed:", e); + } + }); + }) + + .on("noRelated", (queue) => { + const embed = createEmbed( + 0xff0000, + "โŒ No Related Videos", + "Could not find related video for autoplay!" + ); + queue.textChannel?.send({ embeds: [embed] }); + }) + + .on("finish", (queue) => { + try { + queue.distube.voices.leave(queue.id); + const embed = createEmbed( + 0x0099ff, + "๐Ÿ Queue Finished", + "Queue ended โ€” disconnecting now." + ); + queue.textChannel?.send({ embeds: [embed] }); + } catch (e) { + console.error("Immediate leave on finish failed:", e); + } finally { + // Always cleanup any live thread if one was running + live.stop(queue.id, { deleteThread: true }).catch(() => {}); + } + }) + + .on("empty", (queue) => { + try { + queue.distube.voices.leave(queue.id); + queue.textChannel?.send({ + embeds: [ + createEmbed( + 0xff0000, + "๐Ÿ”‡ Left Voice Channel", + "Channel became empty โ€” disconnecting now." + ), + ], + }); + } catch (e) { + console.error("Immediate leave on empty failed:", e); + } finally { + live.stop(queue.id, { deleteThread: true }).catch(() => {}); + } + }) + + .on("disconnect", (queue) => { + // Always cleanup on manual disconnect too + live.stop(queue.id, { deleteThread: true }).catch(() => {}); + }) + + .on("error", (error, queue) => { + console.error("DisTube error:", error); + queue?.textChannel?.send( + "โŒ Playback error: " + (error?.message || String(error)).slice(0, 500) + ); + if (queue?.id) + live.stop(queue.id, { deleteThread: true }).catch(() => {}); + }); +}; diff --git a/index.js b/index.js index 60f3037..349de11 100644 --- a/index.js +++ b/index.js @@ -7,65 +7,170 @@ const { Routes, PresenceUpdateStatus, } = require("discord.js"); +const { DisTube, isVoiceChannelEmpty } = require("distube"); +const { SpotifyPlugin } = require("@distube/spotify"); +const { YouTubePlugin } = require("@distube/youtube"); +const { SoundCloudPlugin } = require("@distube/soundcloud"); const mongoose = require("mongoose"); const fs = require("fs"); const path = require("path"); const ServerSettings = require("./models/ServerSettings"); const seedShopItems = require("./utils/seedShopItems"); const seedSpyfallLocations = require("./utils/seedSpyfallLocations"); +const setupDisTubeEvents = require("./events/distubeEvents"); +const ffmpeg = require("ffmpeg-static"); + +// Console colors +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + underscore: "\x1b[4m", + blink: "\x1b[5m", + reverse: "\x1b[7m", + hidden: "\x1b[8m", + + black: "\x1b[30m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + + bgBlack: "\x1b[40m", + bgRed: "\x1b[41m", + bgGreen: "\x1b[42m", + bgYellow: "\x1b[43m", + bgBlue: "\x1b[44m", + bgMagenta: "\x1b[45m", + bgCyan: "\x1b[46m", + bgWhite: "\x1b[47m", +}; + +const printBanner = () => { + console.log(`${colors.magenta} + โ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–„ โ–ˆโ–ˆโ–“ โ–ˆโ–ˆโ–€โ–ˆโ–ˆโ–ˆ โ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–„ โ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–“โ–„โ–„โ–„โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“ โ–ˆโ–ˆโ–€โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–“โ–’โ–ˆโ–ˆ โ–ˆโ–ˆโ–’ +โ–’โ–ˆโ–ˆโ–€ โ–€โ–ˆ โ–“โ–ˆโ–ˆโ–’โ–“โ–ˆโ–ˆ โ–’ โ–ˆโ–ˆโ–’โ–’โ–ˆโ–ˆโ–€ โ–€โ–ˆ โ–ˆโ–ˆ โ–“โ–ˆโ–ˆโ–’โ–“โ–ˆโ–ˆโ–’โ–“ โ–ˆโ–ˆโ–’ โ–“โ–’โ–“โ–ˆโ–ˆ โ–’ โ–ˆโ–ˆโ–’โ–“โ–ˆโ–ˆโ–’โ–’โ–’ โ–ˆ โ–ˆ โ–’โ–‘ +โ–’โ–“โ–ˆ โ–„ โ–’โ–ˆโ–ˆโ–’โ–“โ–ˆโ–ˆ โ–‘โ–„โ–ˆ โ–’โ–’โ–“โ–ˆ โ–„ โ–“โ–ˆโ–ˆ โ–’โ–ˆโ–ˆโ–‘โ–’โ–ˆโ–ˆโ–’โ–’ โ–“โ–ˆโ–ˆโ–‘ โ–’โ–‘โ–“โ–ˆโ–ˆ โ–‘โ–„โ–ˆ โ–’โ–’โ–ˆโ–ˆโ–’โ–‘โ–‘ โ–ˆ โ–‘ +โ–’โ–“โ–“โ–„ โ–„โ–ˆโ–ˆโ–’โ–‘โ–ˆโ–ˆโ–‘โ–’โ–ˆโ–ˆโ–€โ–€โ–ˆโ–„ โ–’โ–“โ–“โ–„ โ–„โ–ˆโ–ˆโ–’โ–“โ–“โ–ˆ โ–‘โ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–‘โ–‘ โ–“โ–ˆโ–ˆโ–“ โ–‘ โ–’โ–ˆโ–ˆโ–€โ–€โ–ˆโ–„ โ–‘โ–ˆโ–ˆโ–‘ โ–‘ โ–ˆ โ–ˆ โ–’ +โ–’ โ–“โ–ˆโ–ˆโ–ˆโ–€ โ–‘โ–‘โ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–“ โ–’โ–ˆโ–ˆโ–’โ–’ โ–“โ–ˆโ–ˆโ–ˆโ–€ โ–‘โ–’โ–’โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–“ โ–‘โ–ˆโ–ˆโ–‘ โ–’โ–ˆโ–ˆโ–’ โ–‘ โ–‘โ–ˆโ–ˆโ–“ โ–’โ–ˆโ–ˆโ–’โ–‘โ–ˆโ–ˆโ–‘โ–’โ–ˆโ–ˆโ–’ โ–’โ–ˆโ–ˆโ–’ +โ–‘ โ–‘โ–’ โ–’ โ–‘โ–‘โ–“ โ–‘ โ–’โ–“ โ–‘โ–’โ–“โ–‘โ–‘ โ–‘โ–’ โ–’ โ–‘โ–‘โ–’โ–“โ–’ โ–’ โ–’ โ–‘โ–“ โ–’ โ–‘โ–‘ โ–‘ โ–’โ–“ โ–‘โ–’โ–“โ–‘โ–‘โ–“ โ–’โ–’ โ–‘ โ–‘โ–“ โ–‘ + โ–‘ โ–’ โ–’ โ–‘ โ–‘โ–’ โ–‘ โ–’โ–‘ โ–‘ โ–’ โ–‘โ–‘โ–’โ–‘ โ–‘ โ–‘ โ–’ โ–‘ โ–‘ โ–‘โ–’ โ–‘ โ–’โ–‘ โ–’ โ–‘โ–‘โ–‘ โ–‘โ–’ โ–‘ +โ–‘ โ–’ โ–‘ โ–‘โ–‘ โ–‘ โ–‘ โ–‘โ–‘โ–‘ โ–‘ โ–‘ โ–’ โ–‘ โ–‘ โ–‘โ–‘ โ–‘ โ–’ โ–‘ โ–‘ โ–‘ +โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ โ–‘ +โ–‘ โ–‘ + ${colors.reset}`); + console.log( + `${colors.cyan}${colors.bright}โšก Circuitrix Discord Bot ${colors.reset}` + ); + console.log( + `${colors.cyan}${colors.bright}โœจ Developed with โค๏ธ by Ayden ${colors.reset}` + ); + console.log( + `${colors.cyan}${colors.bright}๐ŸŒ https://github.com/aydenjahola ${colors.reset}\n` + ); +}; const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildVoiceStates, ], }); client.commands = new Collection(); +// Initialize DisTube +client.distube = new DisTube(client, { + plugins: [ + new YouTubePlugin(), // YouTube takes priority + new SpotifyPlugin(), // resolves Spotify โ†’ YouTube + new SoundCloudPlugin(), // resolves SoundCloud โ†’ YouTube + ], + emitNewSongOnly: true, + emitAddSongWhenCreatingQueue: false, // scale for big playlists + emitAddListWhenCreatingQueue: true, + savePreviousSongs: false, // lower memory over long sessions + joinNewVoiceChannel: true, // smoother UX if user moves VC +}); + // Function to recursively read commands from subdirectories function loadCommands(dir) { const files = fs.readdirSync(dir); - for (const file of files) { const filePath = path.join(dir, file); - if (fs.statSync(filePath).isDirectory()) { - // If it's a directory, recurse into it loadCommands(filePath); } else if (file.endsWith(".js")) { - // If it's a JavaScript file, load the command - const command = require(filePath); - client.commands.set(command.data.name, command); + try { + const command = require(filePath); + if (command.data && command.data.name) { + client.commands.set(command.data.name, command); + console.log( + `${colors.green}โœ… Loaded command: ${colors.reset}${colors.cyan}/${command.data.name}${colors.reset}` + ); + } + } catch (error) { + console.log( + `${colors.red}โŒ Failed to load command: ${filePath}${colors.reset}` + ); + console.error(error); + } } } } -// Load all commands from the commands directory and its subdirectories +// Load commands +console.log( + `${colors.yellow}${colors.bright}๐Ÿ“ฆ Loading commands...${colors.reset}` +); loadCommands(path.join(__dirname, "commands")); +console.log( + `${colors.green}โœ… Successfully loaded ${colors.bright}${client.commands.size}${colors.reset}${colors.green} commands!${colors.reset}\n` +); async function registerCommands(guildId) { const commands = client.commands.map((cmd) => cmd.data.toJSON()); const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN); - try { await rest.put(Routes.applicationGuildCommands(client.user.id, guildId), { body: commands, }); - console.log(`๐Ÿ”„ Successfully registered commands for guild: ${guildId}`); + console.log( + `${colors.green}๐Ÿ”„ Registered ${colors.bright}${commands.length}${colors.reset}${colors.green} commands for guild: ${colors.cyan}${guildId}${colors.reset}` + ); } catch (error) { - console.error("Error registering commands:", error); + console.log( + `${colors.red}โŒ Error registering commands for guild ${guildId}:${colors.reset}` + ); + console.error(error); } } client.once("ready", async () => { - console.log(`\n==============================`); - console.log(`๐Ÿค– Logged in as ${client.user.tag}`); - console.log(`==============================`); + printBanner(); + + console.log( + `${colors.green}${colors.bright}๐Ÿš€ Bot successfully logged in as ${colors.cyan}${client.user.tag}${colors.reset}` + ); + console.log( + `${colors.green}${colors.bright}๐Ÿ“Š Serving ${colors.cyan}${client.guilds.cache.size}${colors.reset}${colors.green} servers${colors.reset}` + ); + console.log( + `${colors.green}${colors.bright}๐Ÿ‘ฅ Watching ${colors.cyan}${client.users.cache.size}${colors.reset}${colors.green} users${colors.reset}\n` + ); + + // Set up DisTube events + setupDisTubeEvents(client.distube, client.user.username); - // Register commands for all existing guilds const guilds = client.guilds.cache.map((guild) => guild.id); + console.log( + `${colors.yellow}${colors.bright}โš™๏ธ Initializing server configurations...${colors.reset}` + ); await Promise.all( guilds.map(async (guildId) => { @@ -75,68 +180,139 @@ client.once("ready", async () => { }) ); - // Set bot status and activity client.user.setPresence({ - activities: [{ name: "Degenerate Gamers!", type: 3 }], + activities: [{ name: "Powering Servers! ๐Ÿš€", type: 3 }], status: PresenceUpdateStatus.Online, }); - console.log(`\n==============================\n`); + console.log( + `\n${colors.green}${colors.bright}๐ŸŽ‰ Bot is now fully operational and ready!${colors.reset}` + ); + console.log( + `${colors.cyan}${colors.bright}==============================================${colors.reset}\n` + ); }); -// Listen for new guild joins and register the guild ID in the database client.on("guildCreate", async (guild) => { try { - // Create a new entry in the ServerSettings collection with just the guildId await ServerSettings.create({ guildId: guild.id }); - console.log(`โœ… Registered new server: ${guild.name} (ID: ${guild.id})`); - - // seed items for new guild with guildId + console.log( + `${colors.green}โœ… Registered new server: ${colors.cyan}${guild.name}${colors.reset} ${colors.dim}(${guild.id})${colors.reset}` + ); await seedShopItems(guild.id); - - // Seed spyfall locations for the new guild await seedSpyfallLocations(guild.id); - - // Register slash commands for the new guild await registerCommands(guild.id); + + console.log( + `${colors.green}๐ŸŽ‰ Successfully initialized ${colors.cyan}${guild.name}${colors.reset}${colors.green} with all features!${colors.reset}` + ); } catch (error) { - console.error("Error registering new server or commands:", error); + console.log(`${colors.red}โŒ Error registering new server:${colors.reset}`); + console.error(error); + } +}); + +client.on("voiceStateUpdate", async (oldState, newState) => { + try { + // ignore bot state changes (including the music bot itself) + if (oldState.member?.user?.bot || newState.member?.user?.bot) return; + + const guildId = oldState.guild?.id || newState.guild?.id; + if (!guildId) return; + + const queue = client.distube.getQueue(guildId); + if (!queue) return; + + const vc = queue.voice?.channel ?? queue.voiceChannel; + if (!vc) return; + + // Only react to humans leaving/moving out of our VC + const userLeftOurVC = + oldState.channelId === vc.id && newState.channelId !== vc.id; + if (!userLeftOurVC) return; + + // Check emptiness based on the channel they just left + if (isVoiceChannelEmpty(oldState)) { + client.distube.voices.leave(guildId); + queue.textChannel?.send("๐Ÿ”‡ Channel is empty โ€” leaving."); + } + } catch (e) { + console.error("voiceStateUpdate immediate-leave error:", e); } }); // MongoDB connection +console.log( + `${colors.yellow}${colors.bright}๐Ÿ”— Connecting to MongoDB...${colors.reset}` +); mongoose .connect(process.env.MONGODB_URI) - .then(() => console.log("โœ… Connected to MongoDB")) - .catch((err) => console.error("โŒ Failed to connect to MongoDB", err)); + .then(() => { + console.log( + `${colors.green}โœ… Successfully connected to MongoDB!${colors.reset}\n` + ); + }) + .catch((err) => { + console.log(`${colors.red}โŒ Failed to connect to MongoDB:${colors.reset}`); + console.error(err); + process.exit(1); + }); client.on("interactionCreate", async (interaction) => { if (!interaction.isCommand()) return; - const command = client.commands.get(interaction.commandName); - if (!command) return; - try { await command.execute(interaction, client); } catch (err) { - console.error("Error executing command:", err); - if (interaction.deferred || interaction.ephemeral) { - await interaction.followUp({ - content: "There was an error while executing this command!", - ephemeral: true, - }); + console.log( + `${colors.red}โŒ Error executing command ${colors.cyan}/${interaction.commandName}${colors.reset}` + ); + console.error(err); + const replyOptions = { + content: "โŒ There was an error while executing this command!", + ephemeral: true, + }; + if (interaction.deferred || interaction.replied) { + await interaction.followUp(replyOptions); } else { - await interaction.reply({ - content: "There was an error while executing this command!", - ephemeral: true, - }); + await interaction.reply(replyOptions); } } }); client.on("error", (err) => { - console.error("Client error:", err); + console.log(`${colors.red}${colors.bright}โš ๏ธ Client error:${colors.reset}`); + console.error(err); }); -client.login(process.env.BOT_TOKEN); +process.on("unhandledRejection", (error) => { + console.log( + `${colors.red}${colors.bright}โš ๏ธ Unhandled promise rejection:${colors.reset}` + ); + console.error(error); +}); + +process.on("uncaughtException", (error) => { + console.log( + `${colors.red}${colors.bright}โš ๏ธ Uncaught exception:${colors.reset}` + ); + console.error(error); +}); + +// Login +console.log( + `${colors.yellow}${colors.bright}๐Ÿ” Logging in to Discord...${colors.reset}` +); +client + .login(process.env.BOT_TOKEN) + .then(() => { + console.log( + `${colors.green}โœ… Authentication successful!${colors.reset}\n` + ); + }) + .catch((error) => { + console.log(`${colors.red}โŒ Failed to login to Discord:${colors.reset}`); + console.error(error); + process.exit(1); + }); diff --git a/models/MusicSettings.js b/models/MusicSettings.js new file mode 100644 index 0000000..2777902 --- /dev/null +++ b/models/MusicSettings.js @@ -0,0 +1,16 @@ +const { Schema, model } = require("mongoose"); + +const MusicSettingsSchema = new Schema( + { + guildId: { type: String, unique: true, index: true, required: true }, + defaultVolume: { type: Number, default: 100, min: 0, max: 200 }, + autoplay: { type: Boolean, default: false }, + allowedTextChannelIds: { type: [String], default: [] }, // empty => all allowed + djRoleIds: { type: [String], default: [] }, + maxQueue: { type: Number, default: 1000, min: 1, max: 5000 }, + maxPlaylistImport: { type: Number, default: 500, min: 1, max: 2000 }, + }, + { timestamps: true } +); + +module.exports = model("MusicSettings", MusicSettingsSchema); diff --git a/package.json b/package.json index 7437df2..8cb808a 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,19 @@ "keywords": [], "license": "MIT", "dependencies": { + "@discordjs/opus": "^0.10.0", + "@discordjs/voice": "^0.19.0", + "@distube/soundcloud": "^2.0.4", + "@distube/spotify": "^2.0.2", + "@distube/youtube": "^1.0.4", + "@snazzah/davey": "^0.1.6", "axios": "^1.7.7", "discord.js": "^14.15.3", + "distube": "^5.0.7", "dotenv": "^16.4.5", "express": "^4.19.2", + "ffmpeg-static": "^5.2.0", + "genius-lyrics": "^4.4.7", "html-entities": "^2.5.2", "moment": "^2.30.1", "mongoose": "^8.6.0", diff --git a/utils/liveLyricsManager.js b/utils/liveLyricsManager.js new file mode 100644 index 0000000..4967c56 --- /dev/null +++ b/utils/liveLyricsManager.js @@ -0,0 +1,138 @@ +const { getSyncedLyrics } = require("./lyricsProvider"); + +const states = new Map(); // guildId -> { thread, parent, timers, startedAtMs, pausedAtMs, songId, lastSentAtMs, lyrics } + +function clearTimers(state) { + if (!state?.timers) return; + for (const t of state.timers) clearTimeout(t); + state.timers = new Set(); +} + +/** coerce number seconds */ +function sec(x) { + return Math.max(0, Math.floor(Number(x || 0))); +} + +/** schedule lyric lines starting from startTimeSec */ +function scheduleLines(state, queue, lyrics, startTimeSec) { + clearTimers(state); + state.timers = new Set(); + state.startedAtMs = Date.now() - startTimeSec * 1000; + + const parentToSend = state.thread || state.parent; + const MIN_GAP_MS = 400; // rate-limit safety + + for (const line of lyrics) { + if (!line || typeof line.t !== "number") continue; + const delayMs = Math.max(0, Math.round((line.t - startTimeSec) * 1000)); + const timer = setTimeout(async () => { + try { + const now = Date.now(); + if (state.lastSentAtMs && now - state.lastSentAtMs < MIN_GAP_MS) { + await new Promise((r) => + setTimeout(r, MIN_GAP_MS - (now - state.lastSentAtMs)) + ); + } + state.lastSentAtMs = Date.now(); + await parentToSend.send(line.text || ""); + } catch (e) { + console.error("live lyrics send failed:", e?.message || e); + } + }, delayMs); + state.timers.add(timer); + } +} + +async function createThreadOrFallback(queue, song) { + const parent = queue.textChannel; + let thread = null; + + if (parent?.threads?.create) { + try { + thread = await parent.threads.create({ + name: `${(song?.name || "Now Playing").slice(0, 80)} โ€ข Live`, + autoArchiveDuration: 60, + reason: "Live lyrics", + }); + } catch (e) { + console.warn("Thread create failed, falling back to parent:", e?.message); + } + } + + return { thread, parent }; +} + +async function start(queue, song) { + try { + const guildId = queue.id; + await stop(guildId, { deleteThread: true }); + + const lyrics = await getSyncedLyrics(song); + if (!lyrics || lyrics.length === 0) { + queue.textChannel?.send("๐ŸŽค No synced lyrics available for this track."); + return; + } + + const { thread, parent } = await createThreadOrFallback(queue, song); + const state = { + thread, + parent, + timers: new Set(), + startedAtMs: Date.now(), + pausedAtMs: null, + songId: song?.id || song?.url || song?.name, + lastSentAtMs: 0, + lyrics, + }; + states.set(guildId, state); + + const header = `**Live lyrics for:** ${song?.name || "Unknown title"}`; + if (thread) await thread.send(header); + else await parent.send(`${header} *(thread unavailable, posting here)*`); + + const current = sec(queue.currentTime); + scheduleLines(state, queue, lyrics, current); + } catch (e) { + console.error("liveLyrics.start failed:", e?.message || e); + } +} + +async function pause(guildId) { + const state = states.get(guildId); + if (!state || state.pausedAtMs) return; + state.pausedAtMs = Date.now(); + clearTimers(state); +} + +async function resume(queue) { + const state = states.get(queue.id); + if (!state || !state.pausedAtMs) return; + state.pausedAtMs = null; + const current = sec(queue.currentTime); + scheduleLines(state, queue, state.lyrics, current); +} + +async function seek(queue, timeSecOptional) { + const state = states.get(queue.id); + if (!state) return; + const current = sec(timeSecOptional ?? queue.currentTime ?? 0); + scheduleLines(state, queue, state.lyrics, current); +} + +async function stop(guildId, { deleteThread = false } = {}) { + const state = states.get(guildId); + if (!state) return; + clearTimers(state); + + try { + if (deleteThread && state.thread?.delete) { + await state.thread.delete("Song ended โ€” removing live lyrics thread."); + } + } catch (e) { + console.warn("liveLyrics thread delete failed:", e?.message || e); + } + + states.delete(guildId); +} + +module.exports = { start, pause, resume, seek, stop }; diff --git a/utils/lyricsProvider.js b/utils/lyricsProvider.js new file mode 100644 index 0000000..b35d0d8 --- /dev/null +++ b/utils/lyricsProvider.js @@ -0,0 +1,140 @@ +// Tiny provider that tries LRCLIB first (synced LRC), then falls back to unsynced lines. +// Docs: https://lrclib.net (no API key required) +// +// Returned format: [{ t: Number(seconds), text: String }, ...] sorted by t + +function parseLRC(lrcText) { + // Supports tags like [ti:], [ar:], [length:], and timestamp lines [mm:ss.xx] + const lines = lrcText.split(/\r?\n/); + const out = []; + const timeRe = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?]/g; + + for (const line of lines) { + let m; + let lastIndex = 0; + // extract all timestamps from this line + const stamps = []; + while ((m = timeRe.exec(line)) !== null) { + const mm = Number(m[1]); + const ss = Number(m[2]); + const ms = Number(m[3] || 0); + const t = mm * 60 + ss + ms / 1000; + stamps.push({ t, idx: m.index }); + lastIndex = timeRe.lastIndex; + } + if (!stamps.length) continue; + // text is after last timestamp tag + const text = line.slice(lastIndex).trim(); + if (!text) continue; + for (const s of stamps) out.push({ t: s.t, text }); + } + + // remove duplicates, sort + out.sort((a, b) => a.t - b.t); + const dedup = []; + let prev = ""; + for (const l of out) { + const key = `${l.t.toFixed(2)}|${l.text}`; + if (key !== prev) dedup.push(l); + prev = key; + } + return dedup; +} + +function splitUnsyncedLyrics(text) { + // Fallback for plain lyrics (no timestamps): just emit a line every ~2s + const lines = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 500); // keep it sane + const out = []; + let t = 0; + for (const l of lines) { + out.push({ t, text: l }); + t += 2; + } + return out; +} + +function pickArtistAndTitle(song) { + // Try to infer artist/title for better matches + const name = song?.name || ""; + const byUploader = song?.uploader?.name || ""; + let title = name; + let artist = ""; + + // If the title looks like "Artist - Title" + if (name.includes(" - ")) { + const [a, b] = name.split(" - "); + if (a && b) { + artist = a.trim(); + title = b.trim(); + } + } + if (!artist) { + artist = byUploader || song?.author || ""; + } + return { artist, title }; +} + +async function fetchLRCLIBLyrics(song) { + const { artist, title } = pickArtistAndTitle(song); + // Build a simple query, we also try with raw name as a fallback + const candidates = []; + + if (title) { + candidates.push({ track_name: title, artist_name: artist || "" }); + } + if (song?.name) { + candidates.push({ track_name: song.name, artist_name: artist || "" }); + } + + for (const c of candidates) { + try { + const url = new URL("https://lrclib.net/api/get"); + if (c.track_name) url.searchParams.set("track_name", c.track_name); + if (c.artist_name) url.searchParams.set("artist_name", c.artist_name); + // lrclib also accepts album_name + duration if you have them + + const res = await fetch(url.toString(), { + headers: { + "user-agent": "CircuitrixBot/1.0 (+https://github.com/aydenjahola)", + }, + }); + + if (!res.ok) continue; + const data = await res.json(); + + // Prefer synced lyrics + if (data?.syncedLyrics) { + const parsed = parseLRC(data.syncedLyrics); + if (parsed.length) return parsed; + } + + // Fallback to unsynced lyrics + if (data?.plainLyrics) { + const parsed = splitUnsyncedLyrics(data.plainLyrics); + if (parsed.length) return parsed; + } + } catch (e) { + // Keep trying next candidate + // console.warn("LRCLIB fetch failed:", e?.message); + } + } + return null; +} + +/** + * Return an array of { t, text } (seconds) for synced display, or null if none. + * This is the only function the rest of the bot uses. + */ +async function getSyncedLyrics(song) { + // LRCLIB (synced LRC, free) + const lrclib = await fetchLRCLIBLyrics(song); + if (lrclib && lrclib.length) return lrclib; + + return null; +} + +module.exports = { getSyncedLyrics, parseLRC, splitUnsyncedLyrics }; diff --git a/utils/musicGuards.js b/utils/musicGuards.js new file mode 100644 index 0000000..37bb587 --- /dev/null +++ b/utils/musicGuards.js @@ -0,0 +1,16 @@ +exports.requireVC = (interaction) => { + const userVC = interaction.member?.voice?.channel; + if (!userVC) throw new Error("โŒ You need to be in a voice channel!"); + + const meVC = interaction.guild?.members?.me?.voice?.channel; + if (meVC && meVC.id !== userVC.id) { + throw new Error("โŒ You must be in the same voice channel as me."); + } + return userVC; +}; + +exports.requireQueue = (client, interaction) => { + const q = client.distube.getQueue(interaction.guildId); + if (!q || !q.songs?.length) throw new Error("โŒ Nothing is playing."); + return q; +}; diff --git a/utils/musicSettings.js b/utils/musicSettings.js new file mode 100644 index 0000000..f1fc2e6 --- /dev/null +++ b/utils/musicSettings.js @@ -0,0 +1,34 @@ +const MusicSettings = require("../models/MusicSettings"); + +/** in-memory cache to cut Mongo roundtrips */ +const cache = new Map(); // guildId -> settings doc (lean POJO) + +async function ensure(guildId) { + if (!guildId) throw new Error("Missing guildId"); + if (cache.has(guildId)) return cache.get(guildId); + + let doc = await MusicSettings.findOne({ guildId }).lean(); + if (!doc) { + doc = await MusicSettings.create({ guildId }); + doc = doc.toObject(); + } + cache.set(guildId, doc); + return doc; +} + +async function set(guildId, patch) { + const updated = await MusicSettings.findOneAndUpdate( + { guildId }, + { $set: patch }, + { upsert: true, new: true } + ).lean(); + cache.set(guildId, updated); + return updated; +} + +function clear(guildId) { + if (guildId) cache.delete(guildId); + else cache.clear(); +} + +module.exports = { ensure, set, clear };