Bring back the buttons
parent
22dde966b5
commit
da1d0d1fec
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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!");
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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::<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
|
||||
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::<u64>() {
|
||||
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;
|
||||
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::<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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<RwLock<InnerSpoticordSession>>);
|
||||
|
||||
|
@ -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,
|
||||
|
|
|
@ -8,6 +8,12 @@ pub enum Status {
|
|||
None = 0,
|
||||
}
|
||||
|
||||
impl From<Status> for serenity::utils::Colour {
|
||||
fn from(value: Status) -> Self {
|
||||
Self(value as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EmbedMessageOptions {
|
||||
pub title: Option<String>,
|
||||
|
|
Loading…
Reference in New Issue