Bring back the buttons

main
DaXcess 2023-02-21 14:45:59 +01:00
parent 22dde966b5
commit da1d0d1fec
No known key found for this signature in database
GPG Key ID: CF78CC72F0FD5EAD
18 changed files with 752 additions and 160 deletions

View File

@ -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

12
Cargo.lock generated
View File

@ -916,9 +916,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.8" version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@ -2459,9 +2459,9 @@ dependencies = [
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.7" version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
dependencies = [ dependencies = [
"autocfg", "autocfg",
] ]
@ -2770,9 +2770,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.11" version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" checksum = "8fb52b74f05dbf495a8fba459fdc331812b96aa086d9eb78101fa0d4569c3313"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",

View File

@ -11,7 +11,7 @@ use crate::{
pub const NAME: &str = "help"; 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 { Box::pin(async move {
respond_message( respond_message(
&ctx, &ctx,

View File

@ -14,7 +14,7 @@ use crate::{
pub const NAME: &str = "link"; 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 { Box::pin(async move {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value"); let database = data.get::<Database>().expect("to contain a value");

View File

@ -19,7 +19,7 @@ use crate::{
pub const NAME: &str = "rename"; 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 { Box::pin(async move {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value"); let database = data.get::<Database>().expect("to contain a value");

View File

@ -14,7 +14,7 @@ use crate::{
pub const NAME: &str = "unlink"; 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 { Box::pin(async move {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let database = data.get::<Database>().expect("to contain a value"); let database = data.get::<Database>().expect("to contain a value");

View File

@ -11,7 +11,7 @@ use crate::{bot::commands::CommandOutput, consts::VERSION, utils::embed::Status}
pub const NAME: &str = "version"; 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 { Box::pin(async move {
if let Err(why) = command if let Err(why) = command
.create_interaction_response(&ctx.http, |response| { .create_interaction_response(&ctx.http, |response| {

View File

@ -5,7 +5,10 @@ use serenity::{
builder::{CreateApplicationCommand, CreateApplicationCommands}, builder::{CreateApplicationCommand, CreateApplicationCommands},
model::application::command::Command, model::application::command::Command,
model::prelude::{ model::prelude::{
interaction::{application_command::ApplicationCommandInteraction, InteractionResponseType}, interaction::{
application_command::ApplicationCommandInteraction,
message_component::MessageComponentInteraction, InteractionResponseType,
},
GuildId, GuildId,
}, },
prelude::{Context, TypeMapKey}, 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( pub async fn update_message(
ctx: &Context, ctx: &Context,
command: &ApplicationCommandInteraction, command: &ApplicationCommandInteraction,
@ -78,6 +103,7 @@ pub async fn defer_message(
pub type CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>; pub type CommandOutput = Pin<Box<dyn Future<Output = ()> + Send>>;
pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput; pub type CommandExecutor = fn(Context, ApplicationCommandInteraction) -> CommandOutput;
pub type ComponentExecutor = fn(Context, MessageComponentInteraction) -> CommandOutput;
#[derive(Clone)] #[derive(Clone)]
pub struct CommandManager { pub struct CommandManager {
@ -87,7 +113,8 @@ pub struct CommandManager {
#[derive(Clone)] #[derive(Clone)]
pub struct CommandInfo { pub struct CommandInfo {
pub name: String, pub name: String,
pub executor: CommandExecutor, pub command_executor: CommandExecutor,
pub component_executor: Option<ComponentExecutor>,
pub register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, pub register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand,
} }
@ -100,50 +127,71 @@ impl CommandManager {
// Debug-only commands // Debug-only commands
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
instance.insert_command(ping::NAME, ping::register, ping::run); instance.insert(ping::NAME, ping::register, ping::command, None);
instance.insert_command(token::NAME, token::register, token::run); instance.insert(token::NAME, token::register, token::command, None);
} }
// Core commands // Core commands
instance.insert_command(core::help::NAME, core::help::register, core::help::run); instance.insert(
instance.insert_command( core::help::NAME,
core::help::register,
core::help::command,
None,
);
instance.insert(
core::version::NAME, core::version::NAME,
core::version::register, core::version::register,
core::version::run, core::version::command,
None,
); );
instance.insert_command(core::link::NAME, core::link::register, core::link::run); instance.insert(
instance.insert_command( core::link::NAME,
core::link::register,
core::link::command,
None,
);
instance.insert(
core::unlink::NAME, core::unlink::NAME,
core::unlink::register, core::unlink::register,
core::unlink::run, core::unlink::command,
None,
); );
instance.insert_command( instance.insert(
core::rename::NAME, core::rename::NAME,
core::rename::register, core::rename::register,
core::rename::run, core::rename::command,
None,
); );
// Music commands // Music commands
instance.insert_command(music::join::NAME, music::join::register, music::join::run); instance.insert(
instance.insert_command( music::join::NAME,
music::join::register,
music::join::command,
None,
);
instance.insert(
music::leave::NAME, music::leave::NAME,
music::leave::register, music::leave::register,
music::leave::run, music::leave::command,
None,
); );
instance.insert_command( instance.insert(
music::playing::NAME, music::playing::NAME,
music::playing::register, music::playing::register,
music::playing::run, music::playing::command,
Some(music::playing::component),
); );
instance instance
} }
pub fn insert_command( pub fn insert(
&mut self, &mut self,
name: impl Into<String>, name: impl Into<String>,
register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand, register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand,
executor: CommandExecutor, command_executor: CommandExecutor,
component_executor: Option<ComponentExecutor>,
) { ) {
let name = name.into(); let name = name.into();
@ -152,12 +200,13 @@ impl CommandManager {
CommandInfo { CommandInfo {
name, name,
register, 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; let cmds = &self.commands;
debug!( debug!(
@ -196,11 +245,12 @@ impl CommandManager {
.expect("Failed to create global commands"); .expect("Failed to create global commands");
} }
// On slash command interaction
pub async fn execute_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) { pub async fn execute_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) {
let command = self.commands.get(&interaction.data.name); let command = self.commands.get(&interaction.data.name);
if let Some(command) = command { if let Some(command) = command {
(command.executor)(ctx.clone(), interaction.clone()).await; (command.command_executor)(ctx.clone(), interaction.clone()).await;
} else { } else {
// Command does not exist // Command does not exist
if let Err(why) = interaction 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 { impl TypeMapKey for CommandManager {

View File

@ -13,7 +13,7 @@ use crate::{
pub const NAME: &str = "join"; 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 { Box::pin(async move {
let guild = ctx let guild = ctx
.cache .cache

View File

@ -12,7 +12,7 @@ use crate::{
pub const NAME: &str = "leave"; 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 { Box::pin(async move {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let session_manager = data let session_manager = data

View File

@ -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 log::error;
use serenity::{ use serenity::{
builder::CreateApplicationCommand, builder::{CreateApplicationCommand, CreateButton, CreateComponents, CreateEmbed},
model::prelude::interaction::{ model::{
application_command::ApplicationCommandInteraction, InteractionResponseType, prelude::{
component::ButtonStyle,
interaction::{
application_command::ApplicationCommandInteraction,
message_component::MessageComponentInteraction, InteractionResponseType,
},
},
user::User,
}, },
prelude::Context, prelude::Context,
}; };
use crate::{ use crate::{
bot::commands::{respond_message, CommandOutput}, bot::commands::{respond_component_message, respond_message, CommandOutput},
session::manager::SessionManager, session::{manager::SessionManager, pbi::PlaybackInfo},
utils::{ utils::{
self, self,
embed::{EmbedBuilder, Status}, embed::{EmbedBuilder, Status},
@ -19,7 +28,7 @@ use crate::{
pub const NAME: &str = "playing"; 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 { Box::pin(async move {
let not_playing = async { let not_playing = async {
respond_message( respond_message(
@ -27,7 +36,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
&command, &command,
EmbedBuilder::new() EmbedBuilder::new()
.title("Cannot get track info") .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") .description("I'm currently not playing any music in this server")
.status(Status::Error) .status(Status::Error)
.build(), .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 // Get owner of session
let owner = match utils::discord::get_user(&ctx, owner).await { let owner = match utils::discord::get_user(&ctx, owner).await {
Some(user) => user, Some(user) => user,
None => { None => {
// This shouldn't happen // This shouldn't happen
error!("Could not find user with id {}", owner); error!("Could not find user with ID: {owner}");
respond_message( respond_message(
&ctx, &ctx,
@ -133,7 +105,7 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
EmbedBuilder::new() EmbedBuilder::new()
.title("[INTERNAL ERROR] Cannot get track info") .title("[INTERNAL ERROR] Cannot get track info")
.description(format!( .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 owner
)) ))
.status(Status::Error) .status(Status::Error)
@ -146,45 +118,416 @@ pub fn run(ctx: Context, command: ApplicationCommandInteraction) -> CommandOutpu
} }
}; };
// Get the thumbnail image // Get metadata
let thumbnail = pbi.get_thumbnail_url().expect("to contain a value"); let (title, description, audio_type, thumbnail) = get_metadata(spotify_id, &pbi);
if let Err(why) = command if let Err(why) = command
.create_interaction_response(&ctx.http, |response| { .create_interaction_response(&ctx.http, |response| {
response response
.kind(InteractionResponseType::ChannelMessageWithSource) .kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| { .interaction_response_data(|message| {
message.embed(|embed| { message
embed .set_embed(build_playing_embed(
.author(|author| { title,
author audio_type,
.name("Currently Playing") spotify_id,
.icon_url("https://spoticord.com/spotify-logo.png") description,
}) owner,
.title(title) thumbnail,
.url(format!( ))
"https://open.spotify.com/{}/{}", .components(|components| create_button(components, pbi.is_playing))
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)
})
}) })
}) })
.await .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::<SessionManager>()
.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 { pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command command
.name(NAME) .name(NAME)
.description("Display which song is currently being played") .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::<SessionManager>()
.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<String>,
audio_type: impl Into<String>,
spotify_id: SpotifyId,
description: impl Into<String>,
owner: User,
thumbnail: impl Into<String>,
) -> 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)
}

View File

@ -11,7 +11,7 @@ use super::CommandOutput;
pub const NAME: &str = "ping"; 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 { Box::pin(async move {
info!("Pong!"); info!("Pong!");

View File

@ -12,7 +12,7 @@ use super::CommandOutput;
pub const NAME: &str = "token"; 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 { Box::pin(async move {
let data = ctx.data.read().await; let data = ctx.data.read().await;
let db = data.get::<Database>().expect("to contain a value"); let db = data.get::<Database>().expect("to contain a value");

View File

@ -3,7 +3,13 @@
use log::*; use log::*;
use serenity::{ use serenity::{
async_trait, 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}, prelude::{Context, EventHandler},
}; };
@ -11,6 +17,23 @@ use crate::consts::MOTD;
use super::commands::CommandManager; 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::<u64>() {
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 // Handler struct with a command parameter, an array of dictionary which takes a string and function
pub struct Handler; pub struct Handler;
@ -25,7 +48,7 @@ impl EventHandler for Handler {
// Set this to true only when a command is removed/updated/created // Set this to true only when a command is removed/updated/created
if false { if false {
command_manager.register_commands(&ctx).await; command_manager.register(&ctx).await;
} }
ctx.set_activity(Activity::listening(MOTD)).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.) // INTERACTION_CREATE event, emitted when the bot receives an interaction (slash command, button, etc.)
async fn interaction_create(&self, ctx: Context, interaction: Interaction) { async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(command) = interaction { match interaction {
if let Ok(guild_id) = std::env::var("GUILD_ID") { Interaction::ApplicationCommand(command) => self.handle_command(ctx, command).await,
if let Ok(guild_id) = guild_id.parse::<u64>() { Interaction::MessageComponent(component) => self.handle_component(ctx, component).await,
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::<CommandManager>().expect("to contain a value");
command_manager.execute_command(&ctx, command).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::<CommandManager>().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::<CommandManager>().expect("to contain a value");
command_manager.execute_component(&ctx, component).await;
}
}

View File

@ -31,4 +31,16 @@ pub enum IpcPacket {
/// Sent when the user has switched their Spotify device away from Spoticord /// Sent when the user has switched their Spotify device away from Spoticord
Stopped, 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,
} }

View File

@ -31,7 +31,7 @@ pub struct SpoticordPlayer {
} }
impl SpoticordPlayer { impl SpoticordPlayer {
pub fn create(client: ipc::Client) -> Self { pub fn new(client: ipc::Client) -> Self {
Self { Self {
client, client,
session: None, session: None,
@ -223,6 +223,30 @@ impl SpoticordPlayer {
tokio::spawn(spirc_task); 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) { pub fn stop(&mut self) {
if let Some(spirc) = self.spirc.take() { if let Some(spirc) = self.spirc.take() {
spirc.shutdown(); 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"); let client = ipc::Client::connect(tx_name, rx_name).expect("Failed to connect to IPC");
// Create the player // Create the player
let mut player = SpoticordPlayer::create(client.clone()); let mut player = SpoticordPlayer::new(client.clone());
loop { loop {
let message = match client.try_recv() { let message = match client.try_recv() {
@ -274,6 +298,22 @@ pub async fn main() {
player.stop(); player.stop();
} }
IpcPacket::Next => {
player.next();
}
IpcPacket::Previous => {
player.previous();
}
IpcPacket::Pause => {
player.pause();
}
IpcPacket::Resume => {
player.resume();
}
IpcPacket::Quit => { IpcPacket::Quit => {
debug!("Received quit packet, exiting"); debug!("Received quit packet, exiting");

View File

@ -1,3 +1,6 @@
pub mod manager;
pub mod pbi;
use self::{ use self::{
manager::{SessionCreateError, SessionManager}, manager::{SessionCreateError, SessionManager},
pbi::PlaybackInfo, pbi::PlaybackInfo,
@ -30,9 +33,6 @@ use std::{
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub mod manager;
mod pbi;
#[derive(Clone)] #[derive(Clone)]
pub struct SpoticordSession(Arc<RwLock<InnerSpoticordSession>>); pub struct SpoticordSession(Arc<RwLock<InnerSpoticordSession>>);
@ -149,6 +149,42 @@ impl SpoticordSession {
Ok(()) 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> { async fn create_player(&mut self, ctx: &Context) -> Result<(), SessionCreateError> {
let owner_id = match self.owner().await { let owner_id = match self.owner().await {
Some(owner_id) => owner_id, Some(owner_id) => owner_id,

View File

@ -8,6 +8,12 @@ pub enum Status {
None = 0, None = 0,
} }
impl From<Status> for serenity::utils::Colour {
fn from(value: Status) -> Self {
Self(value as u32)
}
}
#[derive(Default)] #[derive(Default)]
pub struct EmbedMessageOptions { pub struct EmbedMessageOptions {
pub title: Option<String>, pub title: Option<String>,