Compare commits

..

41 Commits

Author SHA1 Message Date
aki
3c4dc51855 refactor: Convert project from JavaScript to TypeScript
- Converted all .js files to .ts
- Added TypeScript configuration (tsconfig.json)
- Added ESLint and Prettier configuration
- Updated package.json dependencies
- Modified Docker and application configurations
2025-04-24 13:48:10 +08:00
aki
75185a59c3 last ditch effort :c 2025-04-24 03:58:01 +08:00
aki
5b51e3f529 feat(youtube-plugin): Grab the latest snapshot from Maven latest releases 2025-04-24 03:57:41 +08:00
aki
99a1417c43 debug :> 2025-04-24 03:33:44 +08:00
aki
6546cb8d63 if wsl wasn't so broken, i didn't need to commit this much :)
fix(ShoukakuEvents.js): ensure playback track payload structure matches expected format
2025-04-24 03:15:04 +08:00
aki
b958e79a98 fix(ShoukakuEvents.js): correct property name for track encoding in search results
hopefully the final fix :)
2025-04-24 03:12:01 +08:00
aki
81c65a3644 chore(ShoukakuEvents.js): add logging for track encoded data during playback 2025-04-24 03:07:08 +08:00
aki
68a3f4fb58 chore: update .env.example and logger.js for improved configuration and logging level management 2025-04-24 03:03:05 +08:00
aki
5c5574c06e >,> 2025-04-24 02:55:39 +08:00
aki
9e02e50693 fix(ShoukakuEvents.js): improve error handling during track playback and logging 2025-04-24 02:50:17 +08:00
aki
9d7ff5e7e7 feat(play.js): add source option for track search and enhance search logic 2025-04-24 02:50:11 +08:00
aki
3ba230e6e9 chore: update Dockerfile to use Node.js 23-slim and adjust application.yml for plugin settings 2025-04-24 02:49:36 +08:00
aki
253f369a89 refactor(application.yml): streamline Lavalink configuration and enhance plugin management 2025-04-24 02:23:15 +08:00
aki
7500ea01c8 untest the testing earlier 2025-04-24 02:06:06 +08:00
aki
f31bba40fb add youtube plugin 2025-04-24 02:01:39 +08:00
aki
4d5c301c46 <,< 2025-04-24 01:59:13 +08:00
aki
8f8ff6aa81 fix(lavalink): Update Lavalink configuration and enhance healthcheck with plugin management 2025-04-24 01:50:06 +08:00
aki
ca9e531541 pause fix again hope -o- 2025-04-24 01:40:14 +08:00
aki
f1991f7716 testing 2025-04-24 01:33:42 +08:00
aki
ce635cb32b sleepy 2025-04-24 01:29:28 +08:00
aki
30b5b23868 fix(docker-compose): Update healthcheck for Lavalink service to use correct endpoint and reduce interval 2025-04-24 01:27:17 +08:00
aki
74cac2bfbb fix(lavalink): Refactor search method to use identifier string for track resolution 2025-04-24 01:22:19 +08:00
aki
bb7a796cf9 fix(docker-compose): Add healthcheck for Lavalink service and update depends_on condition 2025-04-24 01:22:09 +08:00
aki
a54becb3a0 fix(lavalink): Update leave for Shoukaku integration 2025-04-24 01:21:44 +08:00
aki
0d0125bf55 fix(lavalink): Update join, play, and voice state handling for Shoukaku integration 2025-04-24 01:01:33 +08:00
aki
854cf12d64 fix(lavalink): Correct Lavalink connection URL format and update deprecated code 2025-04-24 00:52:16 +08:00
aki
e54c23cc63 feat(lavalink): Migrate from Erela.js to Shoukaku for music playback management 2025-04-24 00:25:02 +08:00
aki
5a29fe3d9d chore(lavalink): another application.yml fix cuz i'm sleepy 2025-04-24 00:19:05 +08:00
aki
42de01e004 chore(lavalink): Add YouTube plugin in application.yml to fix deprecation error 2025-04-24 00:14:42 +08:00
aki
537a8c6709 fix(docker): Update environment variables and improve connection handling for Lavalink 2025-04-24 00:05:57 +08:00
Aki Amane
0b86b5d891 fix(docker): Fix Dockerfile errors 2025-04-23 23:55:29 +08:00
aki
d4de2feaaa chore(docker): Add application.yml for lavaplayer 2025-04-23 23:28:48 +08:00
aki
95ea55d972 chore(docker): switch to pnpm, update Dockerfile, docker-compose, and README 2025-04-23 23:27:29 +08:00
aki
170faf7d01 fix(package): format scripts section and add js-yaml to devDependencies 2025-04-23 22:09:35 +08:00
aki
f50c88515e test(scripts): add npm start script test 2025-04-23 22:09:00 +08:00
aki
57d10ddf70 test(deploy): add tests for deploy-commands script 2025-04-23 22:05:13 +08:00
aki
6daf1993d1 build(docker): add Dockerfile, Docker Compose config, and update README 2025-04-23 21:55:40 +08:00
aki
47de3823f3 test(startup): add startup test for missing DISCORD_TOKEN 2025-04-23 21:54:52 +08:00
aki
74dfdbf667 feat(bot): add NodeJS implementation and deploy script 2025-04-23 21:40:02 +08:00
aki
5c632556b7 chore(rust): remove Rust source files and Cargo configuration 2025-04-23 21:39:06 +08:00
aki
05fec6747d Initial project setup with basic structure, including environment configuration, command handling, and Lavalink integration. 2025-04-20 09:02:34 +08:00
41 changed files with 1627 additions and 1170 deletions

View File

@@ -1,9 +0,0 @@
# .dockerignore
.git
.gitignore
target/
.env
*.md
Lavalink.jar
application.yml

