Compare commits

...

7 Commits
main ... docker

Author SHA1 Message Date
aki
b445fd1421 chore(docker): Add .dockerignore 2025-04-20 00:54:50 +08:00
aki
472111bd57 build(docker): Add Docker Compose and Lavalink Configuration 2025-04-20 00:54:39 +08:00
aki
f339943e3b build(docker): Add Dockerfile 2025-04-20 00:54:15 +08:00
aki
ba96e8c32f chore: Add .env.example 2025-04-20 00:51:46 +08:00
aki
e7ea2481df style: Add rustfmt.toml 2025-04-20 00:49:28 +08:00
aki
44170c8ae7 Initial code upload 2025-04-20 00:49:17 +08:00
aki
fe4f0aec41 docs(README): updated README 2025-04-20 00:47:27 +08:00
18 changed files with 1190 additions and 2 deletions

9
.dockerignore Normal file
View File

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

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# .env (Docker Compose Version)
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
# Use the Docker Compose service name for the host
LAVALINK_HOST=lavalink
LAVALINK_PORT=2333
LAVALINK_PASSWORD=youshallnotpass # Must match application.yml

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[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"

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
# Dockerfile
# ---- 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
# Install cargo-chef for build caching
RUN apk add --no-cache musl-dev # Needed by chef
RUN cargo install cargo-chef --locked
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 ----
# Use the same Rust Alpine image for building.
FROM rust:1-alpine AS builder
WORKDIR /app
# Install build dependencies for Alpine
RUN apk add --no-cache build-base openssl-dev pkgconfig
# Install cargo-chef again (could optimize but simpler this way)
RUN cargo install cargo-chef --locked
# Copy the dependency recipe from the planner stage
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies based on the recipe. This layer is cached efficiently.
RUN cargo chef cook --release --recipe-path recipe.json
# Copy application code
COPY . .
# Build the application binary, linking against pre-built dependencies
# Ensure the binary name matches your package name in Cargo.toml
RUN cargo build --release --bin discord-music-bot-lavalink-rs
# ---- Runtime Stage ----
# Use a minimal Alpine image for the final runtime environment.
FROM alpine:latest
WORKDIR /app
# Install runtime dependencies needed by the binary (e.g., SSL certs)
RUN apk add --no-cache ca-certificates openssl
# Copy the compiled application binary from the builder stage
COPY --from=builder /app/target/release/discord-music-bot-lavalink-rs /app/
# Set the binary as the entrypoint
CMD ["./discord-music-bot-lavalink-rs"]

225
README.md
View File

@ -1,3 +1,224 @@
# discord-music-bot
# Rust LavaLink Discord Music Bot Prototype
Discord bot made in Rust using serenity and lavalink-rs
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.
## Features
* Connects to Discord via the Gateway.
* Connects to a LavaLink node for audio processing.
* Slash command support for core music functions:
* `/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
### For Native Execution
1. **Rust Toolchain:** Install Rust using [rustup](https://rustup.rs/). (`rustup update` recommended).
2. **LavaLink Server:**
* 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
1. **Clone the Repository:**
```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
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
# --- 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_PASSWORD=youshallnotpass # Must match password in application.yml
```
**Note:** Ensure the `LAVALINK_PASSWORD` here matches the one in `application.yml`.
## Running the Bot
You can run the bot natively or using Docker Compose.
### Option 1: Running with Docker Compose (Recommended)
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
```
* `--build`: Builds the bot image. Needed the first time or after code changes.
* `-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
```
discord-music-bot-lavalink-rs/
├── .env # Local environment variables (ignored by git)
├── .env.example # Example environment file
├── .dockerignore # Files ignored by Docker context
├── Dockerfile # Instructions to build the bot's Docker image
├── docker-compose.yml # Defines bot and LavaLink services for Docker Compose
├── Lavalink.jar # LavaLink server executable (add locally)
├── 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
* [Serenity](https://github.com/serenity-rs/serenity): Discord API library for Rust.
* [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.

32
application.yml Normal file
View File

@ -0,0 +1,32 @@
# application.yml (Example)
server: # REST and WS server
port: 2333
address: 0.0.0.0 # Listen on all interfaces within the container
lavalink:
server:
password: "youshallnotpass" # CHANGE THIS to a strong password
sources:
youtube: true
bandcamp: true
soundcloud: true
twitch: true
vimeo: true
http: true
local: false
# bufferDurationMs: 400 # How many milliseconds of audio to buffer? Lower values are less safe.
# youtubePlaylistLoadLimit: 6 # Number of pages at 100 tracks each
# playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
# youtubeSearchEnabled: true
# soundcloudSearchEnabled: true
# bandcampSearchEnabled: true # Not implemented by LavaLink currently
# Default values are generally fine
# You can find more options here: https://github.com/lavalink-devs/Lavalink/blob/master/IMPLEMENTATION.md#configuration
logging:
file:
max-history: 30
max-size: 1GB
path: ./logs/
level:
root: INFO
lavalink: INFO

46
compose.yml Normal file
View File

@ -0,0 +1,46 @@
# 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:

3
rustfmt.toml Normal file
View File

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

57
src/commands/join.rs Normal file
View File

@ -0,0 +1,57 @@
// 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(())
})
}

