Compare commits
40 Commits
docker
...
75185a59c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 75185a59c3 | |||
| 5b51e3f529 | |||
| 99a1417c43 | |||
| 6546cb8d63 | |||
| b958e79a98 | |||
| 81c65a3644 | |||
| 68a3f4fb58 | |||
| 5c5574c06e | |||
| 9e02e50693 | |||
| 9d7ff5e7e7 | |||
| 3ba230e6e9 | |||
| 253f369a89 | |||
| 7500ea01c8 | |||
| f31bba40fb | |||
| 4d5c301c46 | |||
| 8f8ff6aa81 | |||
| ca9e531541 | |||
| f1991f7716 | |||
| ce635cb32b | |||
| 30b5b23868 | |||
| 74cac2bfbb | |||
| bb7a796cf9 | |||
| a54becb3a0 | |||
| 0d0125bf55 | |||
| 854cf12d64 | |||
| e54c23cc63 | |||
| 5a29fe3d9d | |||
| 42de01e004 | |||
| 537a8c6709 | |||
|
|
0b86b5d891 | ||
| d4de2feaaa | |||
| 95ea55d972 | |||
| 170faf7d01 | |||
| f50c88515e | |||
| 57d10ddf70 | |||
| 6daf1993d1 | |||
| 47de3823f3 | |||
| 74dfdbf667 | |||
| 5c632556b7 | |||
| 05fec6747d |
@@ -1,9 +0,0 @@
|
|||||||
# .dockerignore
|
|
||||||
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
target/
|
|
||||||
.env
|
|
||||||
*.md
|
|
||||||
Lavalink.jar
|
|
||||||
application.yml
|
|
||||||
17
.env.example
17
.env.example
@@ -1,8 +1,17 @@
|
|||||||
# .env (Docker Compose Version)
|
# Discord Bot Token
|
||||||
|
DISCORD_TOKEN=your_token_here
|
||||||
|
|
||||||
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
|
# Discord Application Client ID (for command deployment)
|
||||||
|
CLIENT_ID=your_client_id_here
|
||||||
|
|
||||||
# Use the Docker Compose service name for the host
|
# Discord Guild ID (optional, for deploying commands to a specific test server)
|
||||||
|
# GUILD_ID=your_guild_id_here
|
||||||
|
|
||||||
|
# Lavalink Configuration
|
||||||
|
# Use 'lavalink' if running via docker-compose, '127.0.0.1' or 'localhost' if running Lavalink directly
|
||||||
LAVALINK_HOST=lavalink
|
LAVALINK_HOST=lavalink
|
||||||
LAVALINK_PORT=2333
|
LAVALINK_PORT=2333
|
||||||
LAVALINK_PASSWORD=youshallnotpass # Must match application.yml
|
LAVALINK_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# Logging Level (e.g., debug, info, warn, error)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|||||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -1,22 +1,20 @@
|
|||||||
# ---> Rust
|
# Node modules
|
||||||
# Generated by Cargo
|
node_modules/
|
||||||
# will have compiled files and executables
|
dist/
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# dotenv environment variables
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
.env
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# VSCode settings
|
||||||
**/*.rs.bk
|
.vscode/
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# Mac system files
|
||||||
*.pdb
|
.DS_Store
|
||||||
|
|
||||||
# RustRover
|
# Lockfiles
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
pnpm-lock.yaml
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# Logs
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
npm-debug.log*
|
||||||
#.idea/
|
logs/
|
||||||
|
*.log
|
||||||
|
|||||||
18
Cargo.toml
18
Cargo.toml
@@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "discord-music-bot-lavalink-rs"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serenity = { version = "0.12", features = ["builder", "cache", "client", "gateway", "model", "framework", "standard_framework", "http", "rustls_backend", "voice", "collector", "interactions"] }
|
|
||||||
lavalink-rs = { version = "0.14", features = ["serenity", "tungstenite-rustls-native-roots"] }
|
|
||||||
tokio = { version = "1.44", features = ["full"] }
|
|
||||||
dotenv = "0.15"
|
|
||||||
url = "2.5"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
anyhow = "1.0"
|
|
||||||
# Add inventory for command registration
|
|
||||||
inventory = "0.3"
|
|
||||||
# Add futures for boxed async traits
|
|
||||||
futures = "0.3"
|
|
||||||
47
Dockerfile
47
Dockerfile
@@ -1,42 +1,15 @@
|
|||||||
# Dockerfile
|
FROM node:23-alpine
|
||||||
|
|
||||||
# ---- Planner Stage ----
|
|
||||||
# Use the official Rust Alpine image as a base for planning.
|
|
||||||
# We use the same base image for planner and builder for consistency.
|
|
||||||
FROM rust:1-alpine AS planner
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Install cargo-chef for build caching
|
|
||||||
RUN apk add --no-cache musl-dev # Needed by chef
|
RUN apk add --no-cache python3 make g++ pnpm
|
||||||
RUN cargo install cargo-chef --locked
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
# Compute dependencies. This output will be cached if Cargo.toml/lock haven't changed.
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
# ---- Builder Stage ----
|
ENV NODE_ENV=production
|
||||||
# Use the same Rust Alpine image for building.
|
|
||||||
FROM rust:1-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
# Install build dependencies for Alpine
|
|
||||||
RUN apk add --no-cache build-base openssl-dev pkgconfig
|
|
||||||
# Install cargo-chef again (could optimize but simpler this way)
|
|
||||||
RUN cargo install cargo-chef --locked
|
|
||||||
# Copy the dependency recipe from the planner stage
|
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
|
||||||
# Build dependencies based on the recipe. This layer is cached efficiently.
|
|
||||||
RUN cargo chef cook --release --recipe-path recipe.json
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
# Build the application binary, linking against pre-built dependencies
|
|
||||||
# Ensure the binary name matches your package name in Cargo.toml
|
|
||||||
RUN cargo build --release --bin discord-music-bot-lavalink-rs
|
|
||||||
|
|
||||||
# ---- Runtime Stage ----
|
CMD ["node", "src/index.js"]
|
||||||
# Use a minimal Alpine image for the final runtime environment.
|
|
||||||
FROM alpine:latest
|
|
||||||
WORKDIR /app
|
|
||||||
# Install runtime dependencies needed by the binary (e.g., SSL certs)
|
|
||||||
RUN apk add --no-cache ca-certificates openssl
|
|
||||||
# Copy the compiled application binary from the builder stage
|
|
||||||
COPY --from=builder /app/target/release/discord-music-bot-lavalink-rs /app/
|
|
||||||
# Set the binary as the entrypoint
|
|
||||||
CMD ["./discord-music-bot-lavalink-rs"]
|
|
||||||
|
|||||||
249
README.md
249
README.md
@@ -1,224 +1,69 @@
|
|||||||
# Rust LavaLink Discord Music Bot Prototype
|
# discord-music-bot
|
||||||
|
|
||||||
A prototype Discord music bot built with Rust, using the [Serenity](https://github.com/serenity-rs/serenity) library for Discord interactions and the [lavalink-rs](https://github.com/adamsoutar/lavalink-rs) crate to interface with a [LavaLink](https://github.com/lavalink-devs/Lavalink) audio server. This bot utilizes slash commands for user interaction and features a modular command structure using the `inventory` crate. It can be run natively or using Docker Compose for easier setup and deployment.
|
Discord music bot template written in NodeJS using `discord.js` and `erela.js`, with Lavalink support.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Connects to Discord via the Gateway.
|
- Slash commands: `/ping`, `/join`, `/play`, `/leave`
|
||||||
* Connects to a LavaLink node for audio processing.
|
- Lavalink integration for audio playback
|
||||||
* Slash command support for core music functions:
|
- Modular command handler structure
|
||||||
* `/join`: Connect the bot to your voice channel.
|
|
||||||
* `/play`: Play audio from a URL or search query (YouTube default). Handles single tracks and playlists.
|
|
||||||
* `/skip`: Skip the currently playing track.
|
|
||||||
* `/leave`: Disconnect the bot from the voice channel.
|
|
||||||
* Basic queueing (handled internally by LavaLink).
|
|
||||||
* Modular command system using `inventory` for easy addition of new commands.
|
|
||||||
* Basic auto-leaving functionality when the bot is alone in a voice channel (with a short delay).
|
|
||||||
* Asynchronous architecture using Tokio.
|
|
||||||
* Logging using `tracing`.
|
|
||||||
* Containerized setup using Docker and Docker Compose.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### For Native Execution
|
- Node.js (>=14)
|
||||||
|
- pnpm or npm
|
||||||
1. **Rust Toolchain:** Install Rust using [rustup](https://rustup.rs/). (`rustup update` recommended).
|
- A Discord application with bot token
|
||||||
2. **LavaLink Server:**
|
- LavaLink server for audio streaming
|
||||||
* Download the latest `Lavalink.jar` from the [LavaLink Releases](https://github.com/lavalink-devs/Lavalink/releases).
|
|
||||||
* A **Java Runtime Environment (JRE)** (Version 11 or higher, 17 recommended) to run the `.jar` file.
|
|
||||||
* Configure the `application.yml` file for LavaLink.
|
|
||||||
3. **Discord Bot Application:** (See details below)
|
|
||||||
|
|
||||||
### For Docker Execution
|
|
||||||
|
|
||||||
1. **Docker & Docker Compose:** Install Docker Desktop (which includes Compose) or Docker Engine and the Docker Compose plugin. See the [official Docker installation guide](https://docs.docker.com/engine/install/).
|
|
||||||
2. **LavaLink Server Files:** You still need to download `Lavalink.jar` and create/configure `application.yml` locally, as these will be mounted into the LavaLink container.
|
|
||||||
3. **Discord Bot Application:** (See details below)
|
|
||||||
|
|
||||||
### Discord Bot Application Details (Common)
|
|
||||||
|
|
||||||
* Create a bot application on the [Discord Developer Portal](https://discord.com/developers/applications).
|
|
||||||
* Retrieve the **Bot Token**.
|
|
||||||
* Enable the **Privileged Gateway Intents**:
|
|
||||||
* `PRESENCE INTENT` (Optional)
|
|
||||||
* `SERVER MEMBERS INTENT` (Optional, not strictly required for prototype)
|
|
||||||
* `MESSAGE CONTENT INTENT` (Optional, not strictly required for prototype)
|
|
||||||
* **`GUILD_VOICE_STATES`** (**Required** for voice functionality)
|
|
||||||
* Invite the bot to your Discord server with necessary permissions: `Connect`, `Speak`, `Send Messages`/`Send Messages in Threads`, `Use Application Commands`.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Clone the Repository:**
|
1. Copy `.env.example` to `.env` and fill in your credentials:
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd discord-music-bot-lavalink-rs
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Prepare LavaLink Files:**
|
|
||||||
* Download the latest `Lavalink.jar` from [LavaLink Releases](https://github.com/lavalink-devs/Lavalink/releases) and place it in the project root directory.
|
|
||||||
* Create or copy an `application.yml` file into the project root directory. Configure at least the `lavalink.server.password`. Example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# application.yml
|
|
||||||
server:
|
|
||||||
port: 2333
|
|
||||||
address: 0.0.0.0
|
|
||||||
lavalink:
|
|
||||||
server:
|
|
||||||
password: "youshallnotpass" # CHANGE THIS
|
|
||||||
sources: # Enable desired sources
|
|
||||||
youtube: true
|
|
||||||
soundcloud: true
|
|
||||||
# Add other configurations as needed
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure Environment Variables:**
|
|
||||||
Create a `.env` file in the project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` with your credentials:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# .env
|
DISCORD_TOKEN=your_discord_bot_token
|
||||||
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
|
CLIENT_ID=your_discord_application_id
|
||||||
|
LAVALINK_HOST=127.0.0.1
|
||||||
# --- IMPORTANT: Choose ONE Lavalink Host setting ---
|
|
||||||
|
|
||||||
# == For Docker Compose Execution ==
|
|
||||||
# Use the service name defined in docker-compose.yml
|
|
||||||
LAVALINK_HOST=lavalink
|
|
||||||
|
|
||||||
# == For Native Execution ==
|
|
||||||
# Use the actual IP or hostname of your LavaLink server
|
|
||||||
# LAVALINK_HOST=127.0.0.1 # Or e.g., other-machine-ip
|
|
||||||
|
|
||||||
# --- Common Settings ---
|
|
||||||
LAVALINK_PORT=2333
|
LAVALINK_PORT=2333
|
||||||
LAVALINK_PASSWORD=youshallnotpass # Must match password in application.yml
|
LAVALINK_PASSWORD=your_lavalink_password
|
||||||
|
```
|
||||||
|
2. Install dependencies:
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
3. Run tests:
|
||||||
|
```sh
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
4. Register slash commands:
|
||||||
|
```sh
|
||||||
|
pnpm start # or node deploy-commands.js
|
||||||
|
```
|
||||||
|
5. Start the bot:
|
||||||
|
```sh
|
||||||
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Ensure the `LAVALINK_PASSWORD` here matches the one in `application.yml`.
|
## Docker
|
||||||
|
|
||||||
## Running the Bot
|
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
|
||||||
|
|
||||||
You can run the bot natively or using Docker Compose.
|
- Build and run with Docker Compose:
|
||||||
|
```sh
|
||||||
### Option 1: Running with Docker Compose (Recommended)
|
docker-compose up --build
|
||||||
|
|
||||||
This method runs both the bot and the LavaLink server in isolated containers.
|
|
||||||
|
|
||||||
1. **Ensure Prerequisites:** Docker, Docker Compose, `Lavalink.jar`, `application.yml`, configured `.env` (with `LAVALINK_HOST=lavalink`).
|
|
||||||
2. **Build and Start:** In the project root, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build -d
|
|
||||||
```
|
```
|
||||||
|
- Environment variables are loaded from `.env`.
|
||||||
* `--build`: Builds the bot image. Needed the first time or after code changes.
|
- Lavalink service is configured in `docker-compose.yml` alongside the bot.
|
||||||
* `-d`: Runs containers in the background.
|
|
||||||
3. **Check Logs (Optional):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f app # View bot logs
|
|
||||||
docker compose logs -f lavalink # View LavaLink logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Press `Ctrl+C` to stop following.
|
|
||||||
4. **Stopping:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Running Natively
|
|
||||||
|
|
||||||
This method requires you to run LavaLink separately.
|
|
||||||
|
|
||||||
1. **Start LavaLink:**
|
|
||||||
Navigate to the directory containing `Lavalink.jar` and `application.yml`. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
java -jar Lavalink.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep this terminal open.
|
|
||||||
2. **Build the Bot (if needed):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure `.env`:** Ensure `LAVALINK_HOST` in your `.env` file points to the correct IP/hostname where LavaLink is running (e.g., `127.0.0.1` if running on the same machine).
|
|
||||||
4. **Run the Bot:**
|
|
||||||
In a **new terminal**, navigate to the project root and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using cargo
|
|
||||||
cargo run
|
|
||||||
# Or using the compiled release binary
|
|
||||||
# target/release/discord-music-bot-lavalink-rs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Commands
|
|
||||||
|
|
||||||
Use these commands in your Discord server where the bot is present:
|
|
||||||
|
|
||||||
* `/join` : Makes the bot join the voice channel you are currently in.
|
|
||||||
* `/play query:<url or search term>` : Plays a song from a URL (direct link or playlist) or searches YouTube for the term and plays the first result. Queues subsequent tracks/playlists.
|
|
||||||
* `/skip` : Skips the song currently playing.
|
|
||||||
* `/leave` : Disconnects the bot from the voice channel.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
- `src/index.js` — Entry point
|
||||||
discord-music-bot-lavalink-rs/
|
- `src/commands/` — Slash command modules
|
||||||
├── .env # Local environment variables (ignored by git)
|
- `src/events/` — Discord event handlers
|
||||||
├── .env.example # Example environment file
|
- `src/structures/` — Erela.js (Lavalink) event wiring
|
||||||
├── .dockerignore # Files ignored by Docker context
|
- `src/utils/logger.js` — Logging setup
|
||||||
├── Dockerfile # Instructions to build the bot's Docker image
|
- `deploy-commands.js` — Slash command registration script
|
||||||
├── docker-compose.yml # Defines bot and LavaLink services for Docker Compose
|
- `Dockerfile` — Bot container image
|
||||||
├── Lavalink.jar # LavaLink server executable (add locally)
|
- `docker-compose.yml` — Multi-service setup (bot + Lavalink)
|
||||||
├── application.yml # LavaLink configuration (add locally)
|
|
||||||
├── Cargo.toml # Rust project manifest, dependencies
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Entry point, client setup, task spawning
|
|
||||||
├── state.rs # Definition of the shared BotState struct
|
|
||||||
├── handler.rs # Serenity event handler (ready, interaction_create, voice_state_update)
|
|
||||||
├── lavalink_handler.rs # lavalink-rs event handler (track start/end, etc.)
|
|
||||||
├── utils.rs # Utility functions (e.g., get_voice_state)
|
|
||||||
└── commands/ # Directory for slash command implementations
|
|
||||||
├── mod.rs # Command registration setup (inventory, traits)
|
|
||||||
├── join.rs # /join command implementation & registration
|
|
||||||
├── leave.rs # /leave command implementation & registration
|
|
||||||
├── play.rs # /play command implementation & registration
|
|
||||||
└── skip.rs # /skip command implementation & registration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Dependencies
|
## License
|
||||||
|
|
||||||
* [Serenity](https://github.com/serenity-rs/serenity): Discord API library for Rust.
|
MIT
|
||||||
* [lavalink-rs](https://github.com/adamsoutar/lavalink-rs): Client implementation for the LavaLink protocol.
|
|
||||||
* [Tokio](https://tokio.rs/): Asynchronous runtime.
|
|
||||||
* [Inventory](https://github.com/dtolnay/inventory): Type-driven dependency injection / global struct collection (used for command registration).
|
|
||||||
* [Anyhow](https://github.com/dtolnay/anyhow): Flexible error handling.
|
|
||||||
* [Tracing](https://github.com/tokio-rs/tracing): Application-level tracing and logging framework.
|
|
||||||
* [Url](https://crates.io/crates/url): URL parsing.
|
|
||||||
* [Dotenv](https://crates.io/crates/dotenv): Loading environment variables from `.env` files.
|
|
||||||
* [Docker](https://www.docker.com/): Containerization platform.
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
This is a prototype, and many features could be added or improved:
|
|
||||||
|
|
||||||
* [] **More Commands:** `/queue`, `/pause`, `/resume`, `/nowplaying`, `/volume`, `/remove`, `/loop`, `/shuffle`, etc.
|
|
||||||
* [] **Robust Error Handling:** More specific error types and user-friendly feedback.
|
|
||||||
* [] **Queue Management:** Displaying the queue, allowing users to remove specific tracks.
|
|
||||||
* [] **Permissions:** Restricting commands based on user roles or voice channel status.
|
|
||||||
* [] **Configuration:** Per-guild settings (e.g., announcement channels, DJ roles).
|
|
||||||
* [] **Player Persistence:** Saving/loading player state across bot restarts (more complex).
|
|
||||||
* [] **Multi-Node Support:** Utilizing multiple LavaLink nodes for scalability.
|
|
||||||
* [] **Tests:** Adding unit and integration tests.
|
|
||||||
|
|||||||
114
application.yml
114
application.yml
@@ -1,32 +1,108 @@
|
|||||||
# application.yml (Example)
|
|
||||||
server: # REST and WS server
|
server: # REST and WS server
|
||||||
port: 2333
|
port: 2333
|
||||||
address: 0.0.0.0 # Listen on all interfaces within the container
|
address: 0.0.0.0
|
||||||
|
http2:
|
||||||
|
enabled: false # Whether to enable HTTP/2 support
|
||||||
|
# Root level plugin configuration block
|
||||||
|
plugins:
|
||||||
|
youtube:
|
||||||
|
enabled: true # Whether this source can be used.
|
||||||
|
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
|
||||||
|
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
|
||||||
|
allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded.
|
||||||
|
# The clients to use for track loading. See below for a list of valid clients.
|
||||||
|
# Clients are queried in the order they are given (so the first client is queried first and so on...)
|
||||||
|
clients:
|
||||||
|
- WEB
|
||||||
|
- WEBEMBEDDED
|
||||||
lavalink:
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
# - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin
|
||||||
|
# repository: "https://maven.example.com/releases" # optional, defaults to the Lavalink releases repository by default
|
||||||
|
# snapshot: false # optional, defaults to false, used to tell Lavalink to use the snapshot repository instead of the release repository
|
||||||
|
pluginsDir: "/plugins" # Set directory for manually loaded plugins
|
||||||
|
# defaultPluginRepository: "https://maven.lavalink.dev/releases" # optional, defaults to the Lavalink release repository
|
||||||
|
# defaultPluginSnapshotRepository: "https://maven.lavalink.dev/snapshots" # optional, defaults to the Lavalink snapshot repository
|
||||||
server:
|
server:
|
||||||
password: "youshallnotpass" # CHANGE THIS to a strong password
|
password: "${LAVALINK_PASSWORD}" # Use environment variable
|
||||||
sources:
|
sources:
|
||||||
youtube: true
|
# The default Youtube source is now deprecated and won't receive further updates. Please use https://github.com/lavalink-devs/youtube-source#plugin instead.
|
||||||
bandcamp: true
|
youtube: false
|
||||||
soundcloud: true
|
bandcamp: false
|
||||||
twitch: true
|
soundcloud: false
|
||||||
vimeo: true
|
twitch: false
|
||||||
http: true
|
vimeo: false
|
||||||
|
nico: false
|
||||||
|
http: false # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address.
|
||||||
local: false
|
local: false
|
||||||
# bufferDurationMs: 400 # How many milliseconds of audio to buffer? Lower values are less safe.
|
filters: # All filters are enabled by default
|
||||||
# youtubePlaylistLoadLimit: 6 # Number of pages at 100 tracks each
|
volume: true
|
||||||
# playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
|
equalizer: true
|
||||||
# youtubeSearchEnabled: true
|
karaoke: true
|
||||||
# soundcloudSearchEnabled: true
|
timescale: true
|
||||||
# bandcampSearchEnabled: true # Not implemented by LavaLink currently
|
tremolo: true
|
||||||
# Default values are generally fine
|
vibrato: true
|
||||||
# You can find more options here: https://github.com/lavalink-devs/Lavalink/blob/master/IMPLEMENTATION.md#configuration
|
distortion: true
|
||||||
|
rotation: true
|
||||||
|
channelMix: true
|
||||||
|
lowPass: true
|
||||||
|
nonAllocatingFrameBuffer: false # Setting to true reduces the number of allocations made by each player at the expense of frame rebuilding (e.g. non-instantaneous volume changes)
|
||||||
|
bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses.
|
||||||
|
frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered
|
||||||
|
opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
|
||||||
|
resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU.
|
||||||
|
trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data.
|
||||||
|
useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready.
|
||||||
|
youtubePlaylistLoadLimit: 6 # Number of pages at 100 each
|
||||||
|
playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
|
||||||
|
youtubeSearchEnabled: true
|
||||||
|
soundcloudSearchEnabled: true
|
||||||
|
gc-warnings: true
|
||||||
|
#ratelimit:
|
||||||
|
#ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks
|
||||||
|
#excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink
|
||||||
|
#strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch
|
||||||
|
#searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing
|
||||||
|
#retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times
|
||||||
|
#youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without.
|
||||||
|
#email: "" # Email of Google account
|
||||||
|
#password: "" # Password of Google account
|
||||||
|
#httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked
|
||||||
|
#proxyHost: "localhost" # Hostname of the proxy, (ip or domain)
|
||||||
|
#proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy
|
||||||
|
#proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth
|
||||||
|
#proxyPassword: "" # Password for basic authentication
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
prometheus:
|
||||||
|
enabled: false
|
||||||
|
endpoint: /metrics
|
||||||
|
|
||||||
|
sentry:
|
||||||
|
dsn: ""
|
||||||
|
environment: ""
|
||||||
|
# tags:
|
||||||
|
# some_key: some_value
|
||||||
|
# another_key: another_value
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
file:
|
file:
|
||||||
max-history: 30
|
|
||||||
max-size: 1GB
|
|
||||||
path: ./logs/
|
path: ./logs/
|
||||||
|
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
lavalink: INFO
|
lavalink: INFO
|
||||||
|
dev.lavalink.youtube: INFO # Add debug logging for youtube plugin
|
||||||
|
|
||||||
|
request:
|
||||||
|
enabled: true
|
||||||
|
includeClientInfo: true
|
||||||
|
includeHeaders: false
|
||||||
|
includeQueryString: true
|
||||||
|
includePayload: true
|
||||||
|
maxPayloadLength: 10000
|
||||||
|
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: 1GB
|
||||||
|
max-history: 30
|
||||||
|
|||||||
46
compose.yml
46
compose.yml
@@ -1,46 +0,0 @@
|
|||||||
# docker-compose.yml
|
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# The Rust Discord Bot Service
|
|
||||||
app:
|
|
||||||
# Build the image from the Dockerfile in the current directory (.)
|
|
||||||
build: .
|
|
||||||
container_name: discord-bot-app
|
|
||||||
restart: unless-stopped
|
|
||||||
# Pass environment variables from the .env file
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
# Depend on the lavalink service to ensure it starts first (best practice)
|
|
||||||
depends_on:
|
|
||||||
- lavalink
|
|
||||||
|
|
||||||
# The LavaLink Service
|
|
||||||
lavalink:
|
|
||||||
# Use a Java Runtime Environment image (Alpine base for smaller size)
|
|
||||||
image: eclipse-temurin:17-jre-alpine
|
|
||||||
container_name: lavalink-server
|
|
||||||
restart: unless-stopped
|
|
||||||
# Set the working directory inside the container
|
|
||||||
working_dir: /opt/lavalink
|
|
||||||
# Mount the local Lavalink.jar and application.yml into the container
|
|
||||||
volumes:
|
|
||||||
- ./Lavalink.jar:/opt/lavalink/Lavalink.jar:ro # Mount Jar read-only
|
|
||||||
- ./application.yml:/opt/lavalink/application.yml:ro # Mount config read-only
|
|
||||||
- ./logs:/opt/lavalink/logs # Mount logs directory (optional)
|
|
||||||
# Expose the LavaLink port to the host machine (optional, but useful for debugging)
|
|
||||||
# and makes it reachable by the 'app' service via Docker network.
|
|
||||||
ports:
|
|
||||||
- "2333:2333"
|
|
||||||
# Command to run LavaLink inside the container
|
|
||||||
command: ["java", "-jar", "Lavalink.jar"]
|
|
||||||
|
|
||||||
# Define networks (optional, Docker Compose creates a default one)
|
|
||||||
# networks:
|
|
||||||
# default:
|
|
||||||
# driver: bridge
|
|
||||||
|
|
||||||
# Define volumes (optional, if you need persistent data beyond logs)
|
|
||||||
# volumes:
|
|
||||||
# lavalink_logs:
|
|
||||||
82
deploy-commands.js
Normal file
82
deploy-commands.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const { REST, Routes } = require('discord.js');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const logger = require('./src/utils/logger'); // Assuming logger is setup
|
||||||
|
require('dotenv').config(); // Load .env variables
|
||||||
|
|
||||||
|
console.log('CLIENT_ID: ', process.env.CLIENT_ID ? 'Present' : process.env.CLIENT_ID);
|
||||||
|
console.log('DISCORD_TOKEN:', process.env.DISCORD_TOKEN ? 'Present' : process.env.DISCORD_TOKEN);
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
const clientId = process.env.CLIENT_ID;
|
||||||
|
const token = process.env.DISCORD_TOKEN;
|
||||||
|
// const guildId = process.env.GUILD_ID; // Uncomment for guild-specific commands during testing
|
||||||
|
|
||||||
|
if (!clientId || !token) {
|
||||||
|
logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [];
|
||||||
|
// Grab all the command files from the commands directory you created earlier
|
||||||
|
const commandsPath = path.join(__dirname, 'src', 'commands');
|
||||||
|
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
|
||||||
|
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = path.join(commandsPath, file);
|
||||||
|
try {
|
||||||
|
const command = require(filePath);
|
||||||
|
if ('data' in command && 'execute' in command) {
|
||||||
|
commands.push(command.data.toJSON());
|
||||||
|
logger.info(`Loaded command: ${command.data.name}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error loading command at ${filePath} for deployment: ${error.message}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct and prepare an instance of the REST module
|
||||||
|
const rest = new REST({ version: '10' }).setToken(token);
|
||||||
|
|
||||||
|
// and deploy your commands!
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
logger.info(`Started wiping all global and guild application (/) commands.`);
|
||||||
|
|
||||||
|
// 1. Wipe Global Commands
|
||||||
|
await rest.put(
|
||||||
|
Routes.applicationCommands(clientId),
|
||||||
|
{ body: [] }
|
||||||
|
);
|
||||||
|
logger.info('Successfully wiped all global application commands.');
|
||||||
|
|
||||||
|
// 2. Wipe Guild Commands (optional but recommended for dev/testing guilds)
|
||||||
|
const guildId = process.env.GUILD_ID; // Make sure this is set
|
||||||
|
if (guildId) {
|
||||||
|
await rest.put(
|
||||||
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
|
{ body: [] }
|
||||||
|
);
|
||||||
|
logger.info(`Successfully wiped all application commands in guild ${guildId}.`);
|
||||||
|
} else {
|
||||||
|
logger.warn('GUILD_ID not set; skipping guild command wipe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Register New Global Commands
|
||||||
|
logger.info(`Registering ${commands.length} new global commands...`);
|
||||||
|
const data = await rest.put(
|
||||||
|
Routes.applicationCommands(clientId),
|
||||||
|
{ body: commands },
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Successfully registered ${data.length} new global commands.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed during command reset and deployment:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
services:
|
||||||
|
lavalink:
|
||||||
|
image: fredboat/lavalink:latest
|
||||||
|
container_name: lavalink
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- bot-network
|
||||||
|
ports:
|
||||||
|
- "2333:2333"
|
||||||
|
environment:
|
||||||
|
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
|
||||||
|
# Removed LAVALINK_PLUGIN_URLS environment variable
|
||||||
|
volumes:
|
||||||
|
- ./application.yml:/opt/Lavalink/application.yml:ro,Z
|
||||||
|
# Mount local plugins directory into the container with SELinux label
|
||||||
|
- ./plugins:/plugins:ro,Z
|
||||||
|
# Add healthcheck to verify Lavalink is ready
|
||||||
|
healthcheck:
|
||||||
|
# Use CMD-SHELL to allow environment variable expansion for the password
|
||||||
|
test: ["CMD-SHELL", "curl -H \"Authorization: $$LAVALINK_SERVER_PASSWORD\" -f http://localhost:2333/version || exit 1"]
|
||||||
|
interval: 10s # Increased interval slightly
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s # Give Lavalink time to start up initially
|
||||||
|
# Removed command override, will use default image entrypoint
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
container_name: discord-music-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- bot-network
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
LAVALINK_HOST: lavalink
|
||||||
|
LAVALINK_PORT: 2333
|
||||||
|
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD}
|
||||||
|
# Update depends_on to wait for healthcheck
|
||||||
|
depends_on:
|
||||||
|
lavalink:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bot-network:
|
||||||
|
driver: bridge
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-music-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"shoukaku": "^4.1.1",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
# rustfmt.toml
|
|
||||||
hard_tabs = false
|
|
||||||
tab_spaces = 2
|
|
||||||
88
src/commands/join.js
Normal file
88
src/commands/join.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } = require('discord.js'); // Import MessageFlags
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('join')
|
||||||
|
.setDescription('Joins your current voice channel'),
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
// Use flags for ephemeral deferral
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const member = interaction.member;
|
||||||
|
const voiceChannel = member?.voice?.channel;
|
||||||
|
|
||||||
|
// 1. Check if user is in a voice channel
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return interaction.editReply('You need to be in a voice channel to use this command!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check bot permissions
|
||||||
|
const permissions = voiceChannel.permissionsFor(client.user);
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Connect)) {
|
||||||
|
return interaction.editReply('I need permission to **connect** to your voice channel!');
|
||||||
|
}
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Speak)) {
|
||||||
|
return interaction.editReply('I need permission to **speak** in your voice channel!');
|
||||||
|
}
|
||||||
|
// Ensure it's a voice channel (not stage, etc.) although erela might handle this
|
||||||
|
if (voiceChannel.type !== ChannelType.GuildVoice) {
|
||||||
|
return interaction.editReply('I can only join standard voice channels.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the initialized Shoukaku player manager from the client object
|
||||||
|
const musicPlayer = interaction.client.player;
|
||||||
|
if (!musicPlayer) {
|
||||||
|
logger.error('Music player not initialized on client object!');
|
||||||
|
return interaction.editReply('The music player is not ready yet. Please try again shortly.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get or create the player and connect using Shoukaku
|
||||||
|
let player = musicPlayer.getPlayer(interaction.guildId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
try {
|
||||||
|
// Create player using the Shoukaku manager
|
||||||
|
player = await musicPlayer.createPlayer({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
textChannel: interaction.channelId,
|
||||||
|
voiceChannel: voiceChannel.id
|
||||||
|
});
|
||||||
|
// Connection is handled within createPlayer
|
||||||
|
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`);
|
||||||
|
await interaction.editReply(`Joined ${voiceChannel.name}! Ready to play music.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
// Player destruction is handled internally if creation fails or via destroy method
|
||||||
|
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If player exists but is in a different channel
|
||||||
|
if (player.voiceChannel !== voiceChannel.id) {
|
||||||
|
// Destroy the old player and create a new one in the correct channel
|
||||||
|
player.destroy();
|
||||||
|
try {
|
||||||
|
player = await musicPlayer.createPlayer({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
textChannel: interaction.channelId,
|
||||||
|
voiceChannel: voiceChannel.id
|
||||||
|
});
|
||||||
|
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guildId}`);
|
||||||
|
await interaction.editReply(`Moved to ${voiceChannel.name}!`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to move player for guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
return interaction.editReply('An error occurred while trying to move to the voice channel.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Already in the correct channel
|
||||||
|
await interaction.editReply(`I'm already in ${voiceChannel.name}!`);
|
||||||
|
}
|
||||||
|
// Update text channel if needed (Shoukaku player object stores textChannel)
|
||||||
|
if (player.textChannel !== interaction.channelId) {
|
||||||
|
player.textChannel = interaction.channelId; // Directly update the property
|
||||||
|
logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
// src/commands/join.rs
|
|
||||||
|
|
||||||
use super::{CommandHandler, SlashCommand}; // Import from parent mod.rs
|
|
||||||
use crate::utils::get_voice_state; // Import utility
|
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use serenity::{
|
|
||||||
builder::{CreateApplicationCommandOption, EditInteractionResponse}, // Need EditInteractionResponse
|
|
||||||
client::Context,
|
|
||||||
model::prelude::{
|
|
||||||
interaction::application_command::ApplicationCommandInteraction,
|
|
||||||
Id // Import Id directly if needed, though GuildId/ChannelId are often used
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
|
||||||
use futures::future::BoxFuture; // Import BoxFuture
|
|
||||||
|
|
||||||
// Command metadata and registration
|
|
||||||
inventory::submit! {
|
|
||||||
SlashCommand {
|
|
||||||
name: "join",
|
|
||||||
description: "Makes the bot join your current voice channel.",
|
|
||||||
options: || Vec::new(), // No options for /join
|
|
||||||
handler: handle_join,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The actual logic for the /join command
|
|
||||||
#[instrument(skip(ctx, interaction, lavalink))]
|
|
||||||
fn handle_join<'a>(
|
|
||||||
ctx: &'a Context,
|
|
||||||
interaction: &'a ApplicationCommandInteraction,
|
|
||||||
lavalink: &'a LavalinkClient,
|
|
||||||
) -> BoxFuture<'a, Result<()>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
|
||||||
let user_id = interaction.user.id;
|
|
||||||
|
|
||||||
// Find the user's voice channel using the utility function
|
|
||||||
let voice_state = get_voice_state(ctx, guild_id, user_id).await
|
|
||||||
.context("Failed to get your voice state. Are you in a channel?")?;
|
|
||||||
|
|
||||||
let channel_id = voice_state.channel_id
|
|
||||||
.context("You are not currently in a voice channel.")?;
|
|
||||||
|
|
||||||
// Request Lavalink create a session (actual connection happens via gateway events)
|
|
||||||
lavalink.create_session(guild_id, channel_id).await?;
|
|
||||||
|
|
||||||
// Edit the deferred response to confirm action
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new()
|
|
||||||
.content(format!("Joining <#{}>...", channel_id))
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
48
src/commands/leave.js
Normal file
48
src/commands/leave.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const { SlashCommandBuilder, MessageFlags } = require('discord.js'); // Import MessageFlags
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('leave')
|
||||||
|
.setDescription('Leaves the current voice channel'),
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
// Use flags for ephemeral deferral
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
// Get the Shoukaku player manager
|
||||||
|
const musicPlayer = interaction.client.player;
|
||||||
|
if (!musicPlayer) {
|
||||||
|
logger.error('Music player not initialized on client object!');
|
||||||
|
return interaction.editReply('The music player is not ready yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the player for this guild using Shoukaku manager
|
||||||
|
const player = musicPlayer.getPlayer(interaction.guildId);
|
||||||
|
|
||||||
|
// Check if the player exists (Shoukaku player object has voiceChannel property)
|
||||||
|
if (!player || !player.voiceChannel) {
|
||||||
|
return interaction.editReply('I am not currently in a voice channel!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check if the user is in the same channel as the bot
|
||||||
|
// const memberVoiceChannel = interaction.member?.voice?.channelId;
|
||||||
|
// if (memberVoiceChannel !== player.voiceChannel) {
|
||||||
|
// return interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelId = player.voiceChannel; // Get channel ID from Shoukaku player
|
||||||
|
const channel = client.channels.cache.get(channelId);
|
||||||
|
const channelName = channel ? channel.name : `ID: ${channelId}`; // Get channel name if possible
|
||||||
|
|
||||||
|
player.destroy(); // Use Shoukaku player's destroy method
|
||||||
|
logger.info(`Player destroyed and left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`);
|
||||||
|
await interaction.editReply(`Left ${channelName}.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error destroying player for guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
// Attempt to reply even if destroy failed partially
|
||||||
|
await interaction.editReply('An error occurred while trying to leave the voice channel.').catch(e => logger.error(`Failed to send error reply for leave command: ${e.message}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// src/commands/leave.rs
|
|
||||||
|
|
||||||
use super::{CommandHandler, SlashCommand};
|
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use serenity::{
|
|
||||||
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
|
||||||
client::Context,
|
|
||||||
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
|
||||||
use futures::future::BoxFuture;
|
|
||||||
|
|
||||||
inventory::submit! {
|
|
||||||
SlashCommand {
|
|
||||||
name: "leave",
|
|
||||||
description: "Makes the bot leave the current voice channel.",
|
|
||||||
options: || Vec::new(), // No options
|
|
||||||
handler: handle_leave,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(ctx, interaction, lavalink))]
|
|
||||||
fn handle_leave<'a>(
|
|
||||||
ctx: &'a Context,
|
|
||||||
interaction: &'a ApplicationCommandInteraction,
|
|
||||||
lavalink: &'a LavalinkClient,
|
|
||||||
) -> BoxFuture<'a, Result<()>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
|
||||||
|
|
||||||
// Check if connected before trying to leave
|
|
||||||
if lavalink.get_player(guild_id).is_none() {
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content("Bot is not in a voice channel.")
|
|
||||||
).await?;
|
|
||||||
return Ok(()); // Not an error, just inform user
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy the Lavalink session for this guild
|
|
||||||
lavalink.destroy_session(guild_id).await?;
|
|
||||||
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content("Left the voice channel.")
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
15
src/commands/ping.js
Normal file
15
src/commands/ping.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('ping')
|
||||||
|
.setDescription('Replies with Pong!'),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Calculate latency (optional but common for ping commands)
|
||||||
|
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true, ephemeral: true });
|
||||||
|
const latency = sent.createdTimestamp - interaction.createdTimestamp;
|
||||||
|
const wsPing = interaction.client.ws.ping; // WebSocket heartbeat ping
|
||||||
|
|
||||||
|
await interaction.editReply(`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`);
|
||||||
|
},
|
||||||
|
};
|
||||||
170
src/commands/play.js
Normal file
170
src/commands/play.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
// Removed direct import of musicPlayer
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('play')
|
||||||
|
.setDescription('Plays audio from a URL or search query')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('query')
|
||||||
|
.setDescription('The URL or search term for the song/playlist')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('source')
|
||||||
|
.setDescription('Specify the search source (defaults to YouTube Music)')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'YouTube Music', value: 'youtubemusic' },
|
||||||
|
{ name: 'YouTube', value: 'youtube' },
|
||||||
|
{ name: 'SoundCloud', value: 'soundcloud' }
|
||||||
|
)),
|
||||||
|
async execute(interaction, client) {
|
||||||
|
await interaction.deferReply(); // Defer reply immediately
|
||||||
|
|
||||||
|
const member = interaction.member;
|
||||||
|
const voiceChannel = member?.voice?.channel;
|
||||||
|
const query = interaction.options.getString('query');
|
||||||
|
const source = interaction.options.getString('source'); // Get the source option
|
||||||
|
|
||||||
|
// 1. Check if user is in a voice channel
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return interaction.editReply('You need to be in a voice channel to play music!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check bot permissions
|
||||||
|
const permissions = voiceChannel.permissionsFor(client.user);
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Connect)) {
|
||||||
|
return interaction.editReply('I need permission to **connect** to your voice channel!');
|
||||||
|
}
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Speak)) {
|
||||||
|
return interaction.editReply('I need permission to **speak** in your voice channel!');
|
||||||
|
}
|
||||||
|
if (voiceChannel.type !== ChannelType.GuildVoice) {
|
||||||
|
return interaction.editReply('I can only join standard voice channels.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the initialized player from the client object
|
||||||
|
const musicPlayer = interaction.client.player;
|
||||||
|
if (!musicPlayer) {
|
||||||
|
logger.error('Music player not initialized on client object!');
|
||||||
|
return interaction.editReply('The music player is not ready yet. Please try again shortly.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get or create player
|
||||||
|
let player = musicPlayer.getPlayer(interaction.guildId);
|
||||||
|
if (!player) {
|
||||||
|
try {
|
||||||
|
player = await musicPlayer.createPlayer({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
textChannel: interaction.channelId, // Use interaction.channelId directly
|
||||||
|
voiceChannel: voiceChannel.id // Use voiceChannel.id directly
|
||||||
|
});
|
||||||
|
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`);
|
||||||
|
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
||||||
|
}
|
||||||
|
} else if (player.voiceChannel !== voiceChannel.id) {
|
||||||
|
// If player exists but in a different voice channel, destroy it and create a new one
|
||||||
|
player.destroy();
|
||||||
|
player = await musicPlayer.createPlayer({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
textChannel: interaction.channelId,
|
||||||
|
voiceChannel: voiceChannel.id
|
||||||
|
});
|
||||||
|
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Determine search identifier based on query and source
|
||||||
|
let identifier;
|
||||||
|
const isUrl = query.startsWith('http://') || query.startsWith('https://');
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
identifier = query; // Use URL directly
|
||||||
|
} else {
|
||||||
|
// Prepend search prefix based on source or default
|
||||||
|
switch (source) {
|
||||||
|
case 'youtube':
|
||||||
|
identifier = `ytsearch:${query}`;
|
||||||
|
break;
|
||||||
|
case 'soundcloud':
|
||||||
|
identifier = `scsearch:${query}`;
|
||||||
|
break;
|
||||||
|
case 'youtubemusic':
|
||||||
|
default: // Default to YouTube Music if source is 'youtubemusic' or not provided
|
||||||
|
identifier = `ytmsearch:${query}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(`Constructed identifier: ${identifier}`);
|
||||||
|
|
||||||
|
// 5. Search for tracks using the constructed identifier
|
||||||
|
const searchResults = await musicPlayer.search({ // Use the player instance from the client
|
||||||
|
identifier: identifier, // Pass the constructed identifier
|
||||||
|
requester: interaction.user
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!searchResults || searchResults.length === 0) {
|
||||||
|
await interaction.editReply(`No results found for "${query}".`);
|
||||||
|
if (!player.playing && player.queue.length === 0) {
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Add track(s) to queue and create response embed
|
||||||
|
const responseEmbed = new EmbedBuilder().setColor('#0099ff');
|
||||||
|
|
||||||
|
// Add first track (or all tracks if it's a playlist)
|
||||||
|
const firstTrack = searchResults[0];
|
||||||
|
|
||||||
|
// Detect if it's a playlist based on number of tracks
|
||||||
|
const isPlaylist = searchResults.length > 1 &&
|
||||||
|
searchResults[0].info.uri.includes('playlist');
|
||||||
|
|
||||||
|
if (isPlaylist) {
|
||||||
|
// Add all tracks to the queue
|
||||||
|
for (const track of searchResults) {
|
||||||
|
await player.enqueue(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up playlist embed
|
||||||
|
responseEmbed
|
||||||
|
.setTitle('Playlist Added to Queue')
|
||||||
|
.setDescription(`**Playlist** (${searchResults.length} tracks)`)
|
||||||
|
.addFields({ name: 'Starting track', value: `[${firstTrack.info.title}](${firstTrack.info.uri})` });
|
||||||
|
|
||||||
|
logger.info(`Added playlist with ${searchResults.length} tracks to queue (Guild: ${interaction.guildId})`);
|
||||||
|
} else {
|
||||||
|
// Add single track to queue
|
||||||
|
await player.enqueue(firstTrack);
|
||||||
|
|
||||||
|
// Set up track embed
|
||||||
|
responseEmbed
|
||||||
|
.setTitle('Track Added to Queue')
|
||||||
|
.setDescription(`[${firstTrack.info.title}](${firstTrack.info.uri})`)
|
||||||
|
.addFields({ name: 'Position in queue', value: `${player.queue.length}`, inline: true });
|
||||||
|
|
||||||
|
// Add thumbnail if available
|
||||||
|
if (firstTrack.info.thumbnail) {
|
||||||
|
responseEmbed.setThumbnail(firstTrack.info.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Added track to queue: ${firstTrack.info.title} (Guild: ${interaction.guildId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
await interaction.editReply({ embeds: [responseEmbed] });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`);
|
||||||
|
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e =>
|
||||||
|
logger.error(`Failed to send error reply for play command: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Executed command 'play' for user ${interaction.user.tag}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
// src/commands/play.rs
|
|
||||||
|
|
||||||
use super::{CommandHandler, SlashCommand};
|
|
||||||
use crate::utils::get_voice_state; // Needed if join logic is integrated
|
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use lavalink_rs::{
|
|
||||||
model::search::{SearchResult, SearchType},
|
|
||||||
LavalinkClient
|
|
||||||
};
|
|
||||||
use serenity::{
|
|
||||||
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
|
||||||
client::Context,
|
|
||||||
model::prelude::{
|
|
||||||
interaction::application_command::{
|
|
||||||
ApplicationCommandInteraction, CommandDataOptionValue,
|
|
||||||
ApplicationCommandOptionType,
|
|
||||||
},
|
|
||||||
Id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tracing::{info, instrument};
|
|
||||||
use url::Url;
|
|
||||||
use futures::future::BoxFuture;
|
|
||||||
use tokio::time::Duration; // Needed for sleep if join logic integrated
|
|
||||||
|
|
||||||
// Command metadata and registration
|
|
||||||
inventory::submit! {
|
|
||||||
SlashCommand {
|
|
||||||
name: "play",
|
|
||||||
description: "Plays a song from a URL or search query.",
|
|
||||||
options: play_options, // Function pointer to build options
|
|
||||||
handler: handle_play,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to define options for the /play command
|
|
||||||
fn play_options() -> Vec<CreateApplicationCommandOption> {
|
|
||||||
vec![
|
|
||||||
CreateApplicationCommandOption::new(
|
|
||||||
ApplicationCommandOptionType::String,
|
|
||||||
"query",
|
|
||||||
"The URL or search query for the song."
|
|
||||||
)
|
|
||||||
.required(true)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Helper to join channel if not already in one (avoids duplicating join logic)
|
|
||||||
// Note: This is slightly simplified, might need more robust error handling from join
|
|
||||||
async fn ensure_connected(
|
|
||||||
ctx: &Context,
|
|
||||||
interaction: &ApplicationCommandInteraction,
|
|
||||||
guild_id: serenity::model::id::GuildId,
|
|
||||||
lavalink: &LavalinkClient,
|
|
||||||
) -> Result<()> {
|
|
||||||
if lavalink.get_player(guild_id).is_some() {
|
|
||||||
return Ok(()); // Already connected
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Play command used but bot not connected. Attempting auto-join.");
|
|
||||||
let user_id = interaction.user.id;
|
|
||||||
let voice_state = get_voice_state(ctx, guild_id, user_id).await
|
|
||||||
.context("Bot not connected & failed to get your voice state. Are you in a channel?")?;
|
|
||||||
let channel_id = voice_state.channel_id
|
|
||||||
.context("Bot not connected & you're not in a voice channel. Use /join first.")?;
|
|
||||||
|
|
||||||
lavalink.create_session(guild_id, channel_id).await?;
|
|
||||||
// Give a moment for connection events to process
|
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
||||||
|
|
||||||
// Check if connection succeeded
|
|
||||||
lavalink.get_player(guild_id)
|
|
||||||
.context("Failed to establish voice connection after attempting to join.")?;
|
|
||||||
|
|
||||||
info!("Auto-join successful for play command.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// The actual logic for the /play command
|
|
||||||
#[instrument(skip(ctx, interaction, lavalink))]
|
|
||||||
fn handle_play<'a>(
|
|
||||||
ctx: &'a Context,
|
|
||||||
interaction: &'a ApplicationCommandInteraction,
|
|
||||||
lavalink: &'a LavalinkClient,
|
|
||||||
) -> BoxFuture<'a, Result<()>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
|
||||||
|
|
||||||
// Ensure the bot is connected, joining if necessary
|
|
||||||
ensure_connected(ctx, interaction, guild_id, lavalink).await?;
|
|
||||||
|
|
||||||
// Get the query string from the interaction options
|
|
||||||
let query = interaction.data.options.iter()
|
|
||||||
.find(|opt| opt.name == "query")
|
|
||||||
.and_then(|opt| opt.value.as_ref())
|
|
||||||
.and_then(|v| v.as_str()) // Use as_str for simplicity
|
|
||||||
.context("Missing 'query' option")?;
|
|
||||||
|
|
||||||
info!("Searching for: {}", query);
|
|
||||||
|
|
||||||
// Determine search type
|
|
||||||
let search_type = if Url::parse(query).is_ok() {
|
|
||||||
SearchType::Track(query.to_string())
|
|
||||||
} else {
|
|
||||||
SearchType::YouTube(query.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Perform the search
|
|
||||||
let search_result = lavalink.search_tracks(search_type).await?;
|
|
||||||
|
|
||||||
// Process results (simplified from previous version for brevity)
|
|
||||||
let (tracks_to_play, response_prefix) = match search_result {
|
|
||||||
SearchResult::Track(track) => {
|
|
||||||
info!("Found track: {}", track.info.title);
|
|
||||||
(vec![track], "Now playing:".to_string())
|
|
||||||
}
|
|
||||||
SearchResult::Playlist(playlist) => {
|
|
||||||
info!("Found playlist: {} ({} tracks)", playlist.info.name, playlist.tracks.len());
|
|
||||||
(playlist.tracks, format!("Queued playlist **{}**: Playing first track:", playlist.info.name))
|
|
||||||
}
|
|
||||||
SearchResult::Search(tracks) => {
|
|
||||||
if let Some(track) = tracks.first() {
|
|
||||||
info!("Found search result: {}", track.info.title);
|
|
||||||
(vec![track.clone()], "Now playing:".to_string())
|
|
||||||
} else { (vec![], String::new()) } // No tracks found
|
|
||||||
}
|
|
||||||
SearchResult::Empty => (vec![], String::new()), // No tracks found
|
|
||||||
SearchResult::LoadFailed(e) => {
|
|
||||||
return Err(anyhow::anyhow!("Failed to load track: {}", e.message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if tracks_to_play.is_empty() {
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content(format!("No results found for: `{}`", query))
|
|
||||||
).await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the player (should exist after ensure_connected)
|
|
||||||
let player = lavalink.get_player(guild_id)
|
|
||||||
.context("Player disappeared unexpectedly after connecting.")?;
|
|
||||||
|
|
||||||
|
|
||||||
// Play first track, queue the rest
|
|
||||||
let first_track = tracks_to_play.first().unwrap(); // Safe due to is_empty check
|
|
||||||
player.play_track(&first_track.track).await?;
|
|
||||||
|
|
||||||
let mut final_response = format!("{} **{}** by **{}**",
|
|
||||||
response_prefix, first_track.info.title, first_track.info.author);
|
|
||||||
|
|
||||||
if tracks_to_play.len() > 1 {
|
|
||||||
for track in tracks_to_play.iter().skip(1) {
|
|
||||||
player.queue(&track.track).await?;
|
|
||||||
}
|
|
||||||
final_response.push_str(&format!("\n...and queued {} other tracks.", tracks_to_play.len() - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update interaction response
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content(final_response)
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// src/commands/skip.rs
|
|
||||||
|
|
||||||
use super::{CommandHandler, SlashCommand};
|
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use serenity::{
|
|
||||||
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
|
||||||
client::Context,
|
|
||||||
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
|
||||||
use futures::future::BoxFuture;
|
|
||||||
|
|
||||||
inventory::submit! {
|
|
||||||
SlashCommand {
|
|
||||||
name: "skip",
|
|
||||||
description: "Skips the currently playing song.",
|
|
||||||
options: || Vec::new(), // No options
|
|
||||||
handler: handle_skip,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(ctx, interaction, lavalink))]
|
|
||||||
fn handle_skip<'a>(
|
|
||||||
ctx: &'a Context,
|
|
||||||
interaction: &'a ApplicationCommandInteraction,
|
|
||||||
lavalink: &'a LavalinkClient,
|
|
||||||
) -> BoxFuture<'a, Result<()>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
|
||||||
|
|
||||||
// Get the player for the guild
|
|
||||||
let player = lavalink.get_player(guild_id)
|
|
||||||
.context("Bot is not connected to a voice channel in this guild.")?;
|
|
||||||
|
|
||||||
// Stop the current track (Lavalink plays next if available)
|
|
||||||
player.stop().await?;
|
|
||||||
|
|
||||||
interaction.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content("Skipped track.")
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
41
src/events/interactionCreate.js
Normal file
41
src/events/interactionCreate.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const { Events, InteractionType } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.InteractionCreate,
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
// Handle only slash commands (ChatInputCommand) for now
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
logger.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
|
try {
|
||||||
|
await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true });
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.error(`Failed to send 'command not found' reply: ${replyError.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the command's logic
|
||||||
|
await command.execute(interaction, client); // Pass client to command execute
|
||||||
|
logger.info(`Executed command '${interaction.commandName}' for user ${interaction.user.tag}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error executing command '${interaction.commandName}': ${error.message}`, error);
|
||||||
|
// Try to reply to the interaction, otherwise edit the deferred reply if applicable
|
||||||
|
const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true };
|
||||||
|
try {
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp(replyOptions);
|
||||||
|
} else {
|
||||||
|
await interaction.reply(replyOptions);
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.error(`Failed to send error reply for command '${interaction.commandName}': ${replyError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
23
src/events/ready.js
Normal file
23
src/events/ready.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { Events, ActivityType } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { setupPlayer } = require('../structures/ShoukakuEvents'); // Import the Shoukaku player
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.ClientReady,
|
||||||
|
once: true, // This event should only run once
|
||||||
|
async execute(client) {
|
||||||
|
logger.info(`Ready! Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
// Initialize the Shoukaku music player
|
||||||
|
try {
|
||||||
|
// Set up the music player with the client
|
||||||
|
client.player = setupPlayer(client);
|
||||||
|
logger.info('Shoukaku music player initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize Shoukaku music player: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set activity status
|
||||||
|
client.user.setActivity('Music | /play', { type: ActivityType.Listening });
|
||||||
|
},
|
||||||
|
};
|
||||||
52
src/events/voiceStateUpdate.js
Normal file
52
src/events/voiceStateUpdate.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const { Events } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.VoiceStateUpdate,
|
||||||
|
execute(oldState, newState, client) { // Added client parameter
|
||||||
|
// Shoukaku handles voice state updates internally via its connector.
|
||||||
|
// We don't need to manually pass the update like with Erela.js.
|
||||||
|
// The warning about Erela.js manager not being initialized can be ignored/removed.
|
||||||
|
|
||||||
|
// Custom logic for player cleanup based on voice state changes.
|
||||||
|
const musicPlayer = client.player;
|
||||||
|
if (!musicPlayer) {
|
||||||
|
// Player manager might not be ready yet, especially during startup.
|
||||||
|
// logger.debug('Voice state update received, but Shoukaku player manager is not ready yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = musicPlayer.getPlayer(newState.guild.id);
|
||||||
|
if (!player) return; // No active player for this guild
|
||||||
|
|
||||||
|
// Check if the bot was disconnected (newState has no channelId for the bot)
|
||||||
|
if (newState.id === client.user.id && !newState.channelId && oldState.channelId === player.voiceChannel) {
|
||||||
|
logger.info(`Bot was disconnected from voice channel ${oldState.channel?.name || oldState.channelId} in guild ${newState.guild.id}. Destroying player.`);
|
||||||
|
player.destroy(); // Use Shoukaku player's destroy method
|
||||||
|
return; // Exit early as the player is destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the bot's channel is now empty (excluding the bot itself)
|
||||||
|
const channel = client.channels.cache.get(player.voiceChannel);
|
||||||
|
// Ensure the channel exists and the update is relevant to the bot's channel
|
||||||
|
if (channel && (newState.channelId === player.voiceChannel || oldState.channelId === player.voiceChannel)) {
|
||||||
|
// Fetch members again to ensure freshness after the update
|
||||||
|
const members = channel.members;
|
||||||
|
if (members.size === 1 && members.has(client.user.id)) {
|
||||||
|
logger.info(`Voice channel ${channel.name} (${player.voiceChannel}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`);
|
||||||
|
// Optional: Add a timeout before destroying
|
||||||
|
// setTimeout(() => {
|
||||||
|
// const currentChannel = client.channels.cache.get(player.voiceChannel);
|
||||||
|
// const currentMembers = currentChannel?.members;
|
||||||
|
// if (currentMembers && currentMembers.size === 1 && currentMembers.has(client.user.id)) {
|
||||||
|
// logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`);
|
||||||
|
// player.destroy();
|
||||||
|
// } else {
|
||||||
|
// logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`);
|
||||||
|
// }
|
||||||
|
// }, 60000); // e.g., 1 minute timeout
|
||||||
|
player.destroy(); // Destroy immediately for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
199
src/handler.rs
199
src/handler.rs
@@ -1,199 +0,0 @@
|
|||||||
// src/handler.rs
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
state::BotState,
|
|
||||||
commands::SlashCommand, // Import the command definition struct
|
|
||||||
utils::get_voice_state,
|
|
||||||
};
|
|
||||||
use serenity::{
|
|
||||||
async_trait,
|
|
||||||
builder::{
|
|
||||||
CreateApplicationCommand, // Need this for registration
|
|
||||||
CreateInteractionResponse, CreateInteractionResponseData,
|
|
||||||
EditInteractionResponse
|
|
||||||
},
|
|
||||||
client::{Context, EventHandler},
|
|
||||||
gateway::ActivityData,
|
|
||||||
model::{
|
|
||||||
gateway::{Ready, Activity},
|
|
||||||
id::{GuildId, ChannelId},
|
|
||||||
prelude::interaction::{Interaction, application_command::ApplicationCommandInteraction},
|
|
||||||
voice::VoiceState,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::time::Duration;
|
|
||||||
use tracing::{error, info, instrument, warn};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
// --- Event Handler ---
|
|
||||||
pub struct Handler {
|
|
||||||
pub state: Arc<Mutex<BotState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods (check_and_handle_auto_leave remains the same as previous version)
|
|
||||||
impl Handler {
|
|
||||||
#[instrument(skip(self, ctx, lavalink))]
|
|
||||||
async fn check_and_handle_auto_leave(
|
|
||||||
&self,
|
|
||||||
ctx: &Context,
|
|
||||||
guild_id: GuildId,
|
|
||||||
bot_channel_id: ChannelId,
|
|
||||||
lavalink: &LavalinkClient,
|
|
||||||
) {
|
|
||||||
let members_in_channel = ctx.cache.guild(guild_id)
|
|
||||||
.await
|
|
||||||
.map(|guild| {
|
|
||||||
guild.voice_states.iter()
|
|
||||||
.filter(|(_, vs)| vs.channel_id == Some(bot_channel_id))
|
|
||||||
.count()
|
|
||||||
});
|
|
||||||
|
|
||||||
let Some(1) = members_in_channel else { return; };
|
|
||||||
|
|
||||||
info!("Bot potentially alone in channel {}, waiting...", bot_channel_id);
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
|
|
||||||
let Some(player_after_delay) = lavalink.get_player(guild_id) else { return; };
|
|
||||||
if player_after_delay.connected_channel_id != Some(bot_channel_id) { return; };
|
|
||||||
|
|
||||||
let members_after_delay = ctx.cache.guild(guild_id)
|
|
||||||
.await
|
|
||||||
.map(|guild| {
|
|
||||||
guild.voice_states.iter()
|
|
||||||
.filter(|(_, vs)| vs.channel_id == Some(bot_channel_id))
|
|
||||||
.count()
|
|
||||||
});
|
|
||||||
|
|
||||||
let Some(1) = members_after_delay else { return; };
|
|
||||||
|
|
||||||
info!("Bot confirmed alone in channel {}, leaving.", bot_channel_id);
|
|
||||||
if let Err(e) = lavalink.destroy_session(guild_id).await {
|
|
||||||
error!("Failed auto-leave {}: {}", bot_channel_id, e);
|
|
||||||
} else {
|
|
||||||
info!("Successfully auto-left {}", bot_channel_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl EventHandler for Handler {
|
|
||||||
/// Called when the bot is ready. Registers slash commands.
|
|
||||||
#[instrument(skip(self, ctx, ready))]
|
|
||||||
async fn ready(&self, ctx: Context, ready: Ready) {
|
|
||||||
info!("{} is connected!", ready.user.name);
|
|
||||||
|
|
||||||
// --- Command Registration using inventory ---
|
|
||||||
info!("Registering global slash commands...");
|
|
||||||
let command_build_results = serenity::model::application::command::Command::set_global_application_commands(
|
|
||||||
&ctx.http,
|
|
||||||
|commands_builder| {
|
|
||||||
for cmd in inventory::iter::<SlashCommand> {
|
|
||||||
commands_builder.create_application_command(|command| {
|
|
||||||
command.name(cmd.name).description(cmd.description);
|
|
||||||
// Build options using the provided function
|
|
||||||
let options = (cmd.options)();
|
|
||||||
for opt in options {
|
|
||||||
command.add_option(opt); // Use add_option
|
|
||||||
}
|
|
||||||
command // Return the command builder
|
|
||||||
});
|
|
||||||
}
|
|
||||||
commands_builder // Return the main builder
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match command_build_results {
|
|
||||||
Ok(registered_commands) => info!("Successfully registered {} global commands.", registered_commands.len()),
|
|
||||||
Err(err) => error!("Failed to register global commands: {:?}", err),
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.set_activity(ActivityData::custom("🎶 Music Time")).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Called for interactions. Dispatches commands.
|
|
||||||
#[instrument(skip(self, ctx))]
|
|
||||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
|
||||||
let Interaction::ApplicationCommand(command) = interaction else { return; };
|
|
||||||
|
|
||||||
info!("Received command: /{}", command.data.name);
|
|
||||||
|
|
||||||
if let Err(e) = command.create_response(&ctx.http,
|
|
||||||
CreateInteractionResponse::Defer(
|
|
||||||
CreateInteractionResponseData::new().ephemeral(false)
|
|
||||||
)
|
|
||||||
).await {
|
|
||||||
error!("Failed to defer interaction response: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(guild_id) = command.guild_id else {
|
|
||||||
warn!("Guild command '/{}' used outside of a guild.", command.data.name);
|
|
||||||
let _ = command.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content("This command only works in servers.")
|
|
||||||
).await;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Command Dispatch using inventory ---
|
|
||||||
let command_name = command.data.name.as_str();
|
|
||||||
let mut command_found = false;
|
|
||||||
let mut command_result: Option<anyhow::Result<()>> = None;
|
|
||||||
|
|
||||||
{ // Scope for state lock
|
|
||||||
let state_guard = self.state.lock().await;
|
|
||||||
let lavalink = &state_guard.lavalink;
|
|
||||||
|
|
||||||
for cmd_def in inventory::iter::<SlashCommand> {
|
|
||||||
if cmd_def.name == command_name {
|
|
||||||
info!("Dispatching command: {}", cmd_def.name);
|
|
||||||
command_found = true;
|
|
||||||
// Call the registered handler function
|
|
||||||
command_result = Some((cmd_def.handler)(&ctx, &command, lavalink).await);
|
|
||||||
break; // Found and handled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // state_guard lock released
|
|
||||||
|
|
||||||
// --- Handle Dispatch Result ---
|
|
||||||
if !command_found {
|
|
||||||
error!("Handler not found for received command: {}", command_name);
|
|
||||||
command_result = Some(Err(anyhow::anyhow!("Unknown command '/{}'", command_name)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the result (error logging/reporting)
|
|
||||||
if let Some(Err(e)) = command_result {
|
|
||||||
error!("Error handling command '/{}': {:?}", command_name, e);
|
|
||||||
if let Err(edit_err) = command.edit_original_response(&ctx.http,
|
|
||||||
EditInteractionResponse::new().content(format!("An error occurred: {}", e))
|
|
||||||
).await {
|
|
||||||
error!("Failed to send error response for '/{}': {}", command_name, edit_err);
|
|
||||||
}
|
|
||||||
} else if command_found {
|
|
||||||
info!("Successfully handled command '/{}'", command_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called for voice state changes. Handles Lavalink updates and auto-leave.
|
|
||||||
#[instrument(skip(self, ctx))]
|
|
||||||
async fn voice_state_update(&self, ctx: Context, _old: Option<VoiceState>, new: VoiceState) {
|
|
||||||
let state_guard = self.state.lock().await;
|
|
||||||
let lavalink = &state_guard.lavalink;
|
|
||||||
|
|
||||||
// Always forward to lavalink-rs
|
|
||||||
lavalink.handle_voice_state_update(&ctx.cache, &new).await;
|
|
||||||
|
|
||||||
// Auto-Leave Logic (simplified)
|
|
||||||
let Some(guild_id) = new.guild_id else { return; };
|
|
||||||
let Some(player) = lavalink.get_player(guild_id) else { return; };
|
|
||||||
let Some(bot_channel_id) = player.connected_channel_id else { return; };
|
|
||||||
|
|
||||||
// Drop the lock before calling the helper which might sleep
|
|
||||||
drop(state_guard);
|
|
||||||
self.check_and_handle_auto_leave(&ctx, guild_id, bot_channel_id, lavalink).await;
|
|
||||||
}
|
|
||||||
} // End impl EventHandler
|
|
||||||
110
src/index.js
Normal file
110
src/index.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Load environment variables from .env file
|
||||||
|
require('dotenv').config();
|
||||||
|
const { Client, GatewayIntentBits, Collection } = require('discord.js');
|
||||||
|
const { Shoukaku, Connectors } = require('shoukaku');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Validate essential environment variables
|
||||||
|
if (!process.env.DISCORD_TOKEN) {
|
||||||
|
logger.error('DISCORD_TOKEN is missing in the .env file!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!process.env.LAVALINK_HOST || !process.env.LAVALINK_PORT || !process.env.LAVALINK_PASSWORD) {
|
||||||
|
logger.warn('Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.');
|
||||||
|
// Decide if the bot should exit or continue without music
|
||||||
|
// process.exit(1); // Uncomment to exit if Lavalink is mandatory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new Discord client instance with necessary intents
|
||||||
|
const client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content
|
||||||
|
GatewayIntentBits.MessageContent, // Add if needed for message content
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define Shoukaku nodes - fix the URL format to properly connect to Lavalink
|
||||||
|
const Nodes = [
|
||||||
|
{
|
||||||
|
name: 'lavalink',
|
||||||
|
url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || '2333'}`,
|
||||||
|
auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass',
|
||||||
|
secure: process.env.LAVALINK_SECURE === 'true'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize Shoukaku with proper configuration
|
||||||
|
client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, {
|
||||||
|
moveOnDisconnect: false,
|
||||||
|
resume: true,
|
||||||
|
reconnectTries: 10,
|
||||||
|
reconnectInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the actual Lavalink connection details (without exposing the actual password)
|
||||||
|
logger.info(`Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT} (Password: ${process.env.LAVALINK_PASSWORD ? '[SET]' : '[NOT SET]'})`);
|
||||||
|
|
||||||
|
// Collections for commands
|
||||||
|
client.commands = new Collection();
|
||||||
|
|
||||||
|
// --- Command Loading ---
|
||||||
|
const commandsPath = path.join(__dirname, 'commands');
|
||||||
|
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = path.join(commandsPath, file);
|
||||||
|
try {
|
||||||
|
const command = require(filePath);
|
||||||
|
// Set a new item in the Collection with the key as the command name and the value as the exported module
|
||||||
|
if ('data' in command && 'execute' in command) {
|
||||||
|
client.commands.set(command.data.name, command);
|
||||||
|
logger.info(`Loaded command: ${command.data.name}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error loading command at ${filePath}: ${error.message}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handling ---
|
||||||
|
const eventsPath = path.join(__dirname, 'events');
|
||||||
|
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
for (const file of eventFiles) {
|
||||||
|
const filePath = path.join(eventsPath, file);
|
||||||
|
const event = require(filePath);
|
||||||
|
if (event.once) {
|
||||||
|
client.once(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
||||||
|
logger.info(`Loaded event ${event.name} (once)`);
|
||||||
|
} else {
|
||||||
|
client.on(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
||||||
|
logger.info(`Loaded event ${event.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shoukaku Event Handling ---
|
||||||
|
// Set up Shoukaku event handlers
|
||||||
|
client.shoukaku.on('ready', (name) => logger.info(`Lavalink Node: ${name} is now connected`));
|
||||||
|
client.shoukaku.on('error', (name, error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`));
|
||||||
|
client.shoukaku.on('close', (name, code, reason) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`));
|
||||||
|
client.shoukaku.on('disconnect', (name, reason) => logger.warn(`Lavalink Node: ${name} disconnected. Reason: ${reason || 'No reason'}`));
|
||||||
|
|
||||||
|
// Log in to Discord with your client's token
|
||||||
|
client.login(process.env.DISCORD_TOKEN)
|
||||||
|
.then(() => logger.info('Successfully logged in to Discord.'))
|
||||||
|
.catch(error => logger.error(`Failed to log in: ${error.message}`));
|
||||||
|
|
||||||
|
// Basic error handling
|
||||||
|
process.on('unhandledRejection', error => {
|
||||||
|
logger.error('Unhandled promise rejection:', error);
|
||||||
|
});
|
||||||
|
process.on('uncaughtException', error => {
|
||||||
|
logger.error('Uncaught exception:', error);
|
||||||
|
// Optional: exit process on critical uncaught exceptions
|
||||||
|
// process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// src/lavalink_handler.rs
|
|
||||||
|
|
||||||
use serenity::http::Http; // Import Http for sending messages
|
|
||||||
use lavalink_rs::gateway::{NodeEvent, LavalinkEventHandler as LavalinkEventHandlerRs};
|
|
||||||
use tracing::{error, info, instrument};
|
|
||||||
use async_trait::async_trait; // Needed to implement async trait methods
|
|
||||||
use std::sync::Arc; // Needed for Arc<Http>
|
|
||||||
|
|
||||||
|
|
||||||
// --- lavalink-rs Event Handler ---
|
|
||||||
// This handler receives events directly from the connected Lavalink server.
|
|
||||||
// It allows reacting to playback events like track start/end.
|
|
||||||
pub struct MyLavalinkEventHandlerRs {
|
|
||||||
pub http: Arc<Http>, // Hold the Http client to send messages to Discord
|
|
||||||
// Add state if needed, e.g., channel IDs for announcements per guild.
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LavalinkEventHandlerRs for MyLavalinkEventHandlerRs {
|
|
||||||
// The main handler function for all Lavalink NodeEvents.
|
|
||||||
#[instrument(skip(self))] // Adds tracing to the handler
|
|
||||||
async fn handle(&self, event: NodeEvent) {
|
|
||||||
// You can uncomment this for verbose logging of all events:
|
|
||||||
// info!("Received LavaLink event: {:?}", event);
|
|
||||||
|
|
||||||
match event {
|
|
||||||
// Handle specific event types
|
|
||||||
NodeEvent::TrackStart { guild_id, track, .. } => {
|
|
||||||
info!(
|
|
||||||
"Track started on guild {}: {}",
|
|
||||||
guild_id,
|
|
||||||
track.info.title
|
|
||||||
);
|
|
||||||
// Example: send a message announcing the track.
|
|
||||||
// This requires knowing which channel to send it to, often
|
|
||||||
// stored per guild in the BotState's announcement_channels.
|
|
||||||
// Accessing BotState would require passing it here or using a global static.
|
|
||||||
// let channel_id = { /* logic to get channel_id from guild_id using self.http or shared state */ };
|
|
||||||
// if let Some(channel_id) = channel_id {
|
|
||||||
// if let Err(e) = ChannelId(channel_id).say(&self.http,
|
|
||||||
// format!("Now playing: **{}** by **{}**", track.info.title, track.info.author)
|
|
||||||
// ).await {
|
|
||||||
// error!("Failed to send track start message: {}", e);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
NodeEvent::TrackEnd { guild_id, track, reason, .. } => {
|
|
||||||
info!(
|
|
||||||
"Track ended on guild {}: {}, reason: {:?}",
|
|
||||||
guild_id,
|
|
||||||
track.info.title,
|
|
||||||
reason
|
|
||||||
);
|
|
||||||
// Logic for track end, e.g., cleaning up state if not using Lavalink queues
|
|
||||||
}
|
|
||||||
NodeEvent::TrackException { guild_id, track, error, .. } => {
|
|
||||||
error!(
|
|
||||||
"Track exception on guild {}: {}, error: {}",
|
|
||||||
guild_id,
|
|
||||||
track.info.title,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Notify the channel about the exception
|
|
||||||
}
|
|
||||||
NodeEvent::TrackStuck { guild_id, track, threshold_ms, .. } => {
|
|
||||||
error!(
|
|
||||||
"Track stuck on guild {}: {}, threshold: {}ms",
|
|
||||||
guild_id,
|
|
||||||
track.info.title,
|
|
||||||
threshold_ms
|
|
||||||
);
|
|
||||||
// Notify the channel about the stuck track
|
|
||||||
}
|
|
||||||
NodeEvent::WebSocketOpen { node, .. } => {
|
|
||||||
info!("Lavalink WebSocket opened: {}", node);
|
|
||||||
}
|
|
||||||
NodeEvent::WebSocketClosed { node, code, reason, .. } => {
|
|
||||||
error!("Lavalink WebSocket closed: {}, Code: {:?}, Reason: {}", node, code, reason);
|
|
||||||
}
|
|
||||||
// Ignore these common events to avoid log spam
|
|
||||||
NodeEvent::Ready { .. } | NodeEvent::Stats { .. } => { /* Ignore */ }
|
|
||||||
NodeEvent::PlayerUpdate { .. } => { /* Ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
123
src/main.rs
123
src/main.rs
@@ -1,123 +0,0 @@
|
|||||||
// src/main.rs
|
|
||||||
|
|
||||||
// Declare the modules used in this crate
|
|
||||||
mod handler;
|
|
||||||
mod state;
|
|
||||||
mod commands;
|
|
||||||
mod lavalink_handler;
|
|
||||||
mod utils;
|
|
||||||
|
|
||||||
// Import necessary types from our modules
|
|
||||||
use handler::Handler;
|
|
||||||
use state::BotState;
|
|
||||||
use lavalink_handler::MyLavalinkEventHandlerRs;
|
|
||||||
|
|
||||||
// Import necessary types from external crates
|
|
||||||
use serenity::client::Client;
|
|
||||||
use serenity::prelude::GatewayIntents; // Needed to subscribe to events
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{error, info, instrument};
|
|
||||||
use std::sync::Arc; // For shared ownership
|
|
||||||
use std::env; // To read environment variables
|
|
||||||
|
|
||||||
// The entry point of the application
|
|
||||||
#[tokio::main]
|
|
||||||
#[instrument] // Adds tracing to the main function
|
|
||||||
async fn main() {
|
|
||||||
// Initialize tracing for logging output
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_max_level(tracing::Level::INFO) // Set logging level
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
dotenv::dotenv().ok();
|
|
||||||
|
|
||||||
// Get credentials from environment variables
|
|
||||||
let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set");
|
|
||||||
let lavalink_host = env::var("LAVALINK_HOST")
|
|
||||||
.expect("LAVALINK_HOST not set");
|
|
||||||
let lavalink_port = env::var("LAVALINK_PORT")
|
|
||||||
.expect("LAVALINK_PORT not set")
|
|
||||||
.parse::<u16>()
|
|
||||||
.expect("Invalid LAVALINK_PORT");
|
|
||||||
let lavalink_password = env::var("LAVALINK_PASSWORD")
|
|
||||||
.expect("LAVALINK_PASSWORD not set");
|
|
||||||
|
|
||||||
// Define the gateway intents needed for the bot
|
|
||||||
// GUILDS: To receive guild information for commands and cache
|
|
||||||
// GUILD_VOICE_STATES: Crucial for receiving voice state updates
|
|
||||||
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
|
||||||
|
|
||||||
// Build the Serenity client. Need the await here because of the async builder.
|
|
||||||
let mut serenity_client = Client::builder(&token, intents)
|
|
||||||
.await
|
|
||||||
.expect("Error creating serenity client");
|
|
||||||
|
|
||||||
// Clone the Arc<Http> client from Serenity. We need this to be able to send
|
|
||||||
// HTTP requests (like sending messages) from places that don't have the full Context,
|
|
||||||
// such as the Lavalink event handler.
|
|
||||||
let http = serenity_client.cache_and_http.http.clone();
|
|
||||||
|
|
||||||
// Get the bot's user ID from the cache. This is needed to initialize the Lavalink client.
|
|
||||||
let bot_user_id = serenity_client
|
|
||||||
.cache_and_http
|
|
||||||
.cache
|
|
||||||
.current_user_id()
|
|
||||||
.await
|
|
||||||
.expect("Failed to get bot user ID from cache");
|
|
||||||
|
|
||||||
// Create the Lavalink client builder
|
|
||||||
let mut lavalink_client_builder = LavalinkClient::builder(bot_user_id);
|
|
||||||
|
|
||||||
// Set Lavalink node details
|
|
||||||
lavalink_client_builder
|
|
||||||
.set_host(lavalink_host)
|
|
||||||
.set_port(lavalink_port)
|
|
||||||
.set_password(lavalink_password);
|
|
||||||
|
|
||||||
// Create and set the custom Lavalink event handler, passing the http client
|
|
||||||
let lavalink_event_handler = Arc::new(MyLavalinkEventHandlerRs {
|
|
||||||
http: http.clone() // Clone Http again for the lavalink event handler
|
|
||||||
});
|
|
||||||
lavalink_client_builder.set_event_handler(lavalink_event_handler);
|
|
||||||
|
|
||||||
// Build the Lavalink client
|
|
||||||
let lavalink_client = lavalink_client_builder.build();
|
|
||||||
|
|
||||||
// Create and store the shared state (BotState)
|
|
||||||
let bot_state = Arc::new(Mutex::new(BotState {
|
|
||||||
lavalink: lavalink_client.clone(), // Clone lavalink client for the handler struct
|
|
||||||
http: http, // Store the Http client clone in the state
|
|
||||||
// announcement_channels: Mutex::new(HashMap::new()), // Optional: for announcements
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set the Serenity event handler, passing the shared state
|
|
||||||
serenity_client.event_handler(Handler { state: bot_state });
|
|
||||||
|
|
||||||
// Spawn the lavalink-rs client's main run task. This task will manage the
|
|
||||||
// WebSocket connection to the Lavalink server and process its events.
|
|
||||||
let lavalink_task = tokio::spawn(async move {
|
|
||||||
info!("Starting lavalink-rs client task...");
|
|
||||||
if let Err(why) = lavalink_client.run().await {
|
|
||||||
error!("Lavalink client task error: {:?}", why);
|
|
||||||
}
|
|
||||||
info!("Lavalink client task stopped.");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Start the Serenity client gateway. This connects the bot to Discord
|
|
||||||
// and begins receiving events. This call is blocking until the client stops.
|
|
||||||
info!("Starting serenity client gateway...");
|
|
||||||
if let Err(why) = serenity_client.start().await {
|
|
||||||
error!("Serenity client gateway error: {:?}", why);
|
|
||||||
}
|
|
||||||
info!("Serenity client gateway stopped.");
|
|
||||||
|
|
||||||
|
|
||||||
// Wait for the lavalink task to finish.
|
|
||||||
// In a real bot, this task should ideally never finish unless there's a critical error.
|
|
||||||
let _ = lavalink_task.await;
|
|
||||||
|
|
||||||
info!("Bot shut down.");
|
|
||||||
}
|
|
||||||
41
src/mod.rs
41
src/mod.rs
@@ -1,41 +0,0 @@
|
|||||||
// src/commands/mod.rs
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use serenity::{
|
|
||||||
builder::CreateApplicationCommandOption, // Correct import path
|
|
||||||
client::Context,
|
|
||||||
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
|
||||||
};
|
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use futures::future::BoxFuture; // Use BoxFuture for convenience
|
|
||||||
|
|
||||||
// --- Command Definition ---
|
|
||||||
|
|
||||||
// Type alias for the async command handler function signature
|
|
||||||
pub type CommandHandler = for<'a> fn(
|
|
||||||
&'a Context,
|
|
||||||
&'a ApplicationCommandInteraction,
|
|
||||||
&'a LavalinkClient,
|
|
||||||
) -> BoxFuture<'a, Result<()>>;
|
|
||||||
|
|
||||||
// Structure to hold all information needed to register and execute a command
|
|
||||||
pub struct SlashCommand {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub description: &'static str,
|
|
||||||
// Function to build command options (lazily evaluated)
|
|
||||||
pub options: fn() -> Vec<CreateApplicationCommandOption>,
|
|
||||||
// The async handler function
|
|
||||||
pub handler: CommandHandler,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell `inventory` to collect all `SlashCommand` structs submitted globally.
|
|
||||||
inventory::collect!(SlashCommand);
|
|
||||||
|
|
||||||
// --- Submodule Declarations ---
|
|
||||||
// Declare all files in the commands/ directory as submodules
|
|
||||||
pub mod join;
|
|
||||||
pub mod leave;
|
|
||||||
pub mod play;
|
|
||||||
pub mod skip;
|
|
||||||
17
src/state.rs
17
src/state.rs
@@ -1,17 +0,0 @@
|
|||||||
// src/state.rs
|
|
||||||
|
|
||||||
use lavalink_rs::LavalinkClient;
|
|
||||||
use serenity::{http::Http, model::id::{GuildId, ChannelId}};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use std::{sync::Arc, collections::HashMap};
|
|
||||||
|
|
||||||
// BotState holds shared data accessible by different parts of the bot,
|
|
||||||
// like the Lavalink client and the Serenity HTTP client.
|
|
||||||
pub struct BotState {
|
|
||||||
pub lavalink: LavalinkClient,
|
|
||||||
// Need Http client to send messages from areas that don't have Context,
|
|
||||||
// like the Lavalink event handler.
|
|
||||||
pub http: Arc<Http>,
|
|
||||||
// Optional: Store announcement channels per guild for track announcements.
|
|
||||||
// pub announcement_channels: Mutex<HashMap<GuildId, ChannelId>>,
|
|
||||||
}
|
|
||||||
98
src/structures/ErelaEvents.js
Normal file
98
src/structures/ErelaEvents.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { EmbedBuilder } = require('discord.js'); // Import EmbedBuilder
|
||||||
|
|
||||||
|
module.exports = (client) => {
|
||||||
|
if (!client || !client.manager) {
|
||||||
|
logger.error("ErelaEvents requires a client with an initialized manager.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.manager
|
||||||
|
.on('nodeConnect', node => logger.info(`Node "${node.options.identifier}" connected.`))
|
||||||
|
.on('nodeError', (node, error) => logger.error(`Node "${node.options.identifier}" encountered an error: ${error.message}`))
|
||||||
|
.on('nodeDisconnect', node => logger.warn(`Node "${node.options.identifier}" disconnected.`))
|
||||||
|
.on('nodeReconnect', node => logger.info(`Node "${node.options.identifier}" reconnecting.`))
|
||||||
|
|
||||||
|
.on('trackStart', (player, track) => {
|
||||||
|
logger.info(`Track started in guild ${player.guild}: ${track.title} requested by ${track.requester?.tag || 'Unknown'}`);
|
||||||
|
|
||||||
|
// Find the text channel associated with the player (if stored)
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle('Now Playing')
|
||||||
|
.setDescription(`[${track.title}](${track.uri})`)
|
||||||
|
.addFields({ name: 'Requested by', value: `${track.requester?.tag || 'Unknown'}`, inline: true })
|
||||||
|
.setTimestamp();
|
||||||
|
if (track.thumbnail) {
|
||||||
|
embed.setThumbnail(track.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.send({ embeds: [embed] }).catch(e => logger.error(`Failed to send trackStart message: ${e.message}`));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackEnd', (player, track, payload) => {
|
||||||
|
// Only log track end if it wasn't replaced (e.g., by skip or play next)
|
||||||
|
// 'REPLACED' means another track started immediately after this one.
|
||||||
|
if (payload && payload.reason !== 'REPLACED') {
|
||||||
|
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: ${payload.reason}`);
|
||||||
|
} else if (!payload) {
|
||||||
|
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: Unknown/Finished`);
|
||||||
|
}
|
||||||
|
// Optional: Send a message when a track ends naturally
|
||||||
|
// const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
// if (channel && payload && payload.reason === 'FINISHED') {
|
||||||
|
// channel.send(`Finished playing: ${track.title}`);
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackError', (player, track, payload) => {
|
||||||
|
logger.error(`Track error in guild ${player.guild} for track ${track?.title || 'Unknown'}: ${payload.error}`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send(`An error occurred while trying to play: ${track?.title || 'the track'}. Details: ${payload.exception?.message || 'Unknown error'}`).catch(e => logger.error(`Failed to send trackError message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Optionally destroy player or skip track on error
|
||||||
|
// player.stop();
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackStuck', (player, track, payload) => {
|
||||||
|
logger.warn(`Track stuck in guild ${player.guild} for track ${track?.title || 'Unknown'}. Threshold: ${payload.thresholdMs}ms`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send(`Track ${track?.title || 'the track'} seems stuck. Skipping...`).catch(e => logger.error(`Failed to send trackStuck message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Skip the track
|
||||||
|
player.stop();
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('queueEnd', (player) => {
|
||||||
|
logger.info(`Queue ended for guild ${player.guild}.`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send('Queue finished. Add more songs!').catch(e => logger.error(`Failed to send queueEnd message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Optional: Add a timeout before leaving the channel
|
||||||
|
// setTimeout(() => {
|
||||||
|
// if (player.queue.current) return; // Don't leave if something started playing again
|
||||||
|
// player.destroy();
|
||||||
|
// }, 180000); // 3 minutes
|
||||||
|
player.destroy(); // Destroy player immediately when queue ends
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('playerCreate', player => logger.debug(`Player created for guild ${player.guild}`))
|
||||||
|
.on('playerDestroy', player => logger.debug(`Player destroyed for guild ${player.guild}`))
|
||||||
|
.on('playerMove', (player, oldChannel, newChannel) => {
|
||||||
|
if (!newChannel) {
|
||||||
|
logger.info(`Player for guild ${player.guild} disconnected (moved from channel ${oldChannel}). Destroying player.`);
|
||||||
|
player.destroy();
|
||||||
|
} else {
|
||||||
|
logger.debug(`Player for guild ${player.guild} moved from channel ${oldChannel} to ${newChannel}`);
|
||||||
|
player.setVoiceChannel(newChannel); // Update player's voice channel reference
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Erela.js event listeners attached.");
|
||||||
|
};
|
||||||
292
src/structures/ShoukakuEvents.js
Normal file
292
src/structures/ShoukakuEvents.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages player instances and track playback using Shoukaku
|
||||||
|
* @param {Client} client Discord.js client
|
||||||
|
*/
|
||||||
|
class MusicPlayer {
|
||||||
|
constructor(client) {
|
||||||
|
this.client = client;
|
||||||
|
this.players = new Map(); // Store active players
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a player for a guild or returns existing one
|
||||||
|
* @param {Object} options Options for creating the player
|
||||||
|
* @param {string} options.guildId The guild ID
|
||||||
|
* @param {string} options.textChannel The text channel ID
|
||||||
|
* @param {string} options.voiceChannel The voice channel ID
|
||||||
|
* @returns {Object} The player object
|
||||||
|
*/
|
||||||
|
async createPlayer({ guildId, textChannel, voiceChannel }) {
|
||||||
|
// Check if player already exists
|
||||||
|
if (this.players.has(guildId)) {
|
||||||
|
return this.players.get(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Shoukaku instance and node
|
||||||
|
const shoukaku = this.client.shoukaku; // Get the main shoukaku instance
|
||||||
|
const node = shoukaku.options.nodeResolver(shoukaku.nodes);
|
||||||
|
if (!node) {
|
||||||
|
throw new Error('No available Lavalink nodes!');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new connection to the voice channel using the shoukaku instance
|
||||||
|
const connection = await shoukaku.joinVoiceChannel({
|
||||||
|
guildId: guildId,
|
||||||
|
channelId: voiceChannel,
|
||||||
|
shardId: 0, // Assuming shardId 0, adjust if sharding
|
||||||
|
deaf: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a player object to track state and add methods
|
||||||
|
const player = {
|
||||||
|
guild: guildId,
|
||||||
|
textChannel: textChannel,
|
||||||
|
voiceChannel: voiceChannel,
|
||||||
|
connection: connection,
|
||||||
|
queue: [],
|
||||||
|
current: null,
|
||||||
|
playing: false,
|
||||||
|
volume: 100,
|
||||||
|
|
||||||
|
// Play a track
|
||||||
|
async play(track) {
|
||||||
|
this.current = track;
|
||||||
|
logger.debug(`Attempting to play track: ${track.info.title} (${track.info.uri}) in guild ${this.guild}`);
|
||||||
|
logger.debug(`Track encoded data: ${track.encoded}`); // Log encoded data
|
||||||
|
try {
|
||||||
|
// Start playback - Ensure payload matches { track: { encoded: "..." } }
|
||||||
|
await this.connection.playTrack({ track: { encoded: track.encoded } });
|
||||||
|
this.playing = true;
|
||||||
|
logger.debug(`playTrack called successfully for: ${track.info.title}`);
|
||||||
|
} catch (playError) {
|
||||||
|
logger.error(`Error calling playTrack for ${track.info.title}: ${playError.message}`);
|
||||||
|
console.error(playError); // Log full error object
|
||||||
|
this.playing = false;
|
||||||
|
this.current = null;
|
||||||
|
// Maybe try skipping? Or just log and let the 'end' event handle it if it fires.
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop the current track
|
||||||
|
stop() {
|
||||||
|
this.connection.stopTrack();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Skip to the next track
|
||||||
|
skip() {
|
||||||
|
this.stop();
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
const nextTrack = this.queue.shift();
|
||||||
|
this.play(nextTrack);
|
||||||
|
} else {
|
||||||
|
this.current = null;
|
||||||
|
this.playing = false;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set player volume
|
||||||
|
setVolume(volume) {
|
||||||
|
this.volume = volume;
|
||||||
|
this.connection.setGlobalVolume(volume);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pause playback
|
||||||
|
pause() {
|
||||||
|
this.connection.setPaused(true);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume playback
|
||||||
|
resume() {
|
||||||
|
this.connection.setPaused(false);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
shoukaku: shoukaku, // Store shoukaku instance on the player object
|
||||||
|
|
||||||
|
// Destroy the player and disconnect
|
||||||
|
destroy() {
|
||||||
|
// Use the stored Shoukaku instance to leave the channel
|
||||||
|
this.shoukaku.leaveVoiceChannel(this.guild);
|
||||||
|
// Remove the player instance from the manager's map
|
||||||
|
musicPlayer.players.delete(this.guild);
|
||||||
|
logger.debug(`Destroyed player for guild ${this.guild}`);
|
||||||
|
return this; // Return this for potential chaining, though unlikely needed here
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a track to the queue or play it if nothing is playing
|
||||||
|
async enqueue(track, immediate = false) {
|
||||||
|
if (immediate || (!this.playing && !this.current)) {
|
||||||
|
logger.debug(`Enqueue: Playing immediately - ${track.info.title}`);
|
||||||
|
await this.play(track);
|
||||||
|
} else {
|
||||||
|
logger.debug(`Enqueue: Adding to queue - ${track.info.title}`);
|
||||||
|
this.queue.push(track);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up event listeners for this player
|
||||||
|
connection.on('start', () => {
|
||||||
|
logger.info(`Track started in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
|
||||||
|
|
||||||
|
// Send now playing message
|
||||||
|
if (player.current) {
|
||||||
|
const channel = this.client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle('Now Playing')
|
||||||
|
.setDescription(`[${player.current.info.title}](${player.current.info.uri})`)
|
||||||
|
.addFields({ name: 'Requested by', value: `${player.current.requester?.tag || 'Unknown'}`, inline: true })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (player.current.info.thumbnail) {
|
||||||
|
embed.setThumbnail(player.current.info.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.send({ embeds: [embed] }).catch(e =>
|
||||||
|
logger.error(`Failed to send trackStart message: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('end', () => {
|
||||||
|
logger.info(`Track ended in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
|
||||||
|
player.playing = false;
|
||||||
|
player.current = null;
|
||||||
|
|
||||||
|
// Play next track in queue if available
|
||||||
|
if (player.queue.length > 0) {
|
||||||
|
const nextTrack = player.queue.shift();
|
||||||
|
player.play(nextTrack);
|
||||||
|
} else {
|
||||||
|
// Send queue end message
|
||||||
|
const channel = this.client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send('Queue finished. Add more songs!').catch(e =>
|
||||||
|
logger.error(`Failed to send queueEnd message: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Optional: Add timeout before disconnecting
|
||||||
|
// setTimeout(() => {
|
||||||
|
// if (!player.playing) player.destroy();
|
||||||
|
// }, 300000); // 5 minutes
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('exception', (error) => {
|
||||||
|
logger.error(`Track exception in guild ${player.guild}: ${error.message || 'Unknown error'}`);
|
||||||
|
console.error("Full track exception details:", error); // Log the full error object
|
||||||
|
const channel = this.client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send(`An error occurred during playback: ${error.message || 'Unknown error'}`).catch(e =>
|
||||||
|
logger.error(`Failed to send trackException message: ${e.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Attempt to skip to the next track on exception
|
||||||
|
player.skip();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the player and return it
|
||||||
|
this.players.set(guildId, player);
|
||||||
|
return player;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create player for guild ${guildId}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an existing player
|
||||||
|
* @param {string} guildId The guild ID
|
||||||
|
* @returns {Object|null} The player object or null
|
||||||
|
*/
|
||||||
|
getPlayer(guildId) {
|
||||||
|
return this.players.get(guildId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for tracks using Shoukaku
|
||||||
|
* @param {Object} options Options for the search
|
||||||
|
* @param {string} options.identifier The pre-constructed search identifier (e.g., 'ytsearch:query', 'scsearch:query', or a URL)
|
||||||
|
* @param {string} options.requester The user who requested the track
|
||||||
|
* @returns {Promise<Array>} Array of track objects
|
||||||
|
*/
|
||||||
|
async search({ identifier, requester }) { // Accept identifier directly
|
||||||
|
// Get the first available node
|
||||||
|
const node = this.client.shoukaku.options.nodeResolver(this.client.shoukaku.nodes);
|
||||||
|
if (!node) throw new Error('No available Lavalink nodes!');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Perform the search using the provided identifier string
|
||||||
|
logger.debug(`Performing search with identifier: ${identifier}`);
|
||||||
|
const result = await node.rest.resolve(identifier);
|
||||||
|
if (!result || result.loadType === 'error' || result.loadType === 'empty') {
|
||||||
|
// Log the identifier for debugging if search fails
|
||||||
|
logger.debug(`Search failed for identifier: ${identifier}`);
|
||||||
|
throw new Error(result?.exception?.message || 'No results found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process results
|
||||||
|
let tracks = [];
|
||||||
|
if (result.loadType === 'playlist') {
|
||||||
|
// Playlist processing
|
||||||
|
tracks = result.data.tracks.map(track => ({
|
||||||
|
encoded: track.encoded, // Correct property name
|
||||||
|
info: track.info,
|
||||||
|
requester: requester
|
||||||
|
}));
|
||||||
|
} else if (result.loadType === 'track') {
|
||||||
|
// Single track
|
||||||
|
const track = result.data;
|
||||||
|
tracks = [{
|
||||||
|
encoded: track.encoded, // Correct property name
|
||||||
|
info: track.info,
|
||||||
|
requester: requester
|
||||||
|
}];
|
||||||
|
} else if (result.loadType === 'search') {
|
||||||
|
// Search results
|
||||||
|
tracks = result.data.slice(0, 10).map(track => ({
|
||||||
|
encoded: track.encoded, // Correct property name
|
||||||
|
info: track.info,
|
||||||
|
requester: requester
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Search error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export the player manager
|
||||||
|
const musicPlayer = new MusicPlayer(null);
|
||||||
|
module.exports = {
|
||||||
|
setupPlayer: (client) => {
|
||||||
|
if (!client || !client.shoukaku) {
|
||||||
|
logger.error("ShoukakuEvents requires a client with an initialized shoukaku instance.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the player with the client
|
||||||
|
musicPlayer.client = client;
|
||||||
|
|
||||||
|
logger.info("Shoukaku music player initialized and ready.");
|
||||||
|
return musicPlayer;
|
||||||
|
},
|
||||||
|
musicPlayer
|
||||||
|
};
|
||||||
25
src/utils.rs
25
src/utils.rs
@@ -1,25 +0,0 @@
|
|||||||
// src/utils.rs
|
|
||||||
|
|
||||||
use serenity::{
|
|
||||||
client::Context,
|
|
||||||
model::{
|
|
||||||
id::{GuildId, UserId},
|
|
||||||
voice::VoiceState,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
|
|
||||||
// Helper function to find the voice state of a user in a specific guild
|
|
||||||
// using the cached data. Returns None if the user is not in a voice channel
|
|
||||||
// in that guild, or if the guild is not in the cache.
|
|
||||||
#[instrument(skip(ctx))]
|
|
||||||
pub async fn get_voice_state(
|
|
||||||
ctx: &Context,
|
|
||||||
guild_id: GuildId,
|
|
||||||
user_id: UserId
|
|
||||||
) -> Option<VoiceState> {
|
|
||||||
ctx.cache.guild(guild_id).await
|
|
||||||
// Get the guild from cache, then look up the user's voice state in that guild
|
|
||||||
.and_then(|guild| guild.voice_states.get(&user_id).cloned())
|
|
||||||
}
|
|
||||||
17
src/utils/logger.js
Normal file
17
src/utils/logger.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info', // Use LOG_LEVEL from env or default to 'info'
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(info => `${info.timestamp} ${info.level.toUpperCase()}: ${info.message}`)
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console(),
|
||||||
|
// Optionally add file transport
|
||||||
|
// new winston.transports.File({ filename: 'combined.log' }),
|
||||||
|
// new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
55
tests/deploy-commands.test.js
Normal file
55
tests/deploy-commands.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
jest.mock('discord.js', () => {
|
||||||
|
const original = jest.requireActual('discord.js');
|
||||||
|
const mockRest = {
|
||||||
|
put: jest.fn().mockResolvedValue([{ length: 1 }]),
|
||||||
|
setToken: jest.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
REST: jest.fn(() => mockRest),
|
||||||
|
Routes: {
|
||||||
|
applicationCommands: jest.fn().mockReturnValue('/fake-route'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
readdirSync: jest.fn(() => ['ping.js']),
|
||||||
|
}));
|
||||||
|
jest.mock('node:path', () => {
|
||||||
|
const actual = jest.requireActual('node:path');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
join: (...args) => args.join('/'),
|
||||||
|
resolve: (...args) => args.join('/'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deploy-commands.js', () => {
|
||||||
|
let origEnv;
|
||||||
|
beforeAll(() => {
|
||||||
|
origEnv = { ...process.env };
|
||||||
|
process.env.CLIENT_ID = '12345';
|
||||||
|
process.env.DISCORD_TOKEN = 'token';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = origEnv;
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers commands via REST API', async () => {
|
||||||
|
const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||||
|
jest.mock('../src/utils/logger', () => mockLogger);
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
await require('../deploy-commands.js');
|
||||||
|
|
||||||
|
const { REST } = require('discord.js');
|
||||||
|
expect(REST).toHaveBeenCalled();
|
||||||
|
const restInstance = REST.mock.results[0].value;
|
||||||
|
expect(restInstance.setToken).toHaveBeenCalledWith('token');
|
||||||
|
expect(restInstance.put).toHaveBeenCalledWith('/fake-route', { body: expect.any(Array) });
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Started refreshing'));
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/start-script.test.js
Normal file
10
tests/start-script.test.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
describe('NPM Start Script', () => {
|
||||||
|
test('npm start exits without error when DISCORD_TOKEN is provided', () => {
|
||||||
|
const env = { ...process.env, DISCORD_TOKEN: 'dummy-token', CLIENT_ID: '123', LAVALINK_HOST: 'localhost', LAVALINK_PORT: '2333', LAVALINK_PASSWORD: 'pass' };
|
||||||
|
const result = spawnSync('pnpm', ['start'], { env, encoding: 'utf-8' });
|
||||||
|
// The script starts the bot; if it reaches login attempt, exit code is 0
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
tests/startup.test.js
Normal file
13
tests/startup.test.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
describe('Bot Startup', () => {
|
||||||
|
test('exits with code 1 if DISCORD_TOKEN is missing', () => {
|
||||||
|
// Clear DISCORD_TOKEN
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.DISCORD_TOKEN;
|
||||||
|
|
||||||
|
const result = spawnSync('node', ['src/index.js'], { env, encoding: 'utf-8' });
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr || result.stdout).toMatch(/DISCORD_TOKEN is missing/);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
update-plugin.sh
Executable file
50
update-plugin.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Exit immediately if a command exits with a non-zero status.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Define variables
|
||||||
|
PLUGIN_DIR="./plugins"
|
||||||
|
REPO_URL="https://maven.lavalink.dev/snapshots/dev/lavalink/youtube/youtube-plugin"
|
||||||
|
METADATA_URL="${REPO_URL}/maven-metadata.xml"
|
||||||
|
ARTIFACT_ID="youtube-plugin"
|
||||||
|
|
||||||
|
echo "Fetching latest snapshot version..."
|
||||||
|
|
||||||
|
# Fetch metadata and extract the latest snapshot version using grep and sed
|
||||||
|
# Use curl with -sS for silent operation but show errors
|
||||||
|
# Use grep to find the <latest> tag, then sed to extract the content
|
||||||
|
LATEST_VERSION=$(curl -sS "$METADATA_URL" | grep '<latest>' | sed -e 's/.*<latest>\(.*\)<\/latest>.*/\1/')
|
||||||
|
|
||||||
|
if [ -z "$LATEST_VERSION" ]; then
|
||||||
|
echo "Error: Could not determine the latest snapshot version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Latest snapshot version: $LATEST_VERSION"
|
||||||
|
|
||||||
|
# Construct the JAR filename and download URL
|
||||||
|
JAR_FILENAME="${ARTIFACT_ID}-${LATEST_VERSION}.jar"
|
||||||
|
DOWNLOAD_URL="${REPO_URL}/${LATEST_VERSION}/${JAR_FILENAME}"
|
||||||
|
|
||||||
|
# Create the plugins directory if it doesn't exist
|
||||||
|
mkdir -p "$PLUGIN_DIR"
|
||||||
|
|
||||||
|
# Remove any existing youtube-plugin JARs to avoid conflicts
|
||||||
|
echo "Removing old plugin versions from $PLUGIN_DIR..."
|
||||||
|
rm -f "$PLUGIN_DIR"/youtube-plugin-*.jar
|
||||||
|
|
||||||
|
# Download the latest snapshot JAR
|
||||||
|
echo "Downloading $JAR_FILENAME from $DOWNLOAD_URL..."
|
||||||
|
curl -L -o "$PLUGIN_DIR/$JAR_FILENAME" "$DOWNLOAD_URL"
|
||||||
|
|
||||||
|
# Verify download
|
||||||
|
if [ ! -f "$PLUGIN_DIR/$JAR_FILENAME" ]; then
|
||||||
|
echo "Error: Failed to download the plugin JAR."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Successfully downloaded $JAR_FILENAME to $PLUGIN_DIR"
|
||||||
|
echo "Make sure to restart your Lavalink container for the changes to take effect."
|
||||||
|
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user