View File

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

27
.eslintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json" // Point ESLint to your TS config
},
"plugins": [
"@typescript-eslint",
"prettier" // Integrates Prettier rules into ESLint
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended", // Recommended TS rules
"plugin:@typescript-eslint/recommended-requiring-type-checking", // Rules requiring type info
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier
],
"rules": {
// Add or override specific rules here if needed
"prettier/prettier": "warn", // Show Prettier issues as warnings
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // Warn about unused vars, allow underscores
"@typescript-eslint/explicit-module-boundary-types": "off", // Allow inferred return types for now
"@typescript-eslint/no-explicit-any": "warn" // Warn about using 'any'
},
"ignorePatterns": ["node_modules/", "dist/", "data/", "*.db", "*.db-journal", "*.db-wal"]
}

37
.gitignore vendored
View File

@@ -1,22 +1,23 @@
# ---> 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
# Data directory
data/

21
.prettierignore Normal file
View File

@@ -0,0 +1,21 @@
# Ignore artifacts:
node_modules
dist
coverage
data
*.db
*.db-journal
*.db-wal
# Ignore configuration files managed by other tools:
package-lock.json
pnpm-lock.yaml
yarn.lock
# Ignore logs:
logs
*.log
# Ignore environment files:
.env*
!.env.example

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}

View File

@@ -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"

View File

@@ -1,42 +1,45 @@
# Dockerfile # ---- Build Stage ----
FROM node:23-alpine AS builder
# ---- 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 # Install pnpm and necessary build tools (if native modules are used)
RUN cargo install cargo-chef --locked RUN apk add --no-cache python3 make g++ pnpm
# Copy package manifests
COPY package.json pnpm-lock.yaml ./
# Install ALL dependencies (including devDependencies needed for build)
RUN pnpm install --frozen-lockfile
# Copy the rest of the source code
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 ---- # Compile TypeScript
# Use the same Rust Alpine image for building. RUN pnpm run build
FROM rust:1-alpine AS builder
WORKDIR /app # Prune devDependencies after build (optional but good practice)
# Install build dependencies for Alpine RUN pnpm prune --prod
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 # ---- Production Stage ----
# Copy the dependency recipe from the planner stage FROM node:23-alpine
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 ----
# Use a minimal Alpine image for the final runtime environment.
FROM alpine:latest
WORKDIR /app WORKDIR /app
# Install runtime dependencies needed by the binary (e.g., SSL certs)
RUN apk add --no-cache ca-certificates openssl ENV NODE_ENV=production
# Copy the compiled application binary from the builder stage
COPY --from=builder /app/target/release/discord-music-bot-lavalink-rs /app/ # Copy necessary files from the builder stage
# Set the binary as the entrypoint COPY --from=builder /app/node_modules ./node_modules
CMD ["./discord-music-bot-lavalink-rs"] COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Copy other runtime necessities (adjust if needed)
# COPY .env.example ./
# COPY application.yml ./
# COPY plugins ./plugins
# Expose port if needed (though likely not for a Discord bot)
# EXPOSE 3000
# Run the compiled JavaScript application
CMD ["node", "dist/index.js"]

249
README.md
View File

@@ -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.

View File

@@ -1,32 +1,116 @@
# 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
- MUSIC
oauth:
enabled: true
# If you obtain a refresh token after the initial OAuth flow, you can add it here
# refreshToken: "paste your refresh token here if applicable"
# Leave skipInitialization commented for first-time setup
# skipInitialization: true
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 # General YouTube plugin logging
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO # Specific OAuth flow logging
request:
enabled: true
includeClientInfo: true
includeHeaders: false
includeQueryString: true
includePayload: true
maxPayloadLength: 10000
logback:
rollingpolicy:
max-file-size: 1GB
max-history: 30

View File

@@ -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:

103
deploy-commands.ts Normal file
View File

@@ -0,0 +1,103 @@
import { REST, Routes, APIApplicationCommand } from 'discord.js';
import fs from 'node:fs';
import path from 'node:path';
import logger from './src/utils/logger'; // Use default import now
import dotenv from 'dotenv';
// --- Setup ---
dotenv.config(); // Load .env variables
// Log presence of required env vars (optional, but helpful for debugging)
// logger.info(`CLIENT_ID: ${process.env.CLIENT_ID ? 'Present' : 'MISSING!'}`);
// logger.info(`DISCORD_TOKEN: ${process.env.DISCORD_TOKEN ? 'Present' : 'MISSING!'}`);
// --- Configuration ---
const clientId = process.env.CLIENT_ID;
const token = process.env.DISCORD_TOKEN;
if (!clientId || !token) {
logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!');
process.exit(1);
}
const commands: Omit<APIApplicationCommand, 'id' | 'application_id' | 'version'>[] = []; // Type the commands array more accurately
// Grab all the command files from the commands directory
const commandsPath = path.join(__dirname, 'src', 'commands');
// Read .ts files now
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts')); // Add string type
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
const loadCommandsForDeployment = async () => {
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
try {
// Use dynamic import
const commandModule = await import(filePath);
// Assuming commands export default or have a 'default' property
const command = commandModule.default || commandModule;
if (command && typeof command === 'object' && 'data' in command && typeof command.data.toJSON === 'function') {
// We push the JSON representation which matches the API structure
commands.push(command.data.toJSON());
logger.info(`Loaded command for deployment: ${command.data.name}`);
} else {
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" property with a "toJSON" method.`);
}
} catch (error: unknown) { // Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error loading command at ${filePath} for deployment: ${errorMessage}`, error);
}
}
};
// Construct and prepare an instance of the REST module
const rest = new REST({ version: '10' }).setToken(token);
// Define the deployment function
const deployCommands = async () => {
try {
await loadCommandsForDeployment(); // Wait for commands to be loaded
if (commands.length === 0) {
logger.warn('No commands loaded for deployment. Exiting.');
return;
}
logger.info(`Started refreshing ${commands.length} application (/) commands.`);
// The put method is used to fully refresh all commands
const guildId = process.env.GUILD_ID;
let data: any; // Type appropriately if possible, depends on discord.js version
if (guildId) {
// Deploying to a specific guild (faster for testing)
logger.info(`Deploying commands to guild: ${guildId}`);
data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commands },
);
logger.info(`Successfully reloaded ${data.length} application (/) commands in guild ${guildId}.`);
} else {
// Deploying globally (can take up to an hour)
logger.info('Deploying commands globally...');
data = await rest.put(
Routes.applicationCommands(clientId),
{ body: commands },
);
logger.info(`Successfully reloaded ${data.length} global application (/) commands.`);
}
} catch (error: unknown) { // Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed during command deployment: ${errorMessage}`, error);
}
};
// Execute the deployment
deployCommands();
// Note: The old wipe logic is removed as PUT overwrites existing commands.
// If you specifically need to wipe commands first for some reason,
// you can add separate PUT requests with an empty body before deploying.