49
src/commands/leave.rs Normal file
View File

@ -0,0 +1,49 @@
// 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(())
})
}

168
src/commands/play.rs Normal file
View File

@ -0,0 +1,168 @@
// 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(())
})
}

45
src/commands/skip.rs Normal file
View File

@ -0,0 +1,45 @@
// 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(())
})
}

199
src/handler.rs Normal file
View File

@ -0,0 +1,199 @@
// 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

85
src/lavalink_handler.rs Normal file
View File

@ -0,0 +1,85 @@
// src/lavalink_handler.rs
use serenity::http::Http; // Import Http for sending messages
use lavalink_rs::gateway::{NodeEvent, LavalinkEventHandler as LavalinkEventHandlerRs};
use tracing::{error, info, instrument};
use async_trait::async_trait; // Needed to implement async trait methods
use std::sync::Arc; // Needed for Arc<Http>
// --- lavalink-rs Event Handler ---
// This handler receives events directly from the connected Lavalink server.
// It allows reacting to playback events like track start/end.
pub struct MyLavalinkEventHandlerRs {
pub http: Arc<Http>, // Hold the Http client to send messages to Discord
// Add state if needed, e.g., channel IDs for announcements per guild.
}
#[async_trait]
impl LavalinkEventHandlerRs for MyLavalinkEventHandlerRs {
// The main handler function for all Lavalink NodeEvents.
#[instrument(skip(self))] // Adds tracing to the handler
async fn handle(&self, event: NodeEvent) {
// You can uncomment this for verbose logging of all events:
// info!("Received LavaLink event: {:?}", event);
match event {
// Handle specific event types
NodeEvent::TrackStart { guild_id, track, .. } => {
info!(
"Track started on guild {}: {}",
guild_id,
track.info.title
);
// Example: send a message announcing the track.
// This requires knowing which channel to send it to, often
// stored per guild in the BotState's announcement_channels.
// Accessing BotState would require passing it here or using a global static.
// let channel_id = { /* logic to get channel_id from guild_id using self.http or shared state */ };
// if let Some(channel_id) = channel_id {
// if let Err(e) = ChannelId(channel_id).say(&self.http,
// format!("Now playing: **{}** by **{}**", track.info.title, track.info.author)
// ).await {
// error!("Failed to send track start message: {}", e);
// }
// }
}
NodeEvent::TrackEnd { guild_id, track, reason, .. } => {
info!(
"Track ended on guild {}: {}, reason: {:?}",
guild_id,
track.info.title,
reason
);
// Logic for track end, e.g., cleaning up state if not using Lavalink queues
}
NodeEvent::TrackException { guild_id, track, error, .. } => {
error!(
"Track exception on guild {}: {}, error: {}",
guild_id,
track.info.title,
error
);
// Notify the channel about the exception
}
NodeEvent::TrackStuck { guild_id, track, threshold_ms, .. } => {
error!(
"Track stuck on guild {}: {}, threshold: {}ms",
guild_id,
track.info.title,
threshold_ms
);
// Notify the channel about the stuck track
}
NodeEvent::WebSocketOpen { node, .. } => {
info!("Lavalink WebSocket opened: {}", node);
}
NodeEvent::WebSocketClosed { node, code, reason, .. } => {
error!("Lavalink WebSocket closed: {}, Code: {:?}, Reason: {}", node, code, reason);
}
// Ignore these common events to avoid log spam
NodeEvent::Ready { .. } | NodeEvent::Stats { .. } => { /* Ignore */ }
NodeEvent::PlayerUpdate { .. } => { /* Ignore */ }
}
}
}

123
src/main.rs Normal file
View File

