From d54b8d3823f283e54af51df2b20b98be3c03cb7d Mon Sep 17 00:00:00 2001 From: Benjamin Sherriff Date: Wed, 5 Jul 2023 09:32:33 -0400 Subject: [PATCH] Adding play command --- .env.TEMPLATE | 1 + Cargo.toml | 2 +- README.md | 27 ++++++++--- docker-compose.yml | 2 +- src/commands/audio/mod.rs | 80 +++++++++++++++++++++++++++++++- src/commands/audio/play.rs | 94 +++++++++++++++++++++++++++++++++++--- src/main.rs | 54 +++++++++++----------- 7 files changed, 218 insertions(+), 42 deletions(-) diff --git a/.env.TEMPLATE b/.env.TEMPLATE index 3cbac47..0289214 100644 --- a/.env.TEMPLATE +++ b/.env.TEMPLATE @@ -1,4 +1,5 @@ DISCORD_TOKEN= +RUST_LOG=warn,siren=info POSTGRES_USER= POSTGRES_PASSWORD= POSTGRES_DB= \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 54e4223..a96b0a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] dotenv = "0.15.0" -serenity = "0.11" +serenity = { version = "0.11", features = ["http"] } tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/README.md b/README.md index eb2e938..ad04f26 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,36 @@ A D&D Bot built for Discord. Includes music support and assistant DM tools. ## Running -Visit the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application. -Guides and more information are available [here](https://discord.com/developers/docs/intro). +Visit the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application. Click [here](https://discord.com/developers/docs/intro) for guides and more information. + +Required Scopes: + - bot + - application.commands + +Example Invite: +``` +https://discord.com/api/oauth2/authorize?client_id=&permissions=40671259392832&scope=bot%20applications.commands +``` + - The CLIENT_ID can be found in the General Information tab on the Discord Developer Portal for your application, under `Application ID` + +Start the application with `docker compose up -d` +- Requires [Docker](https://www.docker.com/) ## Contributing -Rust must be installed to run locally. +[Rust](https://www.rust-lang.org/) must be installed to run locally. -Furthermore, the following packages must be installed for [serenity-rs/songbird](https://github.com/serenity-rs/songbird) +Furthermore, the following packages must be installed for [serenity-rs/songbird](https://github.com/serenity-rs/songbird). View the repository for additional installation and setup information on other operating systems. ``` sudo apt install libopus-dev sudo apt install ffmpeg sudo apt apt install youtube-dl ``` -The application can instead be tested from within a docker container. +Copy `.env.TEMPLATE` to `.env` and fill out the fields +Run `cargo run` to begin the application + +The application can be tested from within a Docker container: ``` docker build -t siren . -docker run --env-file .env -it --rm --name siren siren +docker run --env-file .env -it --rm --name siren siren:latest ``` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 78617f4..9db9103 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: args: - VERSION=${SIREN_VERSION} volumes: - - ./app:/app + - ./app:/usr/src/siren environment: DISCORD_TOKEN: ${DISCORD_TOKEN} DATABASE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} diff --git a/src/commands/audio/mod.rs b/src/commands/audio/mod.rs index 4215046..1d3c3f1 100644 --- a/src/commands/audio/mod.rs +++ b/src/commands/audio/mod.rs @@ -1,6 +1,84 @@ +use log::debug; + +use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction}; +use serenity::model::prelude::{GuildId, ChannelId}; +use serenity::model::user::User; +use serenity::prelude::*; +use songbird::id::GuildId as SongbirdGuildId; + pub mod pause; pub mod play; pub mod resume; pub mod skip; pub mod stop; -pub mod volume; \ No newline at end of file +pub mod volume; + +pub async fn join(ctx: &Context, guild_id_option: &Option, user: &User) -> Result<(), String> { + let guild_id = match guild_id_option { + Some(g) => g, + None => { + return Err(format!("{}", "No guild ID set")); + } + }; + + let channel_id = match find_voice_channel(&ctx, &guild_id, &user) { + Ok(channel) => channel, + Err(err) => return Err(format!("{}", err)) + }; + + debug!("<{}> Joining channel {}", guild_id.0, channel_id); + let manager = songbird::get(ctx).await.expect("Songbird Voice client placed in at initialization").clone(); + let (_handle_lock, success) = manager.join(guild_id.to_owned(), channel_id.to_owned()).await; + match success { + Ok(s) => Ok(s), + Err(err) => Err(format!("{}", err)) + } +} + +pub async fn leave(ctx: &Context, guild_id_option: &Option) -> Result<(), String> { + let guild_id = match guild_id_option { + Some(g) => g, + None => { + return Err(format!("{}", "No guild ID set")); + } + }; + + let songbird_guild_id = SongbirdGuildId { + 0: guild_id.0 + }; + + let manager = songbird::get(ctx).await.expect("Songbird Voice client placed in at initialization").clone(); + if manager.get(songbird_guild_id).is_some() { + debug!("<{}> Disconnecting from channel", guild_id.0); + if let Err(e) = manager.remove(songbird_guild_id).await { + return Err(format!("{}", e)) + } + } + Ok(()) +} + +fn find_voice_channel(ctx: &Context, guild_id: &GuildId, user: &User) -> Result { + let guild = match guild_id.to_guild_cached(ctx.cache.to_owned()) { + Some(g) => g, + None => return Err(format!("Guild not found")) + }; + + match guild.voice_states.get(&user.id).and_then(|voice_state| voice_state.channel_id) { + Some(channel) => Ok(channel), + None => return Err(format!("User is not in a voice channel")) + } +} + +pub async fn create_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result<(), SerenityError> { + command.create_interaction_response(&ctx.http, |response: &mut serenity::builder::CreateInteractionResponse<'_>| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message: &mut serenity::builder::CreateInteractionResponseData<'_>| message.content(content)) + }).await +} + +pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteraction, content: String) -> Result { + command.edit_original_interaction_response(&ctx.http, |response: &mut serenity::builder::EditInteractionResponse| { + response.content(content) + }).await +} diff --git a/src/commands/audio/play.rs b/src/commands/audio/play.rs index 4e00ffc..7838b17 100644 --- a/src/commands/audio/play.rs +++ b/src/commands/audio/play.rs @@ -1,11 +1,91 @@ -use log::debug; -use serenity::{model::prelude::{interaction::application_command::CommandDataOption, application_command::CommandDataOptionValue}, builder::CreateApplicationCommand}; -use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; +use log::{debug, warn, error}; -pub async fn run(command: &ApplicationCommandInteraction) -> String { - let track_option: &CommandDataOptionValue = command.data.options.get(0).expect("Expected track option").resolved.as_ref().expect("Expected track option to be resolved"); - debug!("Play command executed with track: {:?}", track_option); - "Playing xyz".to_string() +use serenity::prelude::*; +use serenity::builder::{CreateApplicationCommand}; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; +use songbird::id::GuildId as SongbirdGuildId; +use songbird::input; + +use crate::commands::audio::{join, leave}; + +use super::{create_response, edit_response}; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + let track_option = match command.data.options.get(0) { + Some(t) => match &t.value { + Some(v) => match v.as_str() { + Some(s) => s.to_owned(), + None => { + warn!("Missing track option"); + return + } + } + None => { + warn!("Missing track option"); + return + } + } + None => { + warn!("Missing track option"); + return + } + }; + + match create_response(&ctx, &command, format!("Playing track \"{}\"", track_option)).await { + Ok(_) => { + match join(&ctx, &command.guild_id, &command.user).await { + Ok(_) => { + let guild_id = match command.guild_id { + Some(g) => g, + None => { + let _ = edit_response(&ctx, &command, "Unable to join voice channel".to_string()); + return; + } + }; + debug!("Play command executed with track: {:?}", track_option); + + let songbird_guild_id = SongbirdGuildId { + 0: guild_id.0 + }; + let manager = songbird::get(ctx).await.expect("Songbird Voice client placed in at initialization").clone(); + if let Some(handler_lock) = manager.get(songbird_guild_id) { + let mut handler = handler_lock.lock().await; + let source: input::Input; + if track_option.starts_with("http") { + // Play remote track + source = match input::ytdl(&track_option).await { + Ok(source) => source, + Err(why) => { + warn!("Unable to get source: {}", why); + let _ = leave(&ctx, &command.guild_id).await; + return; + } + }; + } else if track_option.starts_with("#") { + // Play tracks based on tag + let _ = leave(&ctx, &command.guild_id).await; + return; + } else { + // Play local track + let _ = leave(&ctx, &command.guild_id).await; + return; + }; + + let _song = handler.play_source(source); + } + }, + Err(err) => { + warn!("{}", err); + let _ = edit_response(&ctx, &command, "Unable to join voice channel".to_string()); + } + } + } + Err(why) => { + error!("Failed to create a response message: {}", why); + let _ = edit_response(&ctx, &command, "Unable to play track".to_string()); + return; + } + } } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { diff --git a/src/main.rs b/src/main.rs index 5b28237..afec996 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,16 @@ use std::collections::HashSet; use std::env; +use commands::audio::create_response; use dotenv::dotenv; use log::{error, warn, info}; use serenity::async_trait; use serenity::framework::StandardFramework; -use serenity::model::application::interaction::{Interaction, InteractionResponseType}; +use serenity::model::application::interaction::Interaction; use serenity::model::gateway::Ready; use serenity::http::Http; -use serenity::model::prelude::GuildId; use serenity::prelude::*; +use songbird::SerenityInit; mod commands; struct Handler; @@ -18,43 +19,43 @@ struct Handler; impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { if let Interaction::ApplicationCommand(command) = interaction { - let content: String = match command.data.name.as_str() { - "ping" => commands::ping::run(&command.data.options), - "play" => commands::audio::play::run(&command).await, - _ => "Unknown command".to_string() - }; - - if let Err(why) = command.create_interaction_response(&ctx.http, |response: &mut serenity::builder::CreateInteractionResponse<'_>| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message: &mut serenity::builder::CreateInteractionResponseData<'_>| message.content(content)) - }).await { - warn!("Cannot respond to slash command: {}", why); + match command.data.name.as_str() { + "play" => commands::audio::play::run(&ctx, &command).await, + _ => { + let content: String = match command.data.name.as_str() { + "ping" => commands::ping::run(&command.data.options), + _ => "Unknown command".to_string() + }; + + if let Err(why) = create_response(&ctx, &command, content).await { + warn!("Cannot respond to slash command: {}", why); + } + } } } } async fn ready(&self, ctx: Context, ready: Ready) { + if ready.guilds.is_empty() { + warn!("No ready guilds found"); + } for guild in ready.guilds { - if let Some(guild) = guild.id.to_guild_cached(&ctx.cache) { - info!("{} is connected to {}", ready.user.name, guild.name); - let commands: Result, SerenityError> = GuildId::set_application_commands(&guild.id, &ctx.http, |commands| { - commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) - .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) }) - }).await; - match commands { - Ok(commands) => info!("Registered {} commands", commands.len()), - Err(why) => error!("Could not register commands: {:?}", why) - } - } + let commands = guild.id.set_application_commands(&ctx.http, |commands| { + commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) }) + }).await; + match commands { + Ok(c) => info!("Registered {} commands for guild {}", c.len(), guild.id.0), + Err(why) => error!("Could not register commands for guild {}: {:?}", guild.id.0, why) + }; } } } #[tokio::main] async fn main() { - env_logger::init(); dotenv().ok(); + env_logger::init(); let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let intents: GatewayIntents = GatewayIntents::all(); @@ -85,6 +86,7 @@ async fn main() { let mut client = Client::builder(token, intents) .event_handler(Handler) .framework(framework) + .register_songbird() .await .expect("Error creating client");