46
docker-compose.yml Normal file
View 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

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "discord-music-bot",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"lint": "eslint src/**/*.ts tests/**/*.ts deploy-commands.ts",
"format": "prettier --write src/**/*.ts tests/**/*.ts deploy-commands.ts",
"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": {
"@types/jest": "^29.5.14",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"jest": "^29.7.0",
"js-yaml": "^4.1.0",
"prettier": "^3.5.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}

View File

@@ -1,3 +0,0 @@
# rustfmt.toml
hard_tabs = false
tab_spaces = 2

View File

@@ -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(())
})
}

122
src/commands/join.ts Normal file
View File

@@ -0,0 +1,122 @@
import {
SlashCommandBuilder,
PermissionFlagsBits,
ChannelType,
ChatInputCommandInteraction, // Import the specific interaction type
GuildMember, // Import GuildMember type
VoiceBasedChannel // Import VoiceBasedChannel type
} from 'discord.js';
import logger from '../utils/logger'; // Use default import
import { BotClient } from '../index'; // Import the BotClient interface
import { Player } from 'shoukaku'; // Import the Player type explicitly
export default { // Use export default for ES Modules
data: new SlashCommandBuilder()
.setName('join')
.setDescription('Joins your current voice channel'),
async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types
// Ensure command is run in a guild
if (!interaction.guildId || !interaction.guild || !interaction.channelId) {
// Reply might fail if interaction is already replied/deferred, use editReply if needed
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
}
// Ensure interaction.member is a GuildMember
if (!(interaction.member instanceof GuildMember)) {
return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {});
}
// Use ephemeral deferral
await interaction.deferReply({ ephemeral: true });
const member = interaction.member; // Already checked it's GuildMember
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!');
}
// Type assertion for voiceChannel after check
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
// 2. Check bot permissions
const permissions = currentVoiceChannel.permissionsFor(client.user!); // Use non-null assertion for client.user
if (!permissions?.has(PermissionFlagsBits.Connect)) { // Optional chaining for permissions
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.)
if (currentVoiceChannel.type !== ChannelType.GuildVoice) {
return interaction.editReply('I can only join standard voice channels.');
}
// Get the initialized Shoukaku instance from the client object
const shoukaku = client.shoukaku;
if (!shoukaku) {
logger.error('Shoukaku instance not found 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
// Correctly get player from the players map and type it
let player: Player | undefined = shoukaku.players.get(interaction.guildId);
if (!player) {
try {
// Create player using the Shoukaku manager
player = await shoukaku.joinVoiceChannel({
guildId: interaction.guildId,
channelId: currentVoiceChannel.id,
shardId: interaction.guild.shardId, // Get shardId from guild
});
logger.info(`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`);
await interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`);
} catch (error: unknown) { // Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${errorMessage}`, error);
// Attempt to leave voice channel if connection failed partially
shoukaku.leaveVoiceChannel(interaction.guildId).catch((e: unknown) => { // Type catch error
const leaveErrorMsg = e instanceof Error ? e.message : String(e);
logger.error(`Error leaving VC after failed join: ${leaveErrorMsg}`);
});
return interaction.editReply('An error occurred while trying to join the voice channel.');
}
} else {
// If player exists, get the corresponding connection
const connection = shoukaku.connections.get(interaction.guildId);
// Check if connection exists and if it's in a different channel
if (!connection || connection.channelId !== currentVoiceChannel.id) {
try {
// Rejoining should handle moving the bot
// Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild.
// If issues arise, explicitly call leaveVoiceChannel first.
player = await shoukaku.joinVoiceChannel({
guildId: interaction.guildId,
channelId: currentVoiceChannel.id,
shardId: interaction.guild.shardId,
});
logger.info(`Moved player to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guildId}`);
await interaction.editReply(`Moved to ${currentVoiceChannel.name}!`);
} catch (error: unknown) { // Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to move player for guild ${interaction.guildId}: ${errorMessage}`, 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 ${currentVoiceChannel.name}!`);
}
// Example of updating a manually managed text channel context (if needed)
// if (player.textChannelId !== interaction.channelId) {
// player.textChannelId = interaction.channelId;
// logger.debug(`Updated player text channel context to ${interaction.channel?.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
// }
}
},
};

View File

@@ -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(())
})
}

67
src/commands/leave.ts Normal file
View File

