commit 6bcedfd0cd803bec5903867beb8899fe59e5dde7 Author: Alex Page Date: Tue Sep 14 00:27:19 2021 -0400 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3645101 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target +Dockerfile +.dockerignore +.git +.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ea5efc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +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 + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +.vscode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c59f5d9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dj-kitty-cat" +version = "0.1.0" +authors = ["my name "] +edition = "2018" + +[dependencies] +tracing = "0.1" +tracing-subscriber = "0.2" +tracing-futures = "0.2" +songbird = "0.2" + +[dependencies.serenity] +version = "0.10" +default-features = false +features = ["cache", "client", "gateway", "model", "voice", "rustls_backend", "unstable_discord_api"] + +[dependencies.tokio] +version = "1.0" +features = ["macros", "rt-multi-thread", "signal"] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a78bb4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM rust:1.55-alpine3.14 as builder +RUN apk add --no-cache musl-dev opus-dev +WORKDIR /usr/src/myapp +COPY . . +RUN cargo install --path . + +FROM alpine:3.14 +RUN apk add --no-cache ffmpeg youtube-dl +COPY --from=builder /usr/local/cargo/bin/dj-kitty-cat /usr/local/bin/dj-kitty-cat +CMD ["dj-kitty-cat"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b1d6de --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# DJ Kitty Cat + +This is a Discord bot that uses [serenity](https://crates.io/crates/serenity) and [songbird](https://crates.io/crates/songbird) to play music in a channel. It's very heavily tailored to the needs of the `Cats? Cats.` community. + +You need libopus, ffmpeg, and youtube-dl as described in the [songbird readme](https://github.com/serenity-rs/songbird#dependencies). diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..1aab5af --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,179 @@ +use serenity::client::Context; + +use serenity::model::interactions::application_command::{ + ApplicationCommandInteraction, ApplicationCommandInteractionDataOptionValue, +}; +use serenity::utils::{EmbedMessageBuilding, MessageBuilder}; +use songbird::create_player; + +use crate::{CurrentVolume, CurrentlyPlayingTrack}; + +pub async fn join(ctx: &Context, command: &ApplicationCommandInteraction) -> String { + let guild_id = command.guild_id.unwrap(); + let guild = guild_id.to_guild_cached(&ctx.cache).await.unwrap(); + + let channel_id = guild + .voice_states + .get(&command.user.id) + .and_then(|voice_state| voice_state.channel_id); + + let connect_to = match channel_id { + Some(channel) => channel, + None => { + return "You're not in a voice channel".to_string(); + } + }; + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let _handler = manager.join(guild_id, connect_to).await; + + "Joining your channel".to_string() +} + +pub async fn leave(ctx: &Context, command: &ApplicationCommandInteraction) -> String { + let guild_id = command.guild_id.unwrap(); + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + let has_handler = manager.get(guild_id).is_some(); + + if has_handler { + if let Err(e) = manager.remove(guild_id).await { + return format!("Failed: {:?}", e); + } + + "Left voice channel".to_string() + } else { + "I'm not in a voice channel".to_string() + } +} + +pub async fn play(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { + let options = command + .data + .options + .get(0) + .expect("Expected url option") + .resolved + .as_ref() + .expect("Expected url object"); + + let url = match options { + ApplicationCommandInteractionDataOptionValue::String(url) => url, + _ => { + return "Must provide a URL to a video or audio".to_string(); + } + }; + + if !url.starts_with("http") { + return "Must provide a valid URL".to_string(); + } + + let guild_id = command.guild_id.unwrap(); + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let source = match songbird::ytdl(&url).await { + Ok(source) => source, + Err(why) => { + println!("Err starting source: {:?}", why); + + return "Error sourcing ffmpeg".to_string(); + } + }; + + let message = { + if let Some(title) = &source.metadata.title { + let mut msg = MessageBuilder::new(); + msg.push_line("Playing song:"); + msg.push_named_link(title, url); + msg.build() + } else { + "Playing song".to_string() + } + }; + + let (mut audio, track_handle) = create_player(source); + + let mut data = ctx.data.write().await; + + let current_track = data.get_mut::().unwrap(); + *current_track = Some(track_handle); + + let volume = data.get::().unwrap(); + + audio.set_volume(*volume); + handler.play_only(audio); + + message + } else { + "Not in a voice channel to play in".to_string() + } +} + +pub async fn stop(ctx: &Context, command: &ApplicationCommandInteraction) -> String { + let guild_id = command.guild_id.unwrap(); + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + handler.stop(); + + "Stopping song".to_string() + } else { + "Not in a voice channel to play in".to_string() + } +} + +pub async fn set_volume(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { + let options = command + .data + .options + .get(0) + .expect("Expected volume option") + .resolved + .as_ref() + .expect("Expected volume object"); + + let volume = match options { + ApplicationCommandInteractionDataOptionValue::Number(volume) => *volume, + _ => { + return "Must provide a volume level".to_string(); + } + }; + + if !(0.0..=100.0).contains(&volume) { + return "Must provide a value between 0 and 100".to_string(); + } + + let mut data = ctx.data.write().await; + + let current_volume = data.get_mut::().unwrap(); + let new_volume = (volume / 100.0) as f32; + *current_volume = new_volume; + + let current_track = data.get::().unwrap(); + if let Some(track) = current_track { + track.set_volume(new_volume).unwrap(); + format!("Setting volume to {}%", volume) + } else { + "No track is currently playing".to_string() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c3e8903 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,185 @@ +#![warn(clippy::all)] + +use std::env; + +use serenity::{ + async_trait, + model::{ + gateway::Ready, + id::GuildId, + interactions::{ + application_command::{ApplicationCommandOptionType, ApplicationCommandPermissionType}, + Interaction, InteractionResponseType, + }, + }, + prelude::*, +}; +use songbird::{tracks::TrackHandle, SerenityInit}; + +mod commands; + +struct CurrentlyPlayingTrack; + +impl TypeMapKey for CurrentlyPlayingTrack { + type Value = Option; +} + +struct CurrentVolume; + +impl TypeMapKey for CurrentVolume { + type Value = f32; +} + +struct Handler; + +#[async_trait] +impl EventHandler for Handler { + async fn interaction_create(&self, mut ctx: Context, interaction: Interaction) { + if let Interaction::ApplicationCommand(command) = interaction { + let content = match command.data.name.as_str() { + "join" => commands::join(&ctx, &command).await, + "leave" => commands::leave(&ctx, &command).await, + "play" => commands::play(&mut ctx, &command).await, + "stop" => commands::stop(&ctx, &command).await, + "volume" => commands::set_volume(&mut ctx, &command).await, + _ => "not implemented :(".to_string(), + }; + + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| message.content(content)) + }) + .await + { + println!("Cannot respond to slash command: {}", why); + } + } + } + + async fn cache_ready(&self, ctx: Context, guilds: Vec) { + for guild in guilds { + let commands = guild + .set_application_commands(&ctx.http, |commands| { + commands + .create_application_command(|command| { + command + .name("join") + .description("Join your current channel") + .default_permission(false) + }) + .create_application_command(|command| { + command + .name("leave") + .description("Leave the bot's current channel") + .default_permission(false) + }) + .create_application_command(|command| { + command + .name("play") + .description("Play a song") + .create_option(|option| { + option + .name("url") + .description("The URL of the song to play") + .kind(ApplicationCommandOptionType::String) + .required(true) + }) + .default_permission(false) + }) + .create_application_command(|command| { + command + .name("stop") + .description("Stop any currently playing songs") + .default_permission(false) + }) + .create_application_command(|command| { + command + .name("volume") + .description("Set the bot's playback volume") + .create_option(|option| { + option + .name("volume") + .description("The volume on a scale from 0 to 100") + .kind(ApplicationCommandOptionType::Number) + .required(true) + }) + .default_permission(false) + }) + }) + .await + .expect("Couldn't create commands"); + + println!("I created the following guild commands: {:#?}", commands); + + let role_id = env::var("ROLE_ID") + .expect("Expected a role id in the environment") + .parse::() + .expect("Role id is not a valid id"); + + let permissions = guild + .set_application_commands_permissions(&ctx.http, |permissions| { + for command in commands { + permissions.create_application_command(|permissions| { + permissions + .id(command.id.into()) + .create_permissions(|permission| { + permission + .id(role_id) + .kind(ApplicationCommandPermissionType::Role) + .permission(true) + }) + }); + } + permissions + }) + .await + .expect("Couldn't set permissions"); + + println!("I created the following permissions: {:#?}", permissions); + } + } + + async fn ready(&self, _ctx: Context, ready: Ready) { + println!("{} is connected!", ready.user.name); + } +} + +#[tokio::main] +async fn main() { + let token = + env::var("DISCORD_TOKEN").expect("Expected a bot token in the environment: DISCORD_TOKEN"); + + let application_id: u64 = env::var("APPLICATION_ID") + .expect("Expected an application id in the environment: APPLICATION_ID") + .parse() + .expect("application id is not a valid id"); + + env::var("ROLE_ID") + .expect("Expected a role id in the environment: ROLE_ID") + .parse::() + .expect("Role id is not a valid id"); + + env::var("CHANNEL_ID") + .expect("Expected a channel id in the environment: CHANNEL_ID") + .parse::() + .expect("Channel id is not a valid id"); + + let mut client = Client::builder(token) + .event_handler(Handler) + .application_id(application_id) + .register_songbird() + .await + .expect("Error creating client"); + + { + let mut data = client.data.write().await; + data.insert::(None); + data.insert::(1.0); + } + + if let Err(why) = client.start().await { + println!("Client error: {:?}", why); + } +}