Feat/Add Music Commands (#1)
Some checks are pending
Docker / build (push) Waiting to run

* add simple music functionality

* update workflow

* update Dockerfile

* update Dockerfile

* update Dockerfile

* update Dockerfile

* add few more music commands

* add lyrics command

* update lyrics command

* add loop, and add categories to all commands

* change discord status

* seperate distube and change startup console theme

* Update README

* UPDATE LICENSE file

* fix docker compose image, add better error handling for distube and update tagging workflow

* switch to node-alpine image for docker

* switch to node-alpine image for docker

* update ascii

* music commands imporvements, implement live lyrics, some guards and bot leaving on empty

* use ffmpeg package rather than ffmpeg-static
This commit is contained in:
Ayden 2025-09-21 01:26:18 +01:00 committed by GitHub
parent 51dcdc7406
commit cb5a906850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 2473 additions and 202 deletions

View file

@ -1,20 +1,13 @@
name: Docker 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: on:
push: push:
branches: ["main"] branches: ["*"]
# Publish semver tags as releases. # Publish semver tags as releases.
# tags: [ 'v*.*.*' ] # tags: [ 'v*.*.*' ]
env: env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
@ -23,30 +16,21 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with: with:
cosign-release: "v2.1.1" 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 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 }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
@ -55,16 +39,19 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 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 - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0

View file

@ -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 ./ COPY package*.json ./
@ -8,4 +16,6 @@ RUN npm install
COPY . . COPY . .
CMD ["node", "."] ENV NODE_ENV=production
CMD ["node", "index.js"]

143
LICENSE
View file

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble Preamble
The GNU General Public License is a free, copyleft license for The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works. 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 The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, 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 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 software for all its users.
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.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you 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 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. free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you Developers that use our General Public Licenses protect your rights
these rights or asking you to surrender the rights. Therefore, you have with two steps: (1) assert copyright on the software, and (2) offer
certain responsibilities if you distribute copies of the software, or if you this License which gives you legal permission to copy, distribute
you modify it: responsibilities to respect the freedom of others. and/or modify the software.
For example, if you distribute copies of such a program, whether A secondary benefit of defending all users' freedom is that
gratis or for a fee, you must pass on to the recipients the same improvements made in alternate versions of the program, if they
freedoms that you received. You must make sure that they, too, receive receive widespread use, become available for other developers to
or can get the source code. And you must show them these terms so they incorporate. Many developers of free software are heartened and
know their rights. 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: The GNU Affero General Public License is designed specifically to
(1) assert copyright on the software, and (2) offer you this License ensure that, in such cases, the modified source code becomes available
giving you legal permission to copy, distribute and/or modify it. 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 An older license, called the Affero General Public License and
that there is no warranty for this free software. For both users' and published by Affero, was designed to accomplish similar goals. This is
authors' sake, the GPL requires that modified versions be marked as a different license, not a version of the Affero GPL, but Affero has
changed, so that their problems will not be attributed erroneously to released a new version of the Affero GPL which permits relicensing under
authors of previous versions. this license.
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.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions. 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 "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. 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 the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. 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 Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed 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 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, License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License, but the work with which it is combined will remain governed by version
section 13, concerning interaction through a network will apply to the 3 of the GNU General Public License.
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of 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 the GNU Affero General Public License from time to time. Such new versions
be similar in spirit to the present version, but may differ in detail to will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the 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 Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the 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. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future 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 public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. 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) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published
the Free Software Foundation, either version 3 of the License, or by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 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 <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If your software can interact with users remotely through a computer
notice like this when it starts in an interactive mode: 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
<program> Copyright (C) <year> <name of author> interface could display a "Source" link that leads users to an archive
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. of the code. There are many ways you could offer source, and different
This is free software, and you are welcome to redistribute it solutions will be better for different programs; see section 13 for the
under certain conditions; type `show c' for details. specific requirements.
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".
You should also get your employer (if you work as a programmer) or school, 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. 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
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.

151
README.md
View file

@ -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. ### 🔐 Verification System
- **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.
### 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** 1. **Clone the Repository**
```sh ```sh
git clone git@github.com:aydenjahola/discord-multipurpose-bot.git git clone git@github.com:aydenjahola/circuitrix.git
``` cd circuitrix
```sh
cd discord-multipurpose-bot
``` ```
2. **Install Dependencies** 2. **Install Dependencies**
@ -42,24 +82,75 @@ cd discord-multipurpose-bot
npm install 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 ```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)