@@ -0,0 +1,67 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction, // Import the specific interaction type
GuildMember // Import GuildMember type
} from 'discord.js';
import logger from '../utils/logger'; // Use default import
import { BotClient } from '../index'; // Import the BotClient interface
// No need to import Player explicitly if we just check connection
export default { // Use export default for ES Modules
data: new SlashCommandBuilder()
.setName('leave')
.setDescription('Leaves the current voice channel'),
async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types
// Ensure command is run in a guild
if (!interaction.guildId || !interaction.guild) {
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
}
// Ensure interaction.member is a GuildMember (optional, but good practice)
if (!(interaction.member instanceof GuildMember)) {
return interaction.reply({ content: 'Could not verify your membership.', ephemeral: true }).catch(() => {});
}
// Use ephemeral deferral
await interaction.deferReply({ ephemeral: true });
// Get the Shoukaku instance
const shoukaku = client.shoukaku;
if (!shoukaku) {
logger.error('Shoukaku instance not found on client object!');
return interaction.editReply('The music player is not ready yet.');
}
// Check if a connection exists for this guild
const connection = shoukaku.connections.get(interaction.guildId);
if (!connection || !connection.channelId) {
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 memberVoiceChannelId = interaction.member.voice.channelId;
// if (memberVoiceChannelId !== connection.channelId) {
// return interaction.editReply('You need to be in the same voice channel as me to make me leave!');
// }
try {
const channelId = connection.channelId; // Get channel ID from connection
const channel = await client.channels.fetch(channelId).catch(() => null); // Fetch channel for name
const channelName = channel && channel.isVoiceBased() ? channel.name : `ID: ${channelId}`; // Get channel name if possible
// Use Shoukaku's leave method - this destroys player and connection
await shoukaku.leaveVoiceChannel(interaction.guildId);
logger.info(`Left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`);
await interaction.editReply(`Left ${channelName}.`);
} catch (error: unknown) { // Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error leaving voice channel for guild ${interaction.guildId}: ${errorMessage}`, error);
// Attempt to reply even if leave failed partially
await interaction.editReply('An error occurred while trying to leave the voice channel.').catch((e: unknown) => { // Type catch error
const replyErrorMsg = e instanceof Error ? e.message : String(e);
logger.error(`Failed to send error reply for leave command: ${replyErrorMsg}`);
});
}
},
};

16
src/commands/ping.ts Normal file
View File

@@ -0,0 +1,16 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
// No need to import BotClient if not used directly in execute
export default { // Use export default for ES Modules
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!'),
async execute(interaction: ChatInputCommandInteraction) { // Add interaction type
// 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`);
},
};

View File

@@ -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(())
})
}

347
src/commands/play.ts Normal file
View File

