diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2bd5dba --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DISCORD_TOKEN=your_token_here +LAVALINK_HOST=127.0.0.1 +LAVALINK_PORT=2333 +LAVALINK_PASSWORD=your_password_here diff --git a/.gitignore b/.gitignore index ab951f8..637f657 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,21 @@ # Generated by Cargo # will have compiled files and executables debug/ -target/ +/target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock +# dotenv environment variables +.env + +# VSCode settings +.vscode/ + +# macOS +.DS_Store + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..725b448 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "discord-music-bot" +version = "1.0.0" +edition = "2021" + +[dependencies] +serenity = { version = "0.12", features = ["client", "gateway", "cache", "model", "http", "builder", "voice", "rustls_backend", "application"] } +lavalink-rs = { version = "0.14", features = ["serenity", "tungstenite-rustls-native-roots"] } +tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] } +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +dotenv = "0.15" +futures = "0.3" +inventory = { version = "0.3", features = ["proc-macro"] } +url = "2.4" diff --git a/README.md b/README.md index 77c58ca..1dc9873 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,57 @@ # discord-music-bot -Discord bot made in Rust using serenity and lavalink-rs \ No newline at end of file +Discord music bot template written in Rust using `serenity` and `lavalink-rs`. + +## Features +- Slash commands: `/ping`, `/join`, `/play`, `/leave` +- LavaLink integration for audio playback +- Modular command handler structure + +## Prerequisites +- Rust (stable toolchain) +- A Discord application with bot token +- LavaLink server running (see [LavaLink](https://github.com/freyacodes/Lavalink)) + +## Setup + +1. Copy `.env.example` to `.env` and fill in your credentials: + ``` + DISCORD_TOKEN=your_discord_bot_token + LAVALINK_HOST=127.0.0.1 + LAVALINK_PORT=2333 + LAVALINK_PASSWORD=your_lavalink_password + ``` + +2. Build the project: + ```sh + cargo build --release + ``` + +3. Run the bot: + ```sh + cargo run --release + ``` + +## Project Structure + +- `src/main.rs` - Bot entry point, initializes Serenity and LavaLink clients +- `src/handler.rs` - Serenity event handlers (ready, interaction, voice updates) +- `src/lavalink_handler.rs` - LavaLink event handlers +- `src/state.rs` - TypeMap keys for shared state +- `src/utils.rs` - Utility functions (env management) +- `src/commands/` - Modular slash command definitions and handlers + +## Commands + +- `/ping` - Replies with "Pong!" +- `/join` - Bot joins your voice channel +- `/play url:` - Plays audio from the given URL +- `/leave` - Bot leaves the voice channel + +## TODO +- Implement actual command logic in `src/commands/*.rs` +- Add error handling and command concurrency management +- Expand LavaLink event handlers + +## License +MIT \ No newline at end of file diff --git a/src/commands/join.rs b/src/commands/join.rs new file mode 100644 index 0000000..0d4c58f --- /dev/null +++ b/src/commands/join.rs @@ -0,0 +1,16 @@ +use super::SlashCommand; +use serenity::builder::CreateCommand; +use serenity::prelude::Context; +use serenity::model::application::interaction::Interaction; +use anyhow::Result; + +inventory::submit! { + SlashCommand { + name: "join", + register: |c| c.name("join").description("Joins your voice channel"), + handler: |ctx: Context, interaction: Interaction| Box::pin(async move { + // TODO: Implement join logic (e.g., move bot to user voice channel) + Ok(()) + }), + } +} \ No newline at end of file diff --git a/src/commands/leave.rs b/src/commands/leave.rs new file mode 100644 index 0000000..2d1f519 --- /dev/null +++ b/src/commands/leave.rs @@ -0,0 +1,16 @@ +use super::SlashCommand; +use serenity::builder::CreateApplicationCommand; +use serenity::prelude::Context; +use serenity::model::interactions::Interaction; +use anyhow::Result; + +inventory::submit! { + SlashCommand { + name: "leave", + register: |c| c.name("leave").description("Leaves the voice channel"), + handler: |ctx: Context, interaction: Interaction| Box::pin(async move { + // TODO: Implement leave logic (e.g., disconnect from voice channel) + Ok(()) + }), + } +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..5d26961 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,26 @@ +use serenity::builder::CreateCommand; +use serenity::prelude::Context; +use serenity::model::interactions::Interaction; +use anyhow::Result; + +/// Defines a slash command with its registration and handler. +pub struct SlashCommand { + pub name: &'static str, + pub register: fn(&mut CreateCommand) -> &mut CreateCommand, + pub handler: fn(Context, Interaction) -> std::pin::Pin> + Send>>, +} + +inventory::collect!(SlashCommand); + +/// Returns all registered slash commands. +pub fn get_slash_commands() -> Vec<&'static SlashCommand> { + inventory::iter:: + .into_iter() + .collect() +} + +// Register individual command modules +pub mod ping; +pub mod join; +pub mod play; +pub mod leave; \ No newline at end of file diff --git a/src/commands/ping.rs b/src/commands/ping.rs new file mode 100644 index 0000000..5830706 --- /dev/null +++ b/src/commands/ping.rs @@ -0,0 +1,16 @@ +use super::SlashCommand; +use serenity::builder::CreateCommand; +use serenity::prelude::Context; +use serenity::model::application::interaction::Interaction; +use anyhow::Result; + +inventory::submit! { + SlashCommand { + name: "ping", + register: |c: &mut CreateCommand| c.name("ping").description("Replies with Pong!"), + handler: |ctx: Context, interaction: Interaction| Box::pin(async move { + // TODO: Implement ping logic (e.g., respond with "Pong!") + Ok(()) + }), + } +} \ No newline at end of file diff --git a/src/commands/play.rs b/src/commands/play.rs new file mode 100644 index 0000000..ac26f71 --- /dev/null +++ b/src/commands/play.rs @@ -0,0 +1,26 @@ +use super::SlashCommand; +use serenity::builder::CreateApplicationCommand; +use serenity::model::interactions::application_command::ApplicationCommandOptionType; +use serenity::prelude::Context; +use serenity::model::interactions::Interaction; +use anyhow::Result; + +inventory::submit! { + SlashCommand { + name: "play", + register: |c| { + c.name("play") + .description("Plays audio from a given URL") + .create_option(|o| { + o.name("url") + .description("Track URL to play") + .kind(ApplicationCommandOptionType::String) + .required(true) + }) + }, + handler: |ctx: Context, interaction: Interaction| Box::pin(async move { + // TODO: Implement play logic (e.g., fetch URL argument and instruct Lavalink to play) + Ok(()) + }), + } +} \ No newline at end of file diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..2f5f33e --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,49 @@ +use serenity::async_trait; +use serenity::model::gateway::Ready; +use serenity::model::application::interaction::{Interaction, InteractionResponseType}; +use serenity::model::channel::VoiceState; +use serenity::prelude::*; +use anyhow::Result; +use tracing::info; +use crate::commands::get_slash_commands; +use crate::state::LavalinkClientKey; + +pub struct Handler; + +#[async_trait] +impl EventHandler for Handler { + async fn ready(&self, ctx: Context, ready: Ready) { + info!("{} is connected!", ready.user.name); + let commands = get_slash_commands(); + for cmd in commands { + match ctx.http.create_global_application_command(|c| (cmd.register)(c)).await { + Ok(_) => info!("Registered slash command: {}", cmd.name), + Err(err) => info!("Failed to register {}: {:?}", cmd.name, err), + } + } + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + if let Interaction::ApplicationCommand(ref command) = interaction { + if let Err(err) = command.defer(&ctx.http).await { + info!("Failed to defer response: {:?}", err); + } + for cmd in get_slash_commands() { + if cmd.name == command.data.name { + if let Err(err) = (cmd.handler)(ctx.clone(), interaction.clone()).await { + info!("Command error {}: {:?}", cmd.name, err); + } + } + } + } + } + + async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { + if let Some(guild) = new.guild_id { + let data_read = ctx.data.read().await; + if let Some(lavalink) = data_read.get::() { + lavalink.on_voice_state_update(&new).await; + } + } + } +} \ No newline at end of file diff --git a/src/lavalink_handler.rs b/src/lavalink_handler.rs new file mode 100644 index 0000000..095d059 --- /dev/null +++ b/src/lavalink_handler.rs @@ -0,0 +1,37 @@ +// src/lavalink_handler.rs +use anyhow::Result; +use tracing::info; +use async_trait::async_trait; +use lavalink_rs::prelude::{LavalinkClient, LavalinkClientBuilder, EventHandler, TrackStartEvent, TrackEndEvent}; +use crate::utils::env_var; + +/// Handles Lavalink events such as track start and end. +pub struct LavalinkHandler; + +#[async_trait] +impl EventHandler for LavalinkHandler { + async fn track_start(&self, _client: &LavalinkClient, event: &TrackStartEvent) { + info!("Track started: {}", event.track); + } + + async fn track_end(&self, _client: &LavalinkClient, event: &TrackEndEvent) { + info!("Track ended: {}", event.track); + } + + // TODO: Add more event handlers as needed +} + +/// Initializes and returns a Lavalink client. +pub async fn create_lavalink_client() -> Result { + let host = env_var("LAVALINK_HOST"); + let port = env_var("LAVALINK_PORT").parse::()?; + let password = env_var("LAVALINK_PASSWORD"); + let url = format!("ws://{}:{}", host, port); + + let builder = LavalinkClientBuilder::new(&url) + .password(&password) + .event_handler(LavalinkHandler); + + let client = builder.build().await?; + Ok(client) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6da5d11 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +// src/main.rs +mod handler; +mod lavalink_handler; +mod state; +mod utils; +mod commands; + +use anyhow::Result; +use dotenv::dotenv; +use std::env; +use tracing_subscriber; +use serenity::prelude::TypeMapKey; +use serenity::Client; +use crate::lavalink_handler::create_lavalink_client; + +#[tokio::main] +async fn main() -> Result<()> { + dotenv().ok(); + tracing_subscriber::fmt().with_env_filter("info").init(); + + let token = env::var("DISCORD_TOKEN")?; + + // Initialize Lavalink client + let lavalink_client = create_lavalink_client().await?; + + let mut client = Client::builder(&token) + .event_handler(handler::Handler) + .await?; + + { + let mut data = client.data.write().await; + data.insert::(lavalink_client); + } + + client.start().await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..05b53e6 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,8 @@ +// src/state.rs +use serenity::prelude::TypeMapKey; +use lavalink_rs::prelude::LavalinkClient; + +pub struct LavalinkClientKey; +impl TypeMapKey for LavalinkClientKey { + type Value = LavalinkClient; +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..67d2f45 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,7 @@ +// src/utils.rs +use std::env; + +/// Retrieves an environment variable or panics if it's not set. +pub fn env_var(key: &str) -> String { + env::var(key).expect(&format!("Environment variable {} not set", key)) +}