178
commands/admin/music.js Normal file
View file

@ -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 (0200)")
)
.addBooleanOption((o) =>
o
.setName("autoplay")
.setDescription("Autoplay when queue ends (true/false)")
)
.addIntegerOption((o) =>
o.setName("maxqueue").setDescription("Max queue size (15000)")
)
.addIntegerOption((o) =>
o
.setName("maxplaylist")
.setDescription("Max playlist import size (12000)")
)
)
.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 0200."));
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 15000."));
update.maxQueue = maxQ;
}
if (maxP !== null) {
if (maxP < 1 || maxP > 2000)
return interaction.followUp(err("Max playlist must be 12000."));
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."));
},
};

View file

@ -12,6 +12,8 @@ module.exports = {
.setDescription("Your message to the AI") .setDescription("Your message to the AI")
.setRequired(true) .setRequired(true)
), ),
category: "AI",
async execute(interaction) { async execute(interaction) {
await interaction.deferReply(); // Defer initial response await interaction.deferReply(); // Defer initial response

View file

@ -8,6 +8,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("help") .setName("help")
.setDescription("Lists all available commands"), .setDescription("Lists all available commands"),
category: "Core",
async execute(interaction, client) { async execute(interaction, client) {
try { try {
@ -16,16 +17,19 @@ module.exports = {
); );
const serverName = interaction.guild.name; const serverName = interaction.guild.name;
const generalCommands = [];
const modCommands = [];
// Categorize commands // Group commands by category
const categories = {};
client.commands.forEach((command) => { 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}`; const commandLine = `/${command.data.name} - ${command.data.description}`;
if (!command.isModOnly) { // Check if command is mod-only and user has permissions
generalCommands.push(commandLine); if (!command.isModOnly || (command.isModOnly && isMod)) {
} else if (isMod) { categories[category].push(commandLine);
modCommands.push(`${commandLine} (Mods only)`);
} }
}); });
@ -43,47 +47,37 @@ module.exports = {
// Function to split commands into fields under 1024 characters // Function to split commands into fields under 1024 characters
const addCommandFields = (embed, commands, title) => { const addCommandFields = (embed, commands, title) => {
if (commands.length === 0) return;
let commandChunk = ""; let commandChunk = "";
let chunkCount = 1; let chunkCount = 1;
commands.forEach((command) => { commands.forEach((command) => {
// Check if adding this command will exceed the 1024 character limit if ((commandChunk + command + "\n").length > 1024) {
if ((commandChunk + command).length > 1024) {
// Add current chunk as a new field
embed.addFields({ embed.addFields({
name: `${title} (Part ${chunkCount})`, name: `${title} (Part ${chunkCount})`,
value: commandChunk, value: commandChunk,
}); });
commandChunk = ""; // Reset chunk for new field commandChunk = "";
chunkCount += 1; chunkCount += 1;
} }
// Append command to the current chunk
commandChunk += command + "\n"; commandChunk += command + "\n";
}); });
// Add any remaining commands in the last chunk
if (commandChunk) { if (commandChunk) {
embed.addFields({ embed.addFields({
name: `${title} (Part ${chunkCount})`, name: chunkCount > 1 ? `${title} (Part ${chunkCount})` : title,
value: commandChunk, value: commandChunk,
}); });
} }
}; };
// Add general commands in fields // Add commands for each category
if (generalCommands.length > 0) { for (const [categoryName, commands] of Object.entries(categories)) {
addCommandFields(helpEmbed, generalCommands, "General Commands"); addCommandFields(helpEmbed, commands, `${categoryName} Commands`);
} }
// Add mod-only commands in fields, if user is a mod await interaction.reply({ embeds: [helpEmbed] });
if (isMod && modCommands.length > 0) {
addCommandFields(helpEmbed, modCommands, "Mod-Only Commands");
}
// Send the single embed
await interaction.reply({
embeds: [helpEmbed],
});
} catch (error) { } catch (error) {
console.error("Error executing the help command:", error); console.error("Error executing the help command:", error);
await interaction.reply({ await interaction.reply({

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("invite") .setName("invite")
.setDescription("Provides an invite link to add the bot to your server."), .setDescription("Provides an invite link to add the bot to your server."),
category: "Core",
async execute(interaction, client) { async execute(interaction, client) {
try { try {

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("ping") .setName("ping")
.setDescription("Replies with Pong! and bot latency"), .setDescription("Replies with Pong! and bot latency"),
category: "Core",
async execute(interaction, client) { async execute(interaction, client) {
try { try {

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("uptime") .setName("uptime")
.setDescription("Shows how long the bot has been running"), .setDescription("Shows how long the bot has been running"),
category: "Core",
async execute(interaction, client) { async execute(interaction, client) {
try { try {

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("balance") .setName("balance")
.setDescription("Check your balance."), .setDescription("Check your balance."),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("daily") .setName("daily")
.setDescription("Claim your daily reward and start a streak!"), .setDescription("Claim your daily reward and start a streak!"),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -6,6 +6,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("inventory") .setName("inventory")
.setDescription("View your inventory with item rarity"), .setDescription("View your inventory with item rarity"),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -12,6 +12,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("sell") .setName("sell")
.setDescription("Sell an item from your inventory."), .setDescription("Sell an item from your inventory."),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -13,6 +13,7 @@ module.exports = {
.setDescription("The item you want to buy (use item name)") .setDescription("The item you want to buy (use item name)")
.setRequired(false) .setRequired(false)
), ),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -31,6 +31,7 @@ module.exports = {
.setDescription("Amount of coins to trade") .setDescription("Amount of coins to trade")
.setRequired(false) .setRequired(false)
), ),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("work") .setName("work")
.setDescription("Work to earn coins and experience random events!"), .setDescription("Work to earn coins and experience random events!"),
category: "Economy",
async execute(interaction) { async execute(interaction) {
const { user, guild } = interaction; const { user, guild } = interaction;

View file

@ -60,6 +60,8 @@ module.exports = {
{ name: "Monthly", value: "monthly" } { name: "Monthly", value: "monthly" }
) )
), ),
category: "Events",
async execute(interaction) { async execute(interaction) {
const name = interaction.options.getString("name"); const name = interaction.options.getString("name");
const description = interaction.options.getString("description"); const description = interaction.options.getString("description");

View file

@ -55,6 +55,7 @@ module.exports = {
{ name: "Monthly", value: "monthly" } { name: "Monthly", value: "monthly" }
) )
), ),
category: "Events",
async execute(interaction) { async execute(interaction) {
const name = interaction.options.getString("name"); const name = interaction.options.getString("name");

View file

@ -13,6 +13,7 @@ module.exports = {
.setDescription("Name of the event to join") .setDescription("Name of the event to join")
.setRequired(true) .setRequired(true)
), ),
category: "Events",
async execute(interaction) { async execute(interaction) {
const eventName = interaction.options.getString("event_name"); const eventName = interaction.options.getString("event_name");

View file

@ -12,6 +12,7 @@ module.exports = {
.setDescription("Name of the event to leave") .setDescription("Name of the event to leave")
.setRequired(true) .setRequired(true)
), ),
category: "Events",
async execute(interaction) { async execute(interaction) {
const eventName = interaction.options.getString("event_name"); const eventName = interaction.options.getString("event_name");

View file

@ -6,6 +6,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("listevents") .setName("listevents")
.setDescription("List all upcoming events."), .setDescription("List all upcoming events."),
category: "Events",
async execute(interaction) { async execute(interaction) {
const { user } = interaction; const { user } = interaction;

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("bored") .setName("bored")
.setDescription("Get a random activity to do."), .setDescription("Get a random activity to do."),
category: "Fun",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -49,6 +49,7 @@ module.exports = {
) )
.setRequired(false) .setRequired(false)
), ),
category: "Fun",
async execute(interaction) { async execute(interaction) {
const category = interaction.options.getString("category"); const category = interaction.options.getString("category");

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("randomfact") .setName("randomfact")
.setDescription("Get a random fun fact"), .setDescription("Get a random fun fact"),
category: "Fun",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -11,6 +11,8 @@ module.exports = {
.setDescription("The text to uwufy") .setDescription("The text to uwufy")
.setRequired(true) .setRequired(true)
), ),
category: "Fun",
async execute(interaction) { async execute(interaction) {
const inputText = interaction.options.getString("text"); const inputText = interaction.options.getString("text");

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("coinflip") .setName("coinflip")
.setDescription("Flip a coin!"), .setDescription("Flip a coin!"),
category: "Games",
async execute(interaction) { async execute(interaction) {
const result = Math.random() < 0.5 ? "Heads" : "Tails"; const result = Math.random() < 0.5 ? "Heads" : "Tails";

View file

@ -12,6 +12,7 @@ module.exports = {
.setMinValue(2) .setMinValue(2)
.setMaxValue(100) .setMaxValue(100)
), ),
category: "Games",
async execute(interaction) { async execute(interaction) {
const sides = interaction.options.getInteger("sides") || 6; const sides = interaction.options.getInteger("sides") || 6;

View file

@ -15,6 +15,7 @@ module.exports = {
{ name: "Scissors", value: "scissors" } { name: "Scissors", value: "scissors" }
) )
), ),
category: "Games",
async execute(interaction) { async execute(interaction) {
const userChoice = interaction.options.getString("choice"); const userChoice = interaction.options.getString("choice");

View file

@ -102,6 +102,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("scramble") .setName("scramble")
.setDescription("Play a word scramble game"), .setDescription("Play a word scramble game"),
category: "Games",
async execute(interaction) { async execute(interaction) {
const userId = interaction.user.id; const userId = interaction.user.id;

View file

@ -13,6 +13,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("spyfall") .setName("spyfall")
.setDescription("Start a game of Spyfall."), .setDescription("Start a game of Spyfall."),
category: "Games",
async execute(interaction) { async execute(interaction) {
const guildId = interaction.guild.id; const guildId = interaction.guild.id;

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("stopspyfall") .setName("stopspyfall")
.setDescription("Stop the current Spyfall game in this server."), .setDescription("Stop the current Spyfall game in this server."),
category: "Games",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -293,6 +293,7 @@ module.exports = {
})) }))
) )
), ),
category: "Games",
async execute(interaction, client) { async execute(interaction, client) {
const userId = interaction.user.id; const userId = interaction.user.id;

View file

@ -26,6 +26,8 @@ module.exports = {
.setDescription("Whether the response should be ephemeral") .setDescription("Whether the response should be ephemeral")
.setRequired(false) .setRequired(false)
), ),
category: "General",
async execute(interaction) { async execute(interaction) {
const word = interaction.options.getString("word").toLowerCase(); const word = interaction.options.getString("word").toLowerCase();
const isEphemeral = interaction.options.getBoolean("ephemeral") || false; const isEphemeral = interaction.options.getBoolean("ephemeral") || false;

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("thisdayinhistory") .setName("thisdayinhistory")
.setDescription("Shows historical events that happened on this day."), .setDescription("Shows historical events that happened on this day."),
category: "General",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -12,6 +12,8 @@ module.exports = {
.setDescription("The term to look up") .setDescription("The term to look up")
.setRequired(true) .setRequired(true)
), ),
category: "General",
async execute(interaction, client) { async execute(interaction, client) {
const term = interaction.options.getString("term").toLowerCase(); const term = interaction.options.getString("term").toLowerCase();
const guild = interaction.guild; const guild = interaction.guild;

View file

@ -83,6 +83,7 @@ module.exports = {
.setDescription("The word to find associations for") .setDescription("The word to find associations for")
.setRequired(true) .setRequired(true)
), ),
category: "General",
async execute(interaction) { async execute(interaction) {
const word = interaction.options.getString("word"); const word = interaction.options.getString("word");

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("botinfo") .setName("botinfo")
.setDescription("Displays information about the bot"), .setDescription("Displays information about the bot"),
category: "Information",
async execute(interaction, client) { async execute(interaction, client) {
try { try {

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("serverinfo") .setName("serverinfo")
.setDescription("Displays information about the server"), .setDescription("Displays information about the server"),
category: "Information",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -12,6 +12,7 @@ module.exports = {
.setRequired(true) .setRequired(true)
), ),
isModOnly: true, isModOnly: true,
category: "Minecraft",
async execute(interaction) { async execute(interaction) {
const username = interaction.options.getString("username"); const username = interaction.options.getString("username");

View file

@ -9,6 +9,7 @@ module.exports = {
.setName("servers") .setName("servers")
.setDescription("Displays a list of servers the bot is currently in"), .setDescription("Displays a list of servers the bot is currently in"),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -15,6 +15,7 @@ module.exports = {
) )
.setRequired(true) .setRequired(true)
), ),
category: "Moderation",
async execute(interaction) { async execute(interaction) {
const serverSettings = await ServerSettings.findOne({ const serverSettings = await ServerSettings.findOne({

View file

@ -19,6 +19,7 @@ module.exports = {
.setRequired(true) .setRequired(true)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
let replySent = false; let replySent = false;

View file

@ -6,6 +6,7 @@ module.exports = {
.setName("clearleaderboard") .setName("clearleaderboard")
.setDescription("Clears all entries in the trivia leaderboard"), .setDescription("Clears all entries in the trivia leaderboard"),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -22,6 +22,7 @@ module.exports = {
.setRequired(true) .setRequired(true)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -27,6 +27,7 @@ module.exports = {
.setMaxValue(100) .setMaxValue(100)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -11,6 +11,7 @@ module.exports = {
.setDescription("Displays the current server settings"), .setDescription("Displays the current server settings"),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
let replySent = false; let replySent = false;

View file

@ -51,6 +51,7 @@ module.exports = {
) )
.setRequired(false) .setRequired(false)
), ),
category: "Moderation",
async execute(interaction) { async execute(interaction) {
// Check if the user has admin permissions // Check if the user has admin permissions

View file

@ -28,6 +28,7 @@ module.exports = {
.setRequired(true) .setRequired(true)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
let replySent = false; let replySent = false;

View file

@ -15,6 +15,7 @@ module.exports = {
.setRequired(false) .setRequired(false)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -22,6 +22,7 @@ module.exports = {
.setRequired(true) .setRequired(true)
), ),
isModOnly: true, isModOnly: true,
category: "Moderation",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -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. Ill post lines in a thread (or here if I cant 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 });
}
}
},
};

71
commands/music/loop.js Normal file
View file

@ -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."
);
}
},
};

91
commands/music/lyrics.js Normal file
View file

@ -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."
);
}
},
};

View file

@ -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 });
}
},
};

36
commands/music/pause.js Normal file
View file

@ -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 });
}
},
};

58
commands/music/play.js Normal file
View file

@ -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 });
}
}
},
};

269
commands/music/queue.js Normal file
View file

@ -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 });
}
}
},
};

42
commands/music/resume.js Normal file
View file

@ -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 });
}
},
};

98
commands/music/seek.js Normal file
View file

@ -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 doesnt 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 });
}
}
},
};

79
commands/music/shuffle.js Normal file
View file

@ -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 });
}
},
};

140
commands/music/skip.js Normal file
View file

@ -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, well 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 theres 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 });
}
},
};

51
commands/music/stop.js Normal file
View file

@ -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 });
}
},
};

131
commands/music/volume.js Normal file
View file

@ -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 (0200).")
.addSubcommand((sc) =>
sc.setName("show").setDescription("Show the current volume.")
)
.addSubcommand((sc) =>
sc
.setName("set")
.setDescription("Set the volume to a specific level (0200).")
.addIntegerOption((o) =>
o
.setName("level")
.setDescription("Volume percent (0200)")
.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 (1100)")
.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 (1100)")
.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 (1200)")
.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 });
}
},
};

View file

@ -11,6 +11,7 @@ module.exports = {
.setDescription("The Steam ID to fetch stats for.") .setDescription("The Steam ID to fetch stats for.")
.setRequired(true) .setRequired(true)
), ),
category: "Stats",
async execute(interaction) { async execute(interaction) {
const steamId = interaction.options.getString("steam_id"); const steamId = interaction.options.getString("steam_id");

View file

@ -13,6 +13,7 @@ module.exports = {
) )
.setRequired(true) .setRequired(true)
), ),
category: "Stats",
async execute(interaction) { async execute(interaction) {
const username = interaction.options.getString("username"); const username = interaction.options.getString("username");

View file

@ -41,6 +41,8 @@ module.exports = {
.setDescription("Include roles stats?") .setDescription("Include roles stats?")
.setRequired(false) .setRequired(false)
), ),
category: "Stats",
async execute(interaction) { async execute(interaction) {
// Immediately defer the reply // Immediately defer the reply
await interaction.deferReply(); await interaction.deferReply();

View file

@ -5,6 +5,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("leaderboard") .setName("leaderboard")
.setDescription("Displays the trivia leaderboard"), .setDescription("Displays the trivia leaderboard"),
category: "Utils",
async execute(interaction, client) { async execute(interaction, client) {
const guild = interaction.guild; const guild = interaction.guild;

View file

@ -4,6 +4,7 @@ module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("stats") .setName("stats")
.setDescription("Displays server statistics."), .setDescription("Displays server statistics."),
category: "Utils",
async execute(interaction) { async execute(interaction) {
try { try {

View file

@ -12,6 +12,7 @@ module.exports = {
.setDescription("Your verification code") .setDescription("Your verification code")
.setRequired(true) .setRequired(true)
), ),
category: "Verification",
async execute(interaction, client) { async execute(interaction, client) {
// Fetch server settings from the database // Fetch server settings from the database

View file

@ -21,6 +21,7 @@ module.exports = {
.setDescription("Your DCU email address") .setDescription("Your DCU email address")
.setRequired(true) .setRequired(true)
), ),
category: "Verification",
async execute(interaction, client) { async execute(interaction, client) {
// Fetch the server settings from the database using guild ID // Fetch the server settings from the database using guild ID

7
docker-compose.yml Normal file
View file

@ -0,0 +1,7 @@
---
services:
bot:
image: ghcr.io/aydenjahola/circuitrix:latest
container_name: discord-bot
restart: unless-stopped
env_file: .env

197
events/distubeEvents.js Normal file
View file

@ -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 its 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(() => {});
});
};

264
index.js
View file

@ -7,65 +7,170 @@ const {
Routes, Routes,
PresenceUpdateStatus, PresenceUpdateStatus,
} = require("discord.js"); } = 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 mongoose = require("mongoose");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const ServerSettings = require("./models/ServerSettings"); const ServerSettings = require("./models/ServerSettings");
const seedShopItems = require("./utils/seedShopItems"); const seedShopItems = require("./utils/seedShopItems");
const seedSpyfallLocations = require("./utils/seedSpyfallLocations"); 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({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
], ],
}); });
client.commands = new Collection(); 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 to recursively read commands from subdirectories
function loadCommands(dir) { function loadCommands(dir) {
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir);
for (const file of files) { for (const file of files) {
const filePath = path.join(dir, file); const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) { if (fs.statSync(filePath).isDirectory()) {
// If it's a directory, recurse into it
loadCommands(filePath); loadCommands(filePath);
} else if (file.endsWith(".js")) { } else if (file.endsWith(".js")) {
// If it's a JavaScript file, load the command try {
const command = require(filePath); const command = require(filePath);
client.commands.set(command.data.name, command); 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")); 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) { async function registerCommands(guildId) {
const commands = client.commands.map((cmd) => cmd.data.toJSON()); const commands = client.commands.map((cmd) => cmd.data.toJSON());
const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN); const rest = new REST({ version: "10" }).setToken(process.env.BOT_TOKEN);
try { try {
await rest.put(Routes.applicationGuildCommands(client.user.id, guildId), { await rest.put(Routes.applicationGuildCommands(client.user.id, guildId), {
body: commands, 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) { } 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 () => { client.once("ready", async () => {
console.log(`\n==============================`); printBanner();
console.log(`🤖 Logged in as ${client.user.tag}`);
console.log(`==============================`); 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); const guilds = client.guilds.cache.map((guild) => guild.id);
console.log(
`${colors.yellow}${colors.bright}⚙️ Initializing server configurations...${colors.reset}`
);
await Promise.all( await Promise.all(
guilds.map(async (guildId) => { guilds.map(async (guildId) => {
@ -75,68 +180,139 @@ client.once("ready", async () => {
}) })
); );
// Set bot status and activity
client.user.setPresence({ client.user.setPresence({
activities: [{ name: "Degenerate Gamers!", type: 3 }], activities: [{ name: "Powering Servers! 🚀", type: 3 }],
status: PresenceUpdateStatus.Online, 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) => { client.on("guildCreate", async (guild) => {
try { try {
// Create a new entry in the ServerSettings collection with just the guildId
await ServerSettings.create({ guildId: guild.id }); await ServerSettings.create({ guildId: guild.id });
console.log(`✅ Registered new server: ${guild.name} (ID: ${guild.id})`); console.log(
`${colors.green}✅ Registered new server: ${colors.cyan}${guild.name}${colors.reset} ${colors.dim}(${guild.id})${colors.reset}`
// seed items for new guild with guildId );
await seedShopItems(guild.id); await seedShopItems(guild.id);
// Seed spyfall locations for the new guild
await seedSpyfallLocations(guild.id); await seedSpyfallLocations(guild.id);
// Register slash commands for the new guild
await registerCommands(guild.id); 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) { } 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 // MongoDB connection
console.log(
`${colors.yellow}${colors.bright}🔗 Connecting to MongoDB...${colors.reset}`
);
mongoose mongoose
.connect(process.env.MONGODB_URI) .connect(process.env.MONGODB_URI)
.then(() => console.log("✅ Connected to MongoDB")) .then(() => {
.catch((err) => console.error("❌ Failed to connect to MongoDB", err)); 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) => { client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return; if (!interaction.isCommand()) return;
const command = client.commands.get(interaction.commandName); const command = client.commands.get(interaction.commandName);
if (!command) return; if (!command) return;
try { try {
await command.execute(interaction, client); await command.execute(interaction, client);
} catch (err) { } catch (err) {
console.error("Error executing command:", err); console.log(
if (interaction.deferred || interaction.ephemeral) { `${colors.red}❌ Error executing command ${colors.cyan}/${interaction.commandName}${colors.reset}`
await interaction.followUp({ );
content: "There was an error while executing this command!", console.error(err);
ephemeral: true, const replyOptions = {
}); content: "❌ There was an error while executing this command!",
ephemeral: true,
};
if (interaction.deferred || interaction.replied) {
await interaction.followUp(replyOptions);
} else { } else {
await interaction.reply({ await interaction.reply(replyOptions);
content: "There was an error while executing this command!",
ephemeral: true,
});
} }
} }
}); });
client.on("error", (err) => { 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);
});

16
models/MusicSettings.js Normal file
View file

@ -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);

View file

@ -11,10 +11,19 @@
"keywords": [], "keywords": [],
"license": "MIT", "license": "MIT",
"dependencies": { "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", "axios": "^1.7.7",
"discord.js": "^14.15.3", "discord.js": "^14.15.3",
"distube": "^5.0.7",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"ffmpeg-static": "^5.2.0",
"genius-lyrics": "^4.4.7",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.6.0", "mongoose": "^8.6.0",

138
utils/liveLyricsManager.js Normal file
View file

@ -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 };

140
utils/lyricsProvider.js Normal file
View file

@ -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 };

16
utils/musicGuards.js Normal file
View file

@ -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;
};

34
utils/musicSettings.js Normal file
View file

@ -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 };