@@ -0,0 +1,347 @@
import {
SlashCommandBuilder,
SlashCommandStringOption, // Import for typing options
PermissionFlagsBits,
ChannelType,
EmbedBuilder,
ChatInputCommandInteraction,
GuildMember,
VoiceBasedChannel
} from 'discord.js';
import logger from '../utils/logger';
import { BotClient } from '../index';
// Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported
import { Player, Node, Track, SearchResult, Connection } from 'shoukaku';
// Define the structure of the Lavalink V4 response (if not directly available from shoukaku types)
// Based on https://lavalink.dev/api/rest.html#load-tracks
type LavalinkLoadType = 'track' | 'playlist' | 'search' | 'empty' | 'error';
interface LavalinkResponse {
loadType: LavalinkLoadType;
data: any; // Data structure varies based on loadType
}
interface LavalinkErrorData {
message: string;
severity: string;
cause: string;
}
interface LavalinkPlaylistInfo {
name: string;
selectedTrack?: number; // Optional index of the selected track within the playlist
}
interface LavalinkPlaylistData {
info: LavalinkPlaylistInfo;
pluginInfo: any; // Or specific type if known
tracks: Track[];
}
// Export: Extend Player type locally to add queue and textChannelId
export interface GuildPlayer extends Player {
queue: TrackWithRequester[];
textChannelId?: string; // Optional: Store text channel ID for messages
}
// Export: Define TrackWithRequester
export interface TrackWithRequester extends Track {
// Ensure encoded is strictly string if extending base Track which might have it optional
encoded: string;
requester: {
id: string;
tag: string;
};
}
// Export: Helper function to start playback if possible
export async function playNext(player: GuildPlayer, interaction: ChatInputCommandInteraction) {
// Check if player is still valid (might have been destroyed)
const shoukaku = (interaction.client as BotClient).shoukaku;
if (!shoukaku?.players.has(player.guildId)) {
logger.warn(`playNext called for destroyed player in guild ${player.guildId}`);
return;
}
if (player.track || player.queue.length === 0) {
return; // Already playing or queue is empty
}
const nextTrack = player.queue.shift();
if (!nextTrack) return;
try {
// Check if user provided an OAuth token (could be stored in a database or env variable)
const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN;
const userData = oauthToken ? { "oauth-token": oauthToken } : undefined;
// Fix: Correct usage for playTrack based on Player.ts
await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData} });
// logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`);
} catch (playError: unknown) {
const errorMsg = playError instanceof Error ? playError.message : String(playError);
logger.error(`Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`);
// Try to send error message to the stored text channel
const channel = interaction.guild?.channels.cache.get(player.textChannelId || interaction.channelId);
if (channel?.isTextBased()) {
// Fix: Check if e is Error before accessing message
channel.send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`).catch((e: unknown) => {
const sendErrorMsg = e instanceof Error ? e.message : String(e);
logger.error(`Failed to send play error message: ${sendErrorMsg}`);
});
}
// Try playing the next track if available
await playNext(player, interaction);
}
}
export default {
data: new SlashCommandBuilder()
.setName('play')
.setDescription('Plays audio from a URL or search query')
.addStringOption((option: SlashCommandStringOption) => // Type option
option.setName('query')
.setDescription('The URL or search term for the song/playlist')
.setRequired(true))
.addStringOption((option: SlashCommandStringOption) => // Type 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' }
// Add other sources like 'spotify' if supported by Lavalink plugins
)),
async execute(interaction: ChatInputCommandInteraction, client: BotClient) {
// Ensure command is run in a guild
if (!interaction.guildId || !interaction.guild || !interaction.channelId) {
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
}
if (!(interaction.member instanceof GuildMember)) {
return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {});
}
await interaction.deferReply(); // Defer reply immediately
const member = interaction.member;
const voiceChannel = member?.voice?.channel;
const query = interaction.options.getString('query', true); // Required option
const source = interaction.options.getString('source'); // Optional
// 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!');
}
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
// 2. Check bot permissions
const permissions = currentVoiceChannel.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 (currentVoiceChannel.type !== ChannelType.GuildVoice) {
return interaction.editReply('I can only join standard voice channels.');
}
// Get Shoukaku instance
const shoukaku = client.shoukaku;
if (!shoukaku) {
logger.error('Shoukaku instance not found on client object!');
return interaction.editReply('The music player is not ready yet. Please try again shortly.');
}
let player: GuildPlayer | undefined; // Declare player variable outside try block
try {
// 3. Get or create player/connection
player = shoukaku.players.get(interaction.guildId) as GuildPlayer | undefined;
const connection = shoukaku.connections.get(interaction.guildId);
if (!player || !connection || connection.channelId !== currentVoiceChannel.id) {
// If player/connection doesn't exist or bot is in wrong channel, join/move
try {
player = await shoukaku.joinVoiceChannel({
guildId: interaction.guildId,
channelId: currentVoiceChannel.id,
shardId: interaction.guild.shardId,
}) as GuildPlayer; // Cast to extended type
logger.info(`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`);
// Initialize queue if it's a new player
if (!player.queue) {
player.queue = [];
}
player.textChannelId = interaction.channelId; // Store text channel context
} catch (joinError: unknown) {
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
logger.error(`Failed to join/move player for guild ${interaction.guildId}: ${errorMsg}`, joinError);
shoukaku.leaveVoiceChannel(interaction.guildId).catch(() => {}); // Attempt cleanup
return interaction.editReply('An error occurred while trying to join the voice channel.');
}
} else {
// Ensure queue exists if player was retrieved
if (!player.queue) {
player.queue = [];
}
// Update text channel context if needed
player.textChannelId = interaction.channelId;
}
// 4. Determine search identifier based on query and source
let identifier: string;
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
identifier = `ytmsearch:${query}`;
break;
}
}
logger.debug(`Constructed identifier: ${identifier}`);
// 5. Search for tracks using Lavalink REST API via an ideal node
const node = shoukaku.getIdealNode();
if (!node) {
throw new Error('No available Lavalink node.');
}
// Use the correct return type (LavalinkResponse) and check for undefined
const searchResult: LavalinkResponse | undefined = await node.rest.resolve(identifier);
if (!searchResult) {
throw new Error('REST resolve returned undefined or null.');
}
// 6. Process search results and add to queue
const responseEmbed = new EmbedBuilder().setColor('#0099ff');
let tracksToAdd: TrackWithRequester[] = [];
// Switch using string literals based on Lavalink V4 load types
switch (searchResult.loadType) {
case 'track': { // Use 'track'
const track = searchResult.data as Track;
// Ensure track and encoded exist before pushing
if (!track?.encoded) throw new Error('Loaded track is missing encoded data.');
tracksToAdd.push({
...track,
encoded: track.encoded, // Explicitly include non-null encoded
requester: { id: interaction.user.id, tag: interaction.user.tag }
});
responseEmbed
.setTitle('Track Added to Queue')
.setDescription(`[${track.info.title}](${track.info.uri})`)
// Ensure player exists before accessing queue
.addFields({ name: 'Position in queue', value: `${player.queue.length + 1}`, inline: true });
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl
logger.info(`Adding track: ${track.info.title} (Guild: ${interaction.guildId})`);
break;
}
case 'search': { // Use 'search'
const tracks = searchResult.data as Track[]; // Data is an array of tracks
if (!tracks || tracks.length === 0) throw new Error('Search returned no results.');
// Fix: Assign track AFTER the check
const track = tracks[0];
if (!track?.encoded) throw new Error('Searched track is missing encoded data.');
tracksToAdd.push({
...track,
encoded: track.encoded, // Explicitly include non-null encoded
requester: { id: interaction.user.id, tag: interaction.user.tag }
});
responseEmbed
.setTitle('Track Added to Queue')
.setDescription(`[${track.info.title}](${track.info.uri})`)
.addFields({ name: 'Position in queue', value: `${player.queue.length + 1}`, inline: true });
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl);
logger.info(`Adding track from search: ${track.info.title} (Guild: ${interaction.guildId})`);
break;
}
case 'playlist': { // Use 'playlist'
const playlistData = searchResult.data as LavalinkPlaylistData; // Cast to correct structure
const playlistInfo = playlistData.info;
const playlistTracks = playlistData.tracks;
// Fix: Filter out tracks without encoded string and assert non-null for map
tracksToAdd = playlistTracks
.filter(track => !!track.encoded) // Ensure encoded exists
.map(track => ({
...track,
encoded: track.encoded!, // Add non-null assertion
requester: { id: interaction.user.id, tag: interaction.user.tag }
}));
if (tracksToAdd.length === 0) throw new Error('Playlist contained no playable tracks.');
// Fix: Use direct optional chaining on array access
responseEmbed
.setTitle('Playlist Added to Queue')
.setDescription(`**[${playlistInfo.name}](${identifier})** (${tracksToAdd.length} tracks)`) // Use filtered length
.addFields({ name: 'Starting track', value: `[${tracksToAdd[0]?.info?.title}](${tracksToAdd[0]?.info?.uri})` }); // Use direct optional chaining
logger.info(`Adding playlist: ${playlistInfo.name} (${tracksToAdd.length} tracks) (Guild: ${interaction.guildId})`);
break;
}
case 'empty': // Use 'empty'
await interaction.editReply(`No results found for "${query}".`);
// Optional: Leave if queue is empty?
// if (player && !player.track && player.queue.length === 0) {
// await shoukaku.leaveVoiceChannel(interaction.guildId);
// }
return; // Stop execution
case 'error': { // Use 'error'
const errorData = searchResult.data as LavalinkErrorData; // Cast to error structure
// Fix: Add explicit check for errorData
if (errorData) {
logger.error(`Failed to load track/playlist: ${errorData.message || 'Unknown reason'} (Severity: ${errorData.severity || 'Unknown'}, Identifier: ${identifier})`);
await interaction.editReply(`Failed to load track/playlist. Reason: ${errorData.message || 'Unknown error'}`);
} else {
logger.error(`Failed to load track/playlist: Unknown error (Identifier: ${identifier})`);
await interaction.editReply(`Failed to load track/playlist. Unknown error.`);
}
return; // Stop execution
}
default:
// Use exhaustive check pattern (will error if a case is missed)
const _exhaustiveCheck: never = searchResult.loadType;
logger.error(`Unknown loadType received: ${searchResult.loadType}`);
await interaction.editReply('Received an unknown response type from the music server.');
return;
}
// Add tracks to the player's queue (ensure player exists)
if (!player) {
// This case should ideally not happen if join logic is correct, but added as safeguard
throw new Error('Player is not defined after processing search results.');
}
player.queue.push(...tracksToAdd);
// Send confirmation embed
await interaction.editReply({ embeds: [responseEmbed] });
// 7. Start playback if not already playing
await playNext(player, interaction);
} catch (error: unknown) { // Catch errors during the process
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Error in play command for query "${query}" in guild ${interaction.guildId}: ${errorMsg}`, error);
// Use editReply as interaction is deferred
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch((e: unknown) => {
const replyErrorMsg = e instanceof Error ? e.message : String(e);
logger.error(`Failed to send error reply for play command: ${replyErrorMsg}`);
});
// Optional: Attempt to leave VC on critical error?
// if (shoukaku.players.has(interaction.guildId)) {
// await shoukaku.leaveVoiceChannel(interaction.guildId).catch(() => {});
// }
}
},
};

