From da1d0d1fecdb5f8105b02726f5c24af5cfb866d1 Mon Sep 17 00:00:00 2001 From: DaXcess Date: Tue, 21 Feb 2023 14:45:59 +0100 Subject: [PATCH] Bring back the buttons --- .githooks/pre-commit | 15 + Cargo.lock | 12 +- src/bot/commands/core/help.rs | 2 +- src/bot/commands/core/link.rs | 2 +- src/bot/commands/core/rename.rs | 2 +- src/bot/commands/core/unlink.rs | 2 +- src/bot/commands/core/version.rs | 2 +- src/bot/commands/mod.rs | 127 ++++++-- src/bot/commands/music/join.rs | 2 +- src/bot/commands/music/leave.rs | 2 +- src/bot/commands/music/playing.rs | 483 +++++++++++++++++++++++++----- src/bot/commands/ping.rs | 2 +- src/bot/commands/token.rs | 2 +- src/bot/events.rs | 153 +++++++--- src/ipc/packet.rs | 12 + src/player.rs | 44 ++- src/session/mod.rs | 42 ++- src/utils/embed.rs | 6 + 18 files changed, 752 insertions(+), 160 deletions(-) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c5d7fb1 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,15 @@ +#!/bin/bash + +RED='\033[0;31m' +BRED='\033[1;31m' +NC='\033[0m' + +diff=$(cargo clippy --all -- -D warnings -D clippy::unwrap_used) +result=$? + +if [[ ${result} -ne 0 ]] ; then + echo -e "\n${BRED}Cannot commit:${NC} There are some clippy issues in your code, check the above output for any errors." + exit 1 +fi + +exit 0 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ff0e5b6..002aa55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -916,9 +916,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" dependencies = [ "bytes", "fnv", @@ -2459,9 +2459,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" dependencies = [ "autocfg", ] @@ -2770,9 +2770,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" +checksum = "8fb52b74f05dbf495a8fba459fdc331812b96aa086d9eb78101fa0d4569c3313" dependencies = [ "futures-core", "pin-project-lite", diff --git a/src/bot/commands/core/help.rs b/src/bot/commands/core/help.rs index afc1313..531da30 100644 --- a/src/bot/commands/core/help.rs +++ b/src/bot/commands/core/help.rs @@ -11,7 +11,7 @@ use crate::{ pub const NAME: &str = "help"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { respond_message( &ctx, diff --git a/src/bot/commands/core/link.rs b/src/bot/commands/core/link.rs index d6f4af1..14b238f 100644 --- a/src/bot/commands/core/link.rs +++ b/src/bot/commands/core/link.rs @@ -14,7 +14,7 @@ use crate::{ pub const NAME: &str = "link"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let database = data.get::().expect("to contain a value"); diff --git a/src/bot/commands/core/rename.rs b/src/bot/commands/core/rename.rs index 814bdbe..36f1d5a 100644 --- a/src/bot/commands/core/rename.rs +++ b/src/bot/commands/core/rename.rs @@ -19,7 +19,7 @@ use crate::{ pub const NAME: &str = "rename"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let database = data.get::().expect("to contain a value"); diff --git a/src/bot/commands/core/unlink.rs b/src/bot/commands/core/unlink.rs index e5d5a8a..99362d2 100644 --- a/src/bot/commands/core/unlink.rs +++ b/src/bot/commands/core/unlink.rs @@ -14,7 +14,7 @@ use crate::{ pub const NAME: &str = "unlink"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let database = data.get::().expect("to contain a value"); diff --git a/src/bot/commands/core/version.rs b/src/bot/commands/core/version.rs index ffa3e69..141d89e 100644 --- a/src/bot/commands/core/version.rs +++ b/src/bot/commands/core/version.rs @@ -11,7 +11,7 @@ use crate::{bot::commands::CommandOutput, consts::VERSION, utils::embed::Status} pub const NAME: &str = "version"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { if let Err(why) = command .create_interaction_response(&ctx.http, |response| { diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index 7d357c8..d065a0f 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -5,7 +5,10 @@ use serenity::{ builder::{CreateApplicationCommand, CreateApplicationCommands}, model::application::command::Command, model::prelude::{ - interaction::{application_command::ApplicationCommandInteraction, InteractionResponseType}, + interaction::{ + application_command::ApplicationCommandInteraction, + message_component::MessageComponentInteraction, InteractionResponseType, + }, GuildId, }, prelude::{Context, TypeMapKey}, @@ -44,6 +47,28 @@ pub async fn respond_message( } } +pub async fn respond_component_message( + ctx: &Context, + component: &MessageComponentInteraction, + options: EmbedMessageOptions, + ephemeral: bool, +) { + if let Err(why) = component + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message + .embed(|embed| make_embed_message(embed, options)) + .ephemeral(ephemeral) + }) + }) + .await + { + error!("Error sending message: {:?}", why); + } +} + pub async fn update_message( ctx: &Context, command: &ApplicationCommandInteraction, @@ -78,6 +103,7 @@ pub async fn defer_message( pub type CommandOutput = Pin + Send>>; pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput; +pub type ComponentExecutor = fn(Context, MessageComponentInteraction) -> CommandOutput; #[derive(Clone)] pub struct CommandManager { @@ -87,7 +113,8 @@ pub struct CommandManager { #[derive(Clone)] pub struct CommandInfo { pub name: String, - pub executor: CommandExecutor, + pub command_executor: CommandExecutor, + pub component_executor: Option, pub register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, } @@ -100,50 +127,71 @@ impl CommandManager { // Debug-only commands #[cfg(debug_assertions)] { - instance.insert_command(ping::NAME, ping::register, ping::run); - instance.insert_command(token::NAME, token::register, token::run); + instance.insert(ping::NAME, ping::register, ping::command, None); + instance.insert(token::NAME, token::register, token::command, None); } // Core commands - instance.insert_command(core::help::NAME, core::help::register, core::help::run); - instance.insert_command( + instance.insert( + core::help::NAME, + core::help::register, + core::help::command, + None, + ); + instance.insert( core::version::NAME, core::version::register, - core::version::run, + core::version::command, + None, ); - instance.insert_command(core::link::NAME, core::link::register, core::link::run); - instance.insert_command( + instance.insert( + core::link::NAME, + core::link::register, + core::link::command, + None, + ); + instance.insert( core::unlink::NAME, core::unlink::register, - core::unlink::run, + core::unlink::command, + None, ); - instance.insert_command( + instance.insert( core::rename::NAME, core::rename::register, - core::rename::run, + core::rename::command, + None, ); // Music commands - instance.insert_command(music::join::NAME, music::join::register, music::join::run); - instance.insert_command( + instance.insert( + music::join::NAME, + music::join::register, + music::join::command, + None, + ); + instance.insert( music::leave::NAME, music::leave::register, - music::leave::run, + music::leave::command, + None, ); - instance.insert_command( + instance.insert( music::playing::NAME, music::playing::register, - music::playing::run, + music::playing::command, + Some(music::playing::component), ); instance } - pub fn insert_command( + pub fn insert( &mut self, name: impl Into, register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, - executor: CommandExecutor, + command_executor: CommandExecutor, + component_executor: Option, ) { let name = name.into(); @@ -152,12 +200,13 @@ impl CommandManager { CommandInfo { name, register, - executor, + command_executor, + component_executor, }, ); } - pub async fn register_commands(&self, ctx: &Context) { + pub async fn register(&self, ctx: &Context) { let cmds = &self.commands; debug!( @@ -196,11 +245,12 @@ impl CommandManager { .expect("Failed to create global commands"); } + // On slash command interaction pub async fn execute_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) { let command = self.commands.get(&interaction.data.name); if let Some(command) = command { - (command.executor)(ctx.clone(), interaction.clone()).await; + (command.command_executor)(ctx.clone(), interaction.clone()).await; } else { // Command does not exist if let Err(why) = interaction @@ -219,6 +269,39 @@ impl CommandManager { } } } + + // On message component interaction (e.g. button) + pub async fn execute_component(&self, ctx: &Context, interaction: MessageComponentInteraction) { + let command = match interaction.data.custom_id.split("::").next() { + Some(command) => command, + None => return, + }; + + let command = self.commands.get(command); + + if let Some(command) = command { + if let Some(executor) = command.component_executor { + executor(ctx.clone(), interaction.clone()).await; + + return; + } + } + + if let Err(why) = interaction + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message + .content("Woops, that interaction doesn't exist") + .ephemeral(true) + }) + }) + .await + { + error!("Failed to respond to interaction: {}", why); + } + } } impl TypeMapKey for CommandManager { diff --git a/src/bot/commands/music/join.rs b/src/bot/commands/music/join.rs index c9c8c9a..65aa10f 100644 --- a/src/bot/commands/music/join.rs +++ b/src/bot/commands/music/join.rs @@ -13,7 +13,7 @@ use crate::{ pub const NAME: &str = "join"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let guild = ctx .cache diff --git a/src/bot/commands/music/leave.rs b/src/bot/commands/music/leave.rs index 2cd3b35..ba8098b 100644 --- a/src/bot/commands/music/leave.rs +++ b/src/bot/commands/music/leave.rs @@ -12,7 +12,7 @@ use crate::{ pub const NAME: &str = "leave"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let session_manager = data diff --git a/src/bot/commands/music/playing.rs b/src/bot/commands/music/playing.rs index 724727a..27d6d26 100644 --- a/src/bot/commands/music/playing.rs +++ b/src/bot/commands/music/playing.rs @@ -1,16 +1,25 @@ -use librespot::core::spotify_id::SpotifyAudioType; +use std::time::Duration; + +use librespot::core::spotify_id::{SpotifyAudioType, SpotifyId}; use log::error; use serenity::{ - builder::CreateApplicationCommand, - model::prelude::interaction::{ - application_command::ApplicationCommandInteraction, InteractionResponseType, + builder::{CreateApplicationCommand, CreateButton, CreateComponents, CreateEmbed}, + model::{ + prelude::{ + component::ButtonStyle, + interaction::{ + application_command::ApplicationCommandInteraction, + message_component::MessageComponentInteraction, InteractionResponseType, + }, + }, + user::User, }, prelude::Context, }; use crate::{ - bot::commands::{respond_message, CommandOutput}, - session::manager::SessionManager, + bot::commands::{respond_component_message, respond_message, CommandOutput}, + session::{manager::SessionManager, pbi::PlaybackInfo}, utils::{ self, embed::{EmbedBuilder, Status}, @@ -19,7 +28,7 @@ use crate::{ pub const NAME: &str = "playing"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let not_playing = async { respond_message( @@ -27,7 +36,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu &command, EmbedBuilder::new() .title("Cannot get track info") - .icon_url("https://tabler-icons.io/static/tabler-icons/icons/ban.svg") + .icon_url("https://spoticord.com/forbidden.png") .description("I'm currently not playing any music in this server") .status(Status::Error) .build(), @@ -82,50 +91,13 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu } }; - // Get audio type - let audio_type = if spotify_id.audio_type == SpotifyAudioType::Track { - "track" - } else { - "episode" - }; - - // Create title - let title = format!( - "{} - {}", - pbi.get_artists().expect("to contain a value"), - pbi.get_name().expect("to contain a value") - ); - - // Create description - let mut description = String::new(); - - let position = pbi.get_position(); - let spot = position * 20 / pbi.duration_ms; - - description.push_str(if pbi.is_playing { "▶️ " } else { "⏸️ " }); - - for i in 0..20 { - if i == spot { - description.push('🔵'); - } else { - description.push('▬'); - } - } - - description.push_str("\n:alarm_clock: "); - description.push_str(&format!( - "{} / {}", - utils::time_to_str(position / 1000), - utils::time_to_str(pbi.duration_ms / 1000) - )); - // Get owner of session let owner = match utils::discord::get_user(&ctx, owner).await { Some(user) => user, None => { // This shouldn't happen - error!("Could not find user with id {}", owner); + error!("Could not find user with ID: {owner}"); respond_message( &ctx, @@ -133,7 +105,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu EmbedBuilder::new() .title("[INTERNAL ERROR] Cannot get track info") .description(format!( - "Could not find user with id {}\nThis is an issue with the bot!", + "Could not find user with ID `{}`\nThis is an issue with the bot!", owner )) .status(Status::Error) @@ -146,45 +118,416 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu } }; - // Get the thumbnail image - let thumbnail = pbi.get_thumbnail_url().expect("to contain a value"); + // Get metadata + let (title, description, audio_type, thumbnail) = get_metadata(spotify_id, &pbi); if let Err(why) = command .create_interaction_response(&ctx.http, |response| { response .kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { - message.embed(|embed| { - embed - .author(|author| { - author - .name("Currently Playing") - .icon_url("https://spoticord.com/spotify-logo.png") - }) - .title(title) - .url(format!( - "https://open.spotify.com/{}/{}", - audio_type, - spotify_id - .to_base62() - .expect("to be able to convert to base62") - )) - .description(description) - .footer(|footer| footer.text(&owner.name).icon_url(owner.face())) - .thumbnail(&thumbnail) - .color(Status::Info as u64) - }) + message + .set_embed(build_playing_embed( + title, + audio_type, + spotify_id, + description, + owner, + thumbnail, + )) + .components(|components| create_button(components, pbi.is_playing)) }) }) .await { - error!("Error sending message: {:?}", why); + error!("Error sending message: {why:?}"); } }) } +pub fn component(ctx: Context, mut interaction: MessageComponentInteraction) -> CommandOutput { + Box::pin(async move { + let error_message = |title: &'static str, description: &'static str| async { + respond_component_message( + &ctx, + &interaction, + EmbedBuilder::new() + .title(title.to_string()) + .icon_url("https://spoticord.com/forbidden.png") + .description(description.to_string()) + .status(Status::Error) + .build(), + true, + ) + .await; + }; + + let error_edit = |title: &'static str, description: &'static str| { + let mut interaction = interaction.clone(); + let ctx = ctx.clone(); + + async move { + interaction.defer(&ctx.http).await.ok(); + + if let Err(why) = interaction + .message + .edit(&ctx, |message| { + message.embed(|embed| { + embed + .description(description) + .author(|author| { + author + .name(title) + .icon_url("https://spoticord.com/forbidden.png") + }) + .color(Status::Error) + }) + }) + .await + { + error!("Failed to update playing message: {why}"); + } + } + }; + + let data = ctx.data.read().await; + let session_manager = data + .get::() + .expect("to contain a value") + .clone(); + + // Check if session still exists + let mut session = match session_manager + .get_session(interaction.guild_id.expect("to contain a value")) + .await + { + Some(session) => session, + None => { + error_edit( + "Cannot perform action", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + // Check if the session contains an owner + let owner = match session.owner().await { + Some(owner) => owner, + None => { + error_edit( + "Cannot change playback state", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + // Get Playback Info from session + let pbi = match session.playback_info().await { + Some(pbi) => pbi, + None => { + error_edit( + "Cannot change playback state", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + // Check if the user is the owner of the session + if owner != interaction.user.id { + error_message( + "Cannot change playback state", + "You must be the host to use the media buttons", + ) + .await; + + return; + } + + // Get owner of session + let owner = match utils::discord::get_user(&ctx, owner).await { + Some(user) => user, + None => { + // This shouldn't happen + + error!("Could not find user with ID: {owner}"); + + respond_component_message( + &ctx, + &interaction, + EmbedBuilder::new() + .title("[INTERNAL ERROR] Cannot get track info") + .description(format!( + "Could not find user with ID `{}`\nThis is an issue with the bot!", + owner + )) + .status(Status::Error) + .build(), + true, + ) + .await; + + return; + } + }; + + // Send the desired command to the session + let success = match interaction.data.custom_id.as_str() { + "playing::btn_pause_play" => { + if pbi.is_playing { + session.pause().await.is_ok() + } else { + session.resume().await.is_ok() + } + } + + "playing::btn_previous_track" => session.previous().await.is_ok(), + + "playing::btn_next_track" => session.next().await.is_ok(), + + _ => { + error!("Unknown custom_id: {}", interaction.data.custom_id); + false + } + }; + + if !success { + error_message( + "Cannot change playback state", + "An error occurred while trying to change the playback state", + ) + .await; + + return; + } + + interaction.defer(&ctx.http).await.ok(); + tokio::time::sleep(Duration::from_millis( + if interaction.data.custom_id == "playing::btn_pause_play" { + 0 + } else { + 2500 + }, + )) + .await; + update_embed(&mut interaction, &ctx, owner).await; + }) +} + pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { command .name(NAME) .description("Display which song is currently being played") } + +fn create_button(components: &mut CreateComponents, playing: bool) -> &mut CreateComponents { + let mut prev_btn = CreateButton::default(); + prev_btn + .style(ButtonStyle::Primary) + .label("<<") + .custom_id("playing::btn_previous_track"); + + let mut toggle_btn = CreateButton::default(); + toggle_btn + .style(ButtonStyle::Secondary) + .label(if playing { "Pause" } else { "Play" }) + .custom_id("playing::btn_pause_play"); + + let mut next_btn = CreateButton::default(); + next_btn + .style(ButtonStyle::Primary) + .label(">>") + .custom_id("playing::btn_next_track"); + + components.create_action_row(|ar| { + ar.add_button(prev_btn) + .add_button(toggle_btn) + .add_button(next_btn) + }) +} + +async fn update_embed(interaction: &mut MessageComponentInteraction, ctx: &Context, owner: User) { + let error_edit = |title: &'static str, description: &'static str| { + let mut interaction = interaction.clone(); + let ctx = ctx.clone(); + + async move { + interaction.defer(&ctx.http).await.ok(); + + if let Err(why) = interaction + .message + .edit(&ctx, |message| { + message.embed(|embed| { + embed + .description(description) + .author(|author| { + author + .name(title) + .icon_url("https://spoticord.com/forbidden.png") + }) + .color(Status::Error) + }) + }) + .await + { + error!("Failed to update playing message: {why}"); + } + } + }; + + let data = ctx.data.read().await; + let session_manager = data + .get::() + .expect("to contain a value") + .clone(); + + // Check if session still exists + let session = match session_manager + .get_session(interaction.guild_id.expect("to contain a value")) + .await + { + Some(session) => session, + None => { + error_edit( + "Cannot perform action", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + // Get Playback Info from session + let pbi = match session.playback_info().await { + Some(pbi) => pbi, + None => { + error_edit( + "Cannot change playback state", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + let spotify_id = match pbi.spotify_id { + Some(spotify_id) => spotify_id, + None => { + error_edit( + "Cannot change playback state", + "I'm currently not playing any music in this server", + ) + .await; + + return; + } + }; + + let (title, description, audio_type, thumbnail) = get_metadata(spotify_id, &pbi); + + if let Err(why) = interaction + .message + .edit(&ctx, |message| { + message + .set_embed(build_playing_embed( + title, + audio_type, + spotify_id, + description, + owner, + thumbnail, + )) + .components(|components| create_button(components, pbi.is_playing)); + + message + }) + .await + { + error!("Failed to update playing message: {why}"); + } +} + +fn build_playing_embed( + title: impl Into, + audio_type: impl Into, + spotify_id: SpotifyId, + description: impl Into, + owner: User, + thumbnail: impl Into, +) -> CreateEmbed { + let mut embed = CreateEmbed::default(); + embed + .author(|author| { + author + .name("Currently Playing") + .icon_url("https://spoticord.com/spotify-logo.png") + }) + .title(title.into()) + .url(format!( + "https://open.spotify.com/{}/{}", + audio_type.into(), + spotify_id + .to_base62() + .expect("to be able to convert to base62") + )) + .description(description.into()) + .footer(|footer| footer.text(&owner.name).icon_url(owner.face())) + .thumbnail(thumbnail.into()) + .color(Status::Info); + + embed +} + +fn get_metadata(spotify_id: SpotifyId, pbi: &PlaybackInfo) -> (String, String, String, String) { + // Get audio type + let audio_type = if spotify_id.audio_type == SpotifyAudioType::Track { + "track" + } else { + "episode" + }; + + // Create title + let title = format!( + "{} - {}", + pbi.get_artists().as_deref().unwrap_or("ID"), + pbi.get_name().as_deref().unwrap_or("ID") + ); + + // Create description + let mut description = String::new(); + + let position = pbi.get_position(); + let spot = position * 20 / pbi.duration_ms; + + description.push_str(if pbi.is_playing { "▶️ " } else { "⏸️ " }); + + for i in 0..20 { + if i == spot { + description.push('🔵'); + } else { + description.push('▬'); + } + } + + description.push_str("\n:alarm_clock: "); + description.push_str(&format!( + "{} / {}", + utils::time_to_str(position / 1000), + utils::time_to_str(pbi.duration_ms / 1000) + )); + + // Get the thumbnail image + let thumbnail = pbi.get_thumbnail_url().expect("to contain a value"); + + (title, description, audio_type.to_string(), thumbnail) +} diff --git a/src/bot/commands/ping.rs b/src/bot/commands/ping.rs index 217189d..1807889 100644 --- a/src/bot/commands/ping.rs +++ b/src/bot/commands/ping.rs @@ -11,7 +11,7 @@ use super::CommandOutput; pub const NAME: &str = "ping"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { info!("Pong!"); diff --git a/src/bot/commands/token.rs b/src/bot/commands/token.rs index 4615411..6c6977c 100644 --- a/src/bot/commands/token.rs +++ b/src/bot/commands/token.rs @@ -12,7 +12,7 @@ use super::CommandOutput; pub const NAME: &str = "token"; -pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { +pub fn command(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutput { Box::pin(async move { let data = ctx.data.read().await; let db = data.get::().expect("to contain a value"); diff --git a/src/bot/events.rs b/src/bot/events.rs index 0db84a3..32d635b 100644 --- a/src/bot/events.rs +++ b/src/bot/events.rs @@ -3,7 +3,13 @@ use log::*; use serenity::{ async_trait, - model::prelude::{interaction::Interaction, Activity, GuildId, Ready}, + model::prelude::{ + interaction::{ + application_command::ApplicationCommandInteraction, + message_component::MessageComponentInteraction, Interaction, + }, + Activity, GuildId, Ready, + }, prelude::{Context, EventHandler}, }; @@ -11,6 +17,23 @@ use crate::consts::MOTD; use super::commands::CommandManager; +// If the GUILD_ID environment variable is set, only allow commands from that guild +macro_rules! enforce_guild { + ($interaction:ident) => { + if let Ok(guild_id) = std::env::var("GUILD_ID") { + if let Ok(guild_id) = guild_id.parse::() { + let guild_id = GuildId(guild_id); + + if let Some(interaction_guild_id) = $interaction.guild_id { + if guild_id != interaction_guild_id { + return; + } + } + } + } + }; +} + // Handler struct with a command parameter, an array of dictionary which takes a string and function pub struct Handler; @@ -25,7 +48,7 @@ impl EventHandler for Handler { // Set this to true only when a command is removed/updated/created if false { - command_manager.register_commands(&ctx).await; + command_manager.register(&ctx).await; } ctx.set_activity(Activity::listening(MOTD)).await; @@ -35,52 +58,86 @@ impl EventHandler for Handler { // INTERACTION_CREATE event, emitted when the bot receives an interaction (slash command, button, etc.) async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Interaction::ApplicationCommand(command) = interaction { - if let Ok(guild_id) = std::env::var("GUILD_ID") { - if let Ok(guild_id) = guild_id.parse::() { - let guild_id = GuildId(guild_id); - - if let Some(interaction_guild_id) = command.guild_id { - if guild_id != interaction_guild_id { - return; - } - } - } - } - - // Commands must only be executed inside of guilds - - let guild_id = match command.guild_id { - Some(guild_id) => guild_id, - None => { - if let Err(why) = command - .create_interaction_response(&ctx.http, |response| { - response - .kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| { - message.content("You can only execute commands inside of a server") - }) - }) - .await { - error!("Failed to send run-in-guild-only error message: {}", why); - } - - trace!("interaction_create END2"); - return; - } - }; - - trace!( - "Received command interaction: command={} user={} guild={}", - command.data.name, - command.user.id, - guild_id - ); - - let data = ctx.data.read().await; - let command_manager = data.get::().expect("to contain a value"); - - command_manager.execute_command(&ctx, command).await; + match interaction { + Interaction::ApplicationCommand(command) => self.handle_command(ctx, command).await, + Interaction::MessageComponent(component) => self.handle_component(ctx, component).await, + _ => {} } } } + +impl Handler { + async fn handle_command(&self, ctx: Context, command: ApplicationCommandInteraction) { + enforce_guild!(command); + + // Commands must only be executed inside of guilds + + let guild_id = match command.guild_id { + Some(guild_id) => guild_id, + None => { + if let Err(why) = command + .create_interaction_response(&ctx.http, |response| { + response + .kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("You can only execute commands inside of a server") + }) + }) + .await { + error!("Failed to send run-in-guild-only error message: {}", why); + } + + return; + } + }; + + trace!( + "Received command interaction: command={} user={} guild={}", + command.data.name, + command.user.id, + guild_id + ); + + let data = ctx.data.read().await; + let command_manager = data.get::().expect("to contain a value"); + + command_manager.execute_command(&ctx, command).await; + } + + async fn handle_component(&self, ctx: Context, component: MessageComponentInteraction) { + enforce_guild!(component); + + // Components can only be interacted with inside of guilds + + let guild_id = match component.guild_id { + Some(guild_id) => guild_id, + None => { + if let Err(why) = component + .create_interaction_response(&ctx.http, |response| { + response + .kind(serenity::model::prelude::interaction::InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("You can only interact with components inside of a server") + }) + }) + .await { + error!("Failed to send run-in-guild-only error message: {}", why); + } + + return; + } + }; + + trace!( + "Received component interaction: command={} user={} guild={}", + component.data.custom_id, + component.user.id, + guild_id + ); + + let data = ctx.data.read().await; + let command_manager = data.get::().expect("to contain a value"); + + command_manager.execute_component(&ctx, component).await; + } +} diff --git a/src/ipc/packet.rs b/src/ipc/packet.rs index 159b904..e25631d 100644 --- a/src/ipc/packet.rs +++ b/src/ipc/packet.rs @@ -31,4 +31,16 @@ pub enum IpcPacket { /// Sent when the user has switched their Spotify device away from Spoticord Stopped, + + /// Request the player to advance to the next track + Next, + + /// Request the player to go back to the previous track + Previous, + + /// Request the player to pause playback + Pause, + + /// Request the player to resume playback + Resume, } diff --git a/src/player.rs b/src/player.rs index 1e464ac..045d55e 100644 --- a/src/player.rs +++ b/src/player.rs @@ -31,7 +31,7 @@ pub struct SpoticordPlayer { } impl SpoticordPlayer { - pub fn create(client: ipc::Client) -> Self { + pub fn new(client: ipc::Client) -> Self { Self { client, session: None, @@ -223,6 +223,30 @@ impl SpoticordPlayer { tokio::spawn(spirc_task); } + pub fn next(&mut self) { + if let Some(spirc) = &self.spirc { + spirc.next(); + } + } + + pub fn previous(&mut self) { + if let Some(spirc) = &self.spirc { + spirc.prev(); + } + } + + pub fn pause(&mut self) { + if let Some(spirc) = &self.spirc { + spirc.pause(); + } + } + + pub fn resume(&mut self) { + if let Some(spirc) = &self.spirc { + spirc.play(); + } + } + pub fn stop(&mut self) { if let Some(spirc) = self.spirc.take() { spirc.shutdown(); @@ -240,7 +264,7 @@ pub async fn main() { let client = ipc::Client::connect(tx_name, rx_name).expect("Failed to connect to IPC"); // Create the player - let mut player = SpoticordPlayer::create(client.clone()); + let mut player = SpoticordPlayer::new(client.clone()); loop { let message = match client.try_recv() { @@ -274,6 +298,22 @@ pub async fn main() { player.stop(); } + IpcPacket::Next => { + player.next(); + } + + IpcPacket::Previous => { + player.previous(); + } + + IpcPacket::Pause => { + player.pause(); + } + + IpcPacket::Resume => { + player.resume(); + } + IpcPacket::Quit => { debug!("Received quit packet, exiting"); diff --git a/src/session/mod.rs b/src/session/mod.rs index 52658cd..70be9d4 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,3 +1,6 @@ +pub mod manager; +pub mod pbi; + use self::{ manager::{SessionCreateError, SessionManager}, pbi::PlaybackInfo, @@ -30,9 +33,6 @@ use std::{ }; use tokio::sync::Mutex; -pub mod manager; -mod pbi; - #[derive(Clone)] pub struct SpoticordSession(Arc>); @@ -149,6 +149,42 @@ impl SpoticordSession { Ok(()) } + /// Advance to the next track + pub async fn next(&mut self) -> Result<(), IpcError> { + if let Some(ref client) = self.0.read().await.client { + return client.send(IpcPacket::Next); + } + + Ok(()) + } + + /// Rewind to the previous track + pub async fn previous(&mut self) -> Result<(), IpcError> { + if let Some(ref client) = self.0.read().await.client { + return client.send(IpcPacket::Previous); + } + + Ok(()) + } + + /// Pause the current track + pub async fn pause(&mut self) -> Result<(), IpcError> { + if let Some(ref client) = self.0.read().await.client { + return client.send(IpcPacket::Pause); + } + + Ok(()) + } + + /// Resume the current track + pub async fn resume(&mut self) -> Result<(), IpcError> { + if let Some(ref client) = self.0.read().await.client { + return client.send(IpcPacket::Resume); + } + + Ok(()) + } + async fn create_player(&mut self, ctx: &Context) -> Result<(), SessionCreateError> { let owner_id = match self.owner().await { Some(owner_id) => owner_id, diff --git a/src/utils/embed.rs b/src/utils/embed.rs index ae52522..839cc45 100644 --- a/src/utils/embed.rs +++ b/src/utils/embed.rs @@ -8,6 +8,12 @@ pub enum Status { None = 0, } +impl From for serenity::utils::Colour { + fn from(value: Status) -> Self { + Self(value as u32) + } +} + #[derive(Default)] pub struct EmbedMessageOptions { pub title: Option,