@ -0,0 +1,123 @@
// src/main.rs
// Declare the modules used in this crate
mod handler;
mod state;
mod commands;
mod lavalink_handler;
mod utils;
// Import necessary types from our modules
use handler::Handler;
use state::BotState;
use lavalink_handler::MyLavalinkEventHandlerRs;
// Import necessary types from external crates
use serenity::client::Client;
use serenity::prelude::GatewayIntents; // Needed to subscribe to events
use lavalink_rs::LavalinkClient;
use tokio::sync::Mutex;
use tracing::{error, info, instrument};
use std::sync::Arc; // For shared ownership
use std::env; // To read environment variables
// The entry point of the application
#[tokio::main]
#[instrument] // Adds tracing to the main function
async fn main() {
// Initialize tracing for logging output
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO) // Set logging level
.init();
// Load environment variables from .env file
dotenv::dotenv().ok();
// Get credentials from environment variables
let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set");
let lavalink_host = env::var("LAVALINK_HOST")
.expect("LAVALINK_HOST not set");
let lavalink_port = env::var("LAVALINK_PORT")
.expect("LAVALINK_PORT not set")
.parse::<u16>()
.expect("Invalid LAVALINK_PORT");
let lavalink_password = env::var("LAVALINK_PASSWORD")
.expect("LAVALINK_PASSWORD not set");
// Define the gateway intents needed for the bot
// GUILDS: To receive guild information for commands and cache
// GUILD_VOICE_STATES: Crucial for receiving voice state updates
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
// Build the Serenity client. Need the await here because of the async builder.
let mut serenity_client = Client::builder(&token, intents)
.await
.expect("Error creating serenity client");
// Clone the Arc<Http> client from Serenity. We need this to be able to send
// HTTP requests (like sending messages) from places that don't have the full Context,
// such as the Lavalink event handler.
let http = serenity_client.cache_and_http.http.clone();
// Get the bot's user ID from the cache. This is needed to initialize the Lavalink client.
let bot_user_id = serenity_client
.cache_and_http
.cache
.current_user_id()
.await
.expect("Failed to get bot user ID from cache");
// Create the Lavalink client builder
let mut lavalink_client_builder = LavalinkClient::builder(bot_user_id);
// Set Lavalink node details
lavalink_client_builder
.set_host(lavalink_host)
.set_port(lavalink_port)
.set_password(lavalink_password);
// Create and set the custom Lavalink event handler, passing the http client
let lavalink_event_handler = Arc::new(MyLavalinkEventHandlerRs {
http: http.clone() // Clone Http again for the lavalink event handler
});
lavalink_client_builder.set_event_handler(lavalink_event_handler);
// Build the Lavalink client
let lavalink_client = lavalink_client_builder.build();
// Create and store the shared state (BotState)
let bot_state = Arc::new(Mutex::new(BotState {
lavalink: lavalink_client.clone(), // Clone lavalink client for the handler struct
http: http, // Store the Http client clone in the state
// announcement_channels: Mutex::new(HashMap::new()), // Optional: for announcements
}));
// Set the Serenity event handler, passing the shared state
serenity_client.event_handler(Handler { state: bot_state });
// Spawn the lavalink-rs client's main run task. This task will manage the
// WebSocket connection to the Lavalink server and process its events.
let lavalink_task = tokio::spawn(async move {
info!("Starting lavalink-rs client task...");
if let Err(why) = lavalink_client.run().await {
error!("Lavalink client task error: {:?}", why);
}
info!("Lavalink client task stopped.");
});
// Start the Serenity client gateway. This connects the bot to Discord
// and begins receiving events. This call is blocking until the client stops.
info!("Starting serenity client gateway...");
if let Err(why) = serenity_client.start().await {
error!("Serenity client gateway error: {:?}", why);
}
info!("Serenity client gateway stopped.");
// Wait for the lavalink task to finish.
// In a real bot, this task should ideally never finish unless there's a critical error.
let _ = lavalink_task.await;
info!("Bot shut down.");
}

41
src/mod.rs Normal file
View File

@ -0,0 +1,41 @@
// src/commands/mod.rs
use anyhow::Result;
use lavalink_rs::LavalinkClient;
use serenity::{
builder::CreateApplicationCommandOption, // Correct import path
client::Context,
model::prelude::interaction::application_command::ApplicationCommandInteraction,
};
use std::future::Future;
use std::pin::Pin;
use futures::future::BoxFuture; // Use BoxFuture for convenience
// --- Command Definition ---
// Type alias for the async command handler function signature
pub type CommandHandler = for<'a> fn(
&'a Context,
&'a ApplicationCommandInteraction,
&'a LavalinkClient,
) -> BoxFuture<'a, Result<()>>;
// Structure to hold all information needed to register and execute a command
pub struct SlashCommand {
pub name: &'static str,
pub description: &'static str,
// Function to build command options (lazily evaluated)
pub options: fn() -> Vec<CreateApplicationCommandOption>,
// The async handler function
pub handler: CommandHandler,
}
// Tell `inventory` to collect all `SlashCommand` structs submitted globally.
inventory::collect!(SlashCommand);
// --- Submodule Declarations ---
// Declare all files in the commands/ directory as submodules
pub mod join;
pub mod leave;
pub mod play;
pub mod skip;

17
src/state.rs Normal file
View File

@ -0,0 +1,17 @@
// 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>>,
}

25
src/utils.rs Normal file
View File

@ -0,0 +1,25 @@
// 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())
}