View File

@@ -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(())
})
}

View File

@@ -0,0 +1,64 @@
import { Events, Interaction } from 'discord.js'; // Import Interaction type
import logger from '../utils/logger'; // Use default import
import { BotClient } from '../index'; // Import BotClient type
export default { // Use export default
name: Events.InteractionCreate,
async execute(interaction: Interaction, client: BotClient) { // Add types
// Handle only slash commands (ChatInputCommand) for now
if (!interaction.isChatInputCommand()) return;
// Store command name after type check
const commandName = interaction.commandName;
// client.commands should be typed as Collection<string, CommandType> on BotClient
const command = client.commands.get(commandName);
if (!command) {
logger.error(`No command matching ${commandName} was found.`);
try {
// Check if interaction is replyable before attempting reply
if (interaction.isRepliable()) {
await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true });
}
} catch (replyError: unknown) { // Type caught error
const errorMsg = replyError instanceof Error ? replyError.message : String(replyError);
// Use stored commandName variable
logger.error(`Failed to send 'command not found' reply for command '${commandName}': ${errorMsg}`);
}
return;
}
try {
// Execute the command's logic
// Command execute function expects ChatInputCommandInteraction, but we check type above
await command.execute(interaction, client);
logger.info(`Executed command '${commandName}' for user ${interaction.user.tag}`);
} catch (error: unknown) { // Type caught error
const errorMsg = error instanceof Error ? error.message : String(error);
// Use stored commandName variable
logger.error(`Error executing command '${commandName}': ${errorMsg}`, 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 {
// Check if interaction is replyable before attempting reply/followUp
if (!interaction.isRepliable()) {
// Use stored commandName variable
logger.warn(`Interaction for command '${commandName}' is no longer replyable.`);
return;
}
if (interaction.replied || interaction.deferred) {
await interaction.followUp(replyOptions);
} else {
await interaction.reply(replyOptions);
}
} catch (replyError: unknown) { // Type caught error
const replyErrorMsg = replyError instanceof Error ? replyError.message : String(replyError);
// Use stored commandName variable
logger.error(`Failed to send error reply for command '${commandName}': ${replyErrorMsg}`);
}
}
},
};

