mirror of
https://github.com/aydenjahola/discord-multipurpose-bot.git
synced 2025-09-21 06:41:35 +01:00
* 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:
parent
51dcdc7406
commit
cb5a906850
80 changed files with 2473 additions and 202 deletions
29
.github/workflows/docker-build.yml
vendored
29
.github/workflows/docker-build.yml
vendored
|
@ -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
|
||||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -1,6 +1,14 @@
|
||||||
FROM node:current-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk update && \
|
||||||
|
apk add --no-cache \
|
||||||
|
ffmpeg \
|
||||||
|
build-base \
|
||||||
|
python3 \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
@ -8,4 +16,6 @@ RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["node", "."]
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["node", "index.js"]
|
143
LICENSE
143
LICENSE
|
@ -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
151
README.md
|
@ -1,4 +1,4 @@
|
||||||
# Discord Multipurpose Bot
|
# Circuitrix Discord Bot
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -8,32 +8,72 @@ This bot includes game statistics functionality (currently supports Valorant, CS
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Welcome to the **Discord Multipurpose Bot**! This bot manages user verification for Discord servers through email authentication, includes a fun trivia game feature, and provides role management and leaderboard tracking functionalities.
|
Welcome to **Circuitrix Discord Bot**! A powerful multipurpose Discord bot featuring email verification, trivia games, music functionality, and comprehensive server management tools.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Email Verification**: Users receive a verification code via email and must enter it in Discord to verify their account.
|
### 🔐 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
178
commands/admin/music.js
Normal 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 (0–200)")
|
||||||
|
)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("autoplay")
|
||||||
|
.setDescription("Autoplay when queue ends (true/false)")
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o.setName("maxqueue").setDescription("Max queue size (1–5000)")
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("maxplaylist")
|
||||||
|
.setDescription("Max playlist import size (1–2000)")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("djrole-add")
|
||||||
|
.setDescription("Allow a role to use DJ commands.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o.setName("role").setDescription("Role").setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("djrole-remove")
|
||||||
|
.setDescription("Remove a DJ role.")
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
o.setName("role").setDescription("Role").setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("channel-allow")
|
||||||
|
.setDescription(
|
||||||
|
"Restrict music commands to a text channel (call multiple times)."
|
||||||
|
)
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
o.setName("channel").setDescription("Text channel").setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("channel-remove")
|
||||||
|
.setDescription("Remove an allowed text channel.")
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
o.setName("channel").setDescription("Text channel").setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("channel-clear")
|
||||||
|
.setDescription("Allow music commands in all text channels.")
|
||||||
|
),
|
||||||
|
category: "Admin",
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const guildId = interaction.guildId;
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
const settings = await ensure(guildId);
|
||||||
|
|
||||||
|
if (sub === "show") {
|
||||||
|
const lines = [
|
||||||
|
`**Default Volume:** ${settings.defaultVolume}%`,
|
||||||
|
`**Autoplay:** ${settings.autoplay ? "On" : "Off"}`,
|
||||||
|
`**Max Queue:** ${settings.maxQueue}`,
|
||||||
|
`**Max Playlist Import:** ${settings.maxPlaylistImport}`,
|
||||||
|
`**DJ Roles:** ${
|
||||||
|
settings.djRoleIds.length
|
||||||
|
? settings.djRoleIds.map((id) => `<@&${id}>`).join(", ")
|
||||||
|
: "*none*"
|
||||||
|
}`,
|
||||||
|
`**Allowed Text Channels:** ${
|
||||||
|
settings.allowedTextChannelIds.length
|
||||||
|
? settings.allowedTextChannelIds.map((id) => `<#${id}>`).join(", ")
|
||||||
|
: "*all*"
|
||||||
|
}`,
|
||||||
|
];
|
||||||
|
return interaction.followUp(lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutate helpers
|
||||||
|
const update = {};
|
||||||
|
if (sub === "set") {
|
||||||
|
const vol = interaction.options.getInteger("volume");
|
||||||
|
const autoplay = interaction.options.getBoolean("autoplay");
|
||||||
|
const maxQ = interaction.options.getInteger("maxqueue");
|
||||||
|
const maxP = interaction.options.getInteger("maxplaylist");
|
||||||
|
|
||||||
|
if (vol !== null) {
|
||||||
|
if (vol < 0 || vol > 200)
|
||||||
|
return interaction.followUp(err("Volume must be 0–200."));
|
||||||
|
update.defaultVolume = vol;
|
||||||
|
}
|
||||||
|
if (autoplay !== null) update.autoplay = autoplay;
|
||||||
|
if (maxQ !== null) {
|
||||||
|
if (maxQ < 1 || maxQ > 5000)
|
||||||
|
return interaction.followUp(err("Max queue must be 1–5000."));
|
||||||
|
update.maxQueue = maxQ;
|
||||||
|
}
|
||||||
|
if (maxP !== null) {
|
||||||
|
if (maxP < 1 || maxP > 2000)
|
||||||
|
return interaction.followUp(err("Max playlist must be 1–2000."));
|
||||||
|
update.maxPlaylistImport = maxP;
|
||||||
|
}
|
||||||
|
|
||||||
|
await set(guildId, update);
|
||||||
|
return interaction.followUp(ok("Settings updated."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "djrole-add") {
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
const setDoc = new Set(settings.djRoleIds || []);
|
||||||
|
setDoc.add(role.id);
|
||||||
|
await set(guildId, { djRoleIds: Array.from(setDoc) });
|
||||||
|
return interaction.followUp(ok(`Added DJ role ${role}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "djrole-remove") {
|
||||||
|
const role = interaction.options.getRole("role", true);
|
||||||
|
const setDoc = new Set(settings.djRoleIds || []);
|
||||||
|
setDoc.delete(role.id);
|
||||||
|
await set(guildId, { djRoleIds: Array.from(setDoc) });
|
||||||
|
return interaction.followUp(ok(`Removed DJ role ${role}.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "channel-allow") {
|
||||||
|
const ch = interaction.options.getChannel("channel", true);
|
||||||
|
const setDoc = new Set(settings.allowedTextChannelIds || []);
|
||||||
|
setDoc.add(ch.id);
|
||||||
|
await set(guildId, { allowedTextChannelIds: Array.from(setDoc) });
|
||||||
|
return interaction.followUp(ok(`Allowed ${ch} for music commands.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "channel-remove") {
|
||||||
|
const ch = interaction.options.getChannel("channel", true);
|
||||||
|
const setDoc = new Set(settings.allowedTextChannelIds || []);
|
||||||
|
setDoc.delete(ch.id);
|
||||||
|
await set(guildId, { allowedTextChannelIds: Array.from(setDoc) });
|
||||||
|
return interaction.followUp(ok(`Removed ${ch} from allowed channels.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "channel-clear") {
|
||||||
|
await set(guildId, { allowedTextChannelIds: [] });
|
||||||
|
return interaction.followUp(
|
||||||
|
ok("Cleared channel restrictions (music commands allowed everywhere).")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction.followUp(err("Unknown subcommand."));
|
||||||
|
},
|
||||||
|
};
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -22,6 +22,7 @@ module.exports = {
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
isModOnly: true,
|
isModOnly: true,
|
||||||
|
category: "Moderation",
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -27,6 +27,7 @@ module.exports = {
|
||||||
.setMaxValue(100)
|
.setMaxValue(100)
|
||||||
),
|
),
|
||||||
isModOnly: true,
|
isModOnly: true,
|
||||||
|
category: "Moderation",
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -15,6 +15,7 @@ module.exports = {
|
||||||
.setRequired(false)
|
.setRequired(false)
|
||||||
),
|
),
|
||||||
isModOnly: true,
|
isModOnly: true,
|
||||||
|
category: "Moderation",
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -22,6 +22,7 @@ module.exports = {
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
),
|
),
|
||||||
isModOnly: true,
|
isModOnly: true,
|
||||||
|
category: "Moderation",
|
||||||
|
|
||||||
async execute(interaction) {
|
async execute(interaction) {
|
||||||
try {
|
try {
|
||||||
|
|
81
commands/music/livelyrics.js
Normal file
81
commands/music/livelyrics.js
Normal 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. I’ll post lines in a thread (or here if I can’t create one)."
|
||||||
|
);
|
||||||
|
} else if (sub === "stop") {
|
||||||
|
await live.stop(queue.id, { deleteThread: true });
|
||||||
|
return interaction.followUp(
|
||||||
|
"🧹 Stopped live lyrics and removed the thread."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction.followUp("❌ Unknown subcommand.");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e?.message ?? "❌ Failed to run /livelyrics.";
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.followUp({ content: msg, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
71
commands/music/loop.js
Normal file
71
commands/music/loop.js
Normal 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
91
commands/music/lyrics.js
Normal 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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
86
commands/music/nowplaying.js
Normal file
86
commands/music/nowplaying.js
Normal 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
36
commands/music/pause.js
Normal 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
58
commands/music/play.js
Normal 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
269
commands/music/queue.js
Normal 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
42
commands/music/resume.js
Normal 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
98
commands/music/seek.js
Normal 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 doesn’t support seeking.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = interaction.options.getString("to", true);
|
||||||
|
const parsed = parseTime(input);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(
|
||||||
|
"❌ Invalid time. Use `90`, `1:30`, `01:02:03`, `+30`, or `-10`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = Math.floor(queue.currentTime ?? 0);
|
||||||
|
const duration = Math.floor(song.duration);
|
||||||
|
|
||||||
|
let target = parsed.relative ? current + parsed.seconds : parsed.seconds;
|
||||||
|
target = Math.max(0, Math.min(duration - 1, Math.floor(target)));
|
||||||
|
|
||||||
|
await queue.seek(target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const live = require("../../utils/liveLyricsManager");
|
||||||
|
live.seek(queue, target);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await interaction.followUp(
|
||||||
|
`⏭️ Seeked to **${fmt(target)}** (track length \`${fmt(duration)}\`).`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e?.message ?? "❌ Failed to seek.";
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.followUp({ content: msg, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
79
commands/music/shuffle.js
Normal file
79
commands/music/shuffle.js
Normal 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
140
commands/music/skip.js
Normal 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, we’ll just skip once below.
|
||||||
|
if (toIndex > 1) {
|
||||||
|
// delete from position 1..(toIndex-1)
|
||||||
|
queue.songs.splice(1, toIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the desired target is at index 1, skip once to play it
|
||||||
|
try {
|
||||||
|
queue.skip();
|
||||||
|
} catch (e) {
|
||||||
|
// If skip throws but autoplay is on, let autoplay do its thing
|
||||||
|
if (!queue.autoplay) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any active live-lyrics thread for the current song
|
||||||
|
try {
|
||||||
|
const live = require("../../utils/liveLyricsManager");
|
||||||
|
await live.stop(interaction.guildId, { deleteThread: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return interaction.followUp(
|
||||||
|
`⏭️ Jumped to track **#${toIndex}** in the queue.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple “skip next”
|
||||||
|
try {
|
||||||
|
queue.skip();
|
||||||
|
} catch (e) {
|
||||||
|
// If there’s no next but autoplay is on, allow autoplay
|
||||||
|
if (!queue.autoplay) {
|
||||||
|
// Fallback: nothing to skip to
|
||||||
|
try {
|
||||||
|
queue.stop();
|
||||||
|
} catch {}
|
||||||
|
client.distube.voices.leave(interaction.guildId);
|
||||||
|
try {
|
||||||
|
const live = require("../../utils/liveLyricsManager");
|
||||||
|
await live.stop(interaction.guildId, { deleteThread: true });
|
||||||
|
} catch {}
|
||||||
|
return interaction.followUp(
|
||||||
|
"🏁 No more songs — stopped and left the voice channel."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const live = require("../../utils/liveLyricsManager");
|
||||||
|
await live.stop(interaction.guildId, { deleteThread: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return interaction.followUp("⏭️ Skipped the current song!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("skip command failed:", e);
|
||||||
|
const msg = e?.message || "❌ Failed to skip.";
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
return interaction.followUp({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
return interaction.reply({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
51
commands/music/stop.js
Normal file
51
commands/music/stop.js
Normal 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
131
commands/music/volume.js
Normal 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 (0–200).")
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc.setName("show").setDescription("Show the current volume.")
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("set")
|
||||||
|
.setDescription("Set the volume to a specific level (0–200).")
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("level")
|
||||||
|
.setDescription("Volume percent (0–200)")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(0)
|
||||||
|
.setMaxValue(200)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("up")
|
||||||
|
.setDescription("Turn the volume up by N (default 10).")
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("by")
|
||||||
|
.setDescription("Percent to increase (1–100)")
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(100)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("down")
|
||||||
|
.setDescription("Turn the volume down by N (default 10).")
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("by")
|
||||||
|
.setDescription("Percent to decrease (1–100)")
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(100)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc.setName("mute").setDescription("Set volume to 0%.")
|
||||||
|
)
|
||||||
|
.addSubcommand((sc) =>
|
||||||
|
sc
|
||||||
|
.setName("unmute")
|
||||||
|
.setDescription("Restore volume to 100% (or specify level).")
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
o
|
||||||
|
.setName("level")
|
||||||
|
.setDescription("Volume percent (1–200)")
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(200)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
category: "Music",
|
||||||
|
|
||||||
|
async execute(interaction, client) {
|
||||||
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
requireVC(interaction);
|
||||||
|
const queue = requireQueue(client, interaction);
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
const current = clamp(Number(queue.volume ?? 100), 0, 200);
|
||||||
|
|
||||||
|
// Helper to apply and confirm
|
||||||
|
const apply = (val) => {
|
||||||
|
const v = clamp(Math.round(val), 0, 200);
|
||||||
|
queue.setVolume(v);
|
||||||
|
const advisory = v > 100 ? " *(warning: may distort >100%)*" : "";
|
||||||
|
return interaction.followUp(`🔊 Volume set to **${v}%**${advisory}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sub === "show") {
|
||||||
|
const advisory = current > 100 ? " *(>100% may distort)*" : "";
|
||||||
|
return interaction.followUp(
|
||||||
|
`🔊 Current volume: **${current}%**${advisory}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "set") {
|
||||||
|
const level = interaction.options.getInteger("level", true);
|
||||||
|
return apply(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "up") {
|
||||||
|
const step = interaction.options.getInteger("by") ?? 10;
|
||||||
|
return apply(current + step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "down") {
|
||||||
|
const step = interaction.options.getInteger("by") ?? 10;
|
||||||
|
return apply(current - step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "mute") {
|
||||||
|
return apply(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "unmute") {
|
||||||
|
const level = interaction.options.getInteger("level") ?? 100;
|
||||||
|
return apply(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction.followUp({
|
||||||
|
content: "❌ Unknown subcommand.",
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("volume command failed:", e);
|
||||||
|
const msg = e?.message || "❌ Failed to adjust volume.";
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
return interaction.followUp({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
return interaction.reply({ content: msg, ephemeral: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
7
docker-compose.yml
Normal 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
197
events/distubeEvents.js
Normal 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 it’s running (no-op otherwise)
|
||||||
|
live.seek(queue, time).catch(() => {});
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("volumeChange", (queue, volume) => {
|
||||||
|
const embed = createEmbed(
|
||||||
|
0x0099ff,
|
||||||
|
"🔊 Volume Changed",
|
||||||
|
`Volume set to ${volume}%`
|
||||||
|
);
|
||||||
|
queue.textChannel?.send({ embeds: [embed] });
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("finishSong", (queue, song) => {
|
||||||
|
// If a manual /livelyrics is active, stop the current thread for this song.
|
||||||
|
// If the next song plays and the user wants lyrics again, they can run /livelyrics start.
|
||||||
|
live.stop(queue.id, { deleteThread: true }).catch(() => {});
|
||||||
|
// If nothing left and autoplay is off, leave now.
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
const remaining = Array.isArray(queue.songs) ? queue.songs.length : 0;
|
||||||
|
if (remaining === 0 && !queue.autoplay) {
|
||||||
|
queue.distube.voices.leave(queue.id);
|
||||||
|
queue.textChannel?.send({
|
||||||
|
embeds: [
|
||||||
|
createEmbed(
|
||||||
|
0x0099ff,
|
||||||
|
"🏁 No More Songs",
|
||||||
|
`Finished **${
|
||||||
|
song?.name ?? "track"
|
||||||
|
}** — nothing left, disconnecting.`
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("finishSong immediate-leave failed:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("noRelated", (queue) => {
|
||||||
|
const embed = createEmbed(
|
||||||
|
0xff0000,
|
||||||
|
"❌ No Related Videos",
|
||||||
|
"Could not find related video for autoplay!"
|
||||||
|
);
|
||||||
|
queue.textChannel?.send({ embeds: [embed] });
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("finish", (queue) => {
|
||||||
|
try {
|
||||||
|
queue.distube.voices.leave(queue.id);
|
||||||
|
const embed = createEmbed(
|
||||||
|
0x0099ff,
|
||||||
|
"🏁 Queue Finished",
|
||||||
|
"Queue ended — disconnecting now."
|
||||||
|
);
|
||||||
|
queue.textChannel?.send({ embeds: [embed] });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Immediate leave on finish failed:", e);
|
||||||
|
} finally {
|
||||||
|
// Always cleanup any live thread if one was running
|
||||||
|
live.stop(queue.id, { deleteThread: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("empty", (queue) => {
|
||||||
|
try {
|
||||||
|
queue.distube.voices.leave(queue.id);
|
||||||
|
queue.textChannel?.send({
|
||||||
|
embeds: [
|
||||||
|
createEmbed(
|
||||||
|
0xff0000,
|
||||||
|
"🔇 Left Voice Channel",
|
||||||
|
"Channel became empty — disconnecting now."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Immediate leave on empty failed:", e);
|
||||||
|
} finally {
|
||||||
|
live.stop(queue.id, { deleteThread: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("disconnect", (queue) => {
|
||||||
|
// Always cleanup on manual disconnect too
|
||||||
|
live.stop(queue.id, { deleteThread: true }).catch(() => {});
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("error", (error, queue) => {
|
||||||
|
console.error("DisTube error:", error);
|
||||||
|
queue?.textChannel?.send(
|
||||||
|
"❌ Playback error: " + (error?.message || String(error)).slice(0, 500)
|
||||||
|
);
|
||||||
|
if (queue?.id)
|
||||||
|
live.stop(queue.id, { deleteThread: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
258
index.js
258
index.js
|
@ -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);
|
||||||
|
if (command.data && command.data.name) {
|
||||||
client.commands.set(command.data.name, command);
|
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);
|
||||||
|
const replyOptions = {
|
||||||
|
content: "❌ There was an error while executing this command!",
|
||||||
ephemeral: true,
|
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
16
models/MusicSettings.js
Normal 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);
|
|
@ -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
138
utils/liveLyricsManager.js
Normal 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
140
utils/lyricsProvider.js
Normal 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
16
utils/musicGuards.js
Normal 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
34
utils/musicSettings.js
Normal 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 };
|
Loading…
Reference in a new issue