Bring back the buttons

This commit is contained in:
DaXcess
2023-02-21 14:45:59 +01:00
parent 22dde966b5
commit da1d0d1fec
18 changed files with 752 additions and 160 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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::<Database>().expect("to contain a value");
+1 -1
View File
@@ -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::<Database>().expect("to contain a value");
+1 -1
View File
@@ -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::<Database>().expect("to contain a value");
+1 -1
View File
@@ -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| {
+105 -22
View File
@@ -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<Box<dyn Future<Output = ()> + 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<ComponentExecutor>,
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<String>,
register: fn(&mut CreateApplicationCommand) -> &mut CreateApplicationCommand,
executor: CommandExecutor,
command_executor: CommandExecutor,
component_executor: Option<ComponentExecutor>,
) {
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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+413 -70
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 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::<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 {
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::<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)
}
+1 -1
View File
@@ -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!");
+1 -1
View File
@@ -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::<Database>().expect("to contain a value");