31
src/events/ready.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Events, ActivityType, Client } from 'discord.js'; // Import base Client type
import logger from '../utils/logger'; // Use default import
import { initializeShoukaku } from '../structures/ShoukakuEvents'; // Import the correct setup function
import { BotClient } from '../index'; // Import BotClient type
export default { // Use export default
name: Events.ClientReady,
once: true, // This event should only run once
async execute(client: BotClient) { // Use BotClient type
// Ensure client.user is available
if (!client.user) {
logger.error('Client user is not available on ready event.');
return;
}
logger.info(`Ready! Logged in as ${client.user.tag}`);
// Initialize the Shoukaku instance and attach listeners
try {
// Assign the initialized Shoukaku instance to client.shoukaku
client.shoukaku = initializeShoukaku(client);
logger.info('Shoukaku instance initialized successfully'); // Log message adjusted slightly
} catch (error: unknown) { // Type caught error
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize Shoukaku: ${errorMsg}`);
// Depending on the severity, you might want to exit or handle this differently
}
// Set activity status
client.user.setActivity('Music | /play', { type: ActivityType.Listening });
},
};

View File

@@ -0,0 +1,60 @@
import { Events, VoiceState, ChannelType } from 'discord.js'; // Added ChannelType
import logger from '../utils/logger';
import { BotClient } from '../index'; // Assuming BotClient is exported from index
export default { // Use export default for ES modules
name: Events.VoiceStateUpdate,
execute(oldState: VoiceState, newState: VoiceState, client: BotClient) { // Added types
// 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 shoukaku = client.shoukaku; // Access Shoukaku instance
if (!shoukaku) {
// Shoukaku might not be initialized yet
logger.debug('Voice state update received, but Shoukaku is not ready yet.');
return;
}
const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection
if (!player) return; // No active player for this guild
// Get the connection associated with the player's guild
const connection = shoukaku.connections.get(player.guildId);
const currentChannelId = connection?.channelId; // Get channelId from connection
// Check if the bot was disconnected (newState has no channelId for the bot)
// Add null check for client.user
if (client.user && newState.id === client.user.id && !newState.channelId && oldState.channelId === currentChannelId) {
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 = currentChannelId ? client.channels.cache.get(currentChannelId) : undefined;
// Ensure the channel exists, is voice-based, and the update is relevant
if (channel?.isVoiceBased() && (newState.channelId === currentChannelId || oldState.channelId === currentChannelId)) {
// Fetch members again to ensure freshness after the update
const members = channel.members; // Safe to access members now
// Add null check for client.user
if (client.user && members.size === 1 && members.has(client.user.id)) {
logger.info(`Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`); // Safe to access name
// 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
}
}
},
};

View File

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

186
src/index.ts Normal file
View File

@@ -0,0 +1,186 @@
import dotenv from 'dotenv';
import {
Client,
GatewayIntentBits,
Collection,
Events,
BaseInteraction, // Use a base type for now, refine later if needed
SlashCommandBuilder, // Assuming commands use this
} from 'discord.js';
import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from 'shoukaku';
import logger from './utils/logger'; // Assuming logger uses export default or similar
import fs from 'fs';
import path from 'path';
// import { fileURLToPath } from 'url'; // Needed for __dirname in ES Modules if module is not CommonJS
// Define Command structure
interface Command {
data: Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">; // Or appropriate type for your command data
execute: (interaction: BaseInteraction, client: BotClient) => Promise<void>; // Adjust interaction type if needed
}
// Define Event structure
interface BotEvent {
name: string; // Should match discord.js event names or custom names
once?: boolean;
execute: (...args: any[]) => void; // Use specific types later if possible
}
// Extend the discord.js Client class to include custom properties
export interface BotClient extends Client { // Add export keyword
commands: Collection<string, Command>;
shoukaku: Shoukaku;
}
// --- Setup ---
dotenv.config();
// __dirname is available in CommonJS modules, which is set in tsconfig.json
// 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
],
}) as BotClient; // Assert the type here
// Define Shoukaku nodes
const Nodes: NodeOption[] = [
{
name: process.env.LAVALINK_NAME || 'lavalink-node-1', // Use an env var or default name
url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || 2333}`, // Use || 2333 for default port number
auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass', // Password from your Lavalink server config
secure: process.env.LAVALINK_SECURE === 'true', // Set to true if using HTTPS/WSS
},
];
// Shoukaku options
const shoukakuOptions: ShoukakuOptions = {
moveOnDisconnect: false, // Whether to move players to another node when a node disconnects
resume: true, // Whether to resume players session after Lavalink restarts
reconnectTries: 3, // Number of attempts to reconnect to Lavalink
reconnectInterval: 5000, // Interval between reconnect attempts in milliseconds
// Add other options as needed
};
// Initialize Shoukaku
client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, shoukakuOptions);
// 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<string, Command>();
// --- Command Loading ---
const commandsPath = path.join(__dirname, 'commands');
// Read .ts files now
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts'));
const loadCommands = async () => {
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
try {
// Use dynamic import for ES Modules/CommonJS interop
const commandModule = await import(filePath);
const command: Command = commandModule.default || commandModule; // Handle default exports
if (command && typeof command === 'object' && '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 or is not structured correctly.`);
}
} catch (error: unknown) { // Type the error as unknown
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error);
}
}
};
// --- Event Handling ---
const eventsPath = path.join(__dirname, 'events');
// Read .ts files now
const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith('.ts'));
const loadEvents = async () => {
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
try {
const eventModule = await import(filePath);
const event: BotEvent = eventModule.default || eventModule; // Handle default exports
if (event && typeof event === 'object' && 'name' in event && 'execute' in event) {
if (event.once) {
client.once(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client
logger.info(`Loaded event ${event.name} (once)`);
} else {
client.on(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client
logger.info(`Loaded event ${event.name}`);
}
} else {
logger.warn(`[WARNING] The event at ${filePath} is missing a required "name" or "execute" property or is not structured correctly.`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error loading event at ${filePath}: ${errorMessage}`, error);
}
}
};
// --- Shoukaku Event Handling ---
client.shoukaku.on('ready', (name: string) => logger.info(`Lavalink Node: ${name} is now connected`));
client.shoukaku.on('error', (name: string, error: Error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`));
client.shoukaku.on('close', (name: string, code: number, reason: string | undefined) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`));
// Corrected disconnect event signature based on common usage and error TS148
client.shoukaku.on('disconnect', (name: string, count: number) => {
logger.warn(`Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`);
});
// --- Main Execution ---
async function main() {
await loadCommands();
await loadEvents();
// Log in to Discord with your client's token
try {
await client.login(process.env.DISCORD_TOKEN);
logger.info('Successfully logged in to Discord.');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to log in: ${errorMessage}`);
process.exit(1); // Exit if login fails
}
}
main().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error during bot initialization: ${errorMessage}`, error);
process.exit(1);
});
// Basic error handling
process.on('unhandledRejection', (reason: unknown, promise: Promise<any>) => {
const reasonMessage = reason instanceof Error ? reason.message : String(reason);
logger.error('Unhandled promise rejection:', { reason: reasonMessage, promise });
});
process.on('uncaughtException', (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => {
logger.error(`Uncaught exception: ${error.message}`, { error, origin });
// Optional: exit process on critical uncaught exceptions
// process.exit(1);
});

View File

@@ -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 */ }
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from 'shoukaku'; // Removed player event types, Added Connectors
// import { Connectors } from 'shoukaku-discord.js'; // Use the discord.js connector - Removed this line
import logger from '../utils/logger';
import { BotClient } from '../index';
// Removed imports from play.ts for now as player listeners are removed
// Define Node options (replace with your actual Lavalink details from .env)
const nodes: NodeOption[] = [
{
name: process.env.LAVALINK_NAME || 'Lavalink-Node-1',
url: process.env.LAVALINK_URL || 'lavalink:2333', // Use service name for Docker Compose if applicable
auth: process.env.LAVALINK_AUTH || 'youshallnotpass',
secure: process.env.LAVALINK_SECURE === 'true' || false,
},
];
// Define Shoukaku options
const shoukakuOptions: ShoukakuOptions = {
moveOnDisconnect: false,
resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence
reconnectTries: 3,
reconnectInterval: 5, // In seconds
restTimeout: 15000, // In milliseconds
voiceConnectionTimeout: 15, // In seconds
};
// Function to initialize Shoukaku and attach listeners
export function initializeShoukaku(client: BotClient): Shoukaku {
if (!client) {
throw new Error("initializeShoukaku requires a client instance.");
}
const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, shoukakuOptions);
// --- Shoukaku Node Event Listeners ---
shoukaku.on('ready', (name, resumed) =>
logger.info(`Lavalink Node '${name}' ready. Resumed: ${resumed}`)
);
shoukaku.on('error', (name, error) =>
logger.error(`Lavalink Node '${name}' error: ${error.message}`, error)
);
shoukaku.on('close', (name, code, reason) =>
logger.warn(`Lavalink Node '${name}' closed. Code: ${code}. Reason: ${reason || 'No reason'}`)
);
// Fix: Correct disconnect listener signature
shoukaku.on('disconnect', (name, count) => {
// count = count of players disconnected from the node
logger.warn(`Lavalink Node '${name}' disconnected. ${count} players disconnected.`);
// If players were not moved, you might want to attempt to reconnect them or clean them up.
});
shoukaku.on('debug', (name, info) => {
// Only log debug messages if not in production or if explicitly enabled
if (process.env.NODE_ENV !== 'production' || process.env.LAVALINK_DEBUG === 'true') {
logger.debug(`Lavalink Node '${name}' debug: ${info}`);
}
});
// --- Shoukaku Player Event Listeners ---
// REMOVED - These need to be attached differently in Shoukaku v4 (e.g., when player is created)
logger.info("Shoukaku instance created and node event listeners attached.");
return shoukaku;
}

View File

@@ -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())
}

31
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,31 @@
import winston, { format, transports } from 'winston'; // Use ES6 import
// No longer needed: import { TransformableInfo } from 'logform';
// Define the type for the log info object after timestamp is added
// We can simplify this for now or try to infer from winston later
// type TimestampedLogInfo = TransformableInfo & {
// timestamp: string;
// };
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', // Use LOG_LEVEL from env or default to 'info'
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // This adds the timestamp
format.printf((info: any) => { // Use 'any' for now to bypass strict type checking here
// Ensure message exists, handle potential non-string messages if necessary
// The 'info' object structure depends on the preceding formatters
const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added
const level = (info.level || 'info').toUpperCase();
const message = typeof info.message === 'string' ? info.message : JSON.stringify(info.message);
return `${timestamp} ${level}: ${message}`;
})
),
transports: [
new transports.Console(),
// Optionally add file transport
// new transports.File({ filename: 'combined.log' }),
// new transports.File({ filename: 'error.log', level: 'error' }),
],
});
export default logger; // Use ES6 export default

View 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: string[]) => args.join('/'),
resolve: (...args: string[]) => args.join('/'),
};
});
describe('deploy-commands.js', () => {
let origEnv: typeof process.env;
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'));
});
});

View 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.ts Normal file
View 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/);
});
});

37
tsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* If NOT transpiling with TypeScript: */
"module": "NodeNext",
"noEmit": true,
/* If your code runs in the DOM: */
// "lib": ["es2022", "dom", "dom.iterable"],
/* If your code doesn't run in the DOM: */
"lib": ["ES2022"],
/* If transpiling with TypeScript: */
"module": "CommonJS", // Use CommonJS for Node.js compatibility
"outDir": "dist", // Output compiled JS to dist/
"sourceMap": true, // Generate source maps
/* Project Structure */
// "rootDir": "src", // Remove rootDir as include covers files outside src
"baseUrl": ".", // Allows for path aliases if needed
"paths": {
"@/*": ["src/*"] // Example path alias - keep if used, adjust if needed
}
},
"include": ["src/**/*.ts", "deploy-commands.ts", "tests/**/*.ts"], // Include source, deploy script, and tests
"exclude": ["node_modules", "dist"] // Exclude build output and dependencies
}

50
update-plugin.sh Executable file
View 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