diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 64bf89b..e27da53 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -23,6 +23,7 @@ use crate::{ }; use super::db::{OsuSavedUsers, OsuUser}; +use super::interaction::score_components; use super::{calculate_weighted_map_length, OsuEnv}; use super::{embeds::score_embed, BeatmapWithMode}; @@ -133,7 +134,7 @@ impl Announcer { let scores = self.scan_user(osu_user, mode).await?; let user = self .client - .user(UserID::ID(osu_user.id), |f| { + .user(&UserID::ID(osu_user.id), |f| { f.mode(mode) .event_days(days_since_last_update.min(31) as u8) }) @@ -339,7 +340,8 @@ impl<'a> CollectedScore<'a> { ScoreType::WorldRecord(rank) => b.world_record(rank), } .build() - }), + }) + .components(vec![score_components()]), ) .await?; diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index 1faebed..d016b2d 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -2,7 +2,7 @@ pub use beatmapset::display_beatmapset; pub use scores::ScoreListStyle; mod scores { - use serenity::{framework::standard::CommandResult, model::channel::Message}; + use serenity::model::channel::Message; use youmubot_prelude::*; @@ -39,8 +39,8 @@ mod scores { scores: Vec, mode: Mode, ctx: &'a Context, - m: &'a Message, - ) -> CommandResult { + m: Message, + ) -> Result<()> { match self { ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await, ScoreListStyle::Grid => grid::display_scores_grid(scores, mode, ctx, m).await, @@ -48,12 +48,14 @@ mod scores { } } - pub mod grid { + mod grid { + use pagination::paginate_with_first_message; use serenity::builder::EditMessage; - use serenity::{framework::standard::CommandResult, model::channel::Message}; + use serenity::model::channel::Message; use youmubot_prelude::*; + use crate::discord::interaction::score_components; use crate::discord::{cache::save_beatmap, BeatmapWithMode, OsuEnv}; use crate::models::{Mode, Score}; @@ -61,17 +63,18 @@ mod scores { scores: Vec, mode: Mode, ctx: &'a Context, - m: &'a Message, - ) -> CommandResult { + mut on: Message, + ) -> Result<()> { if scores.is_empty() { - m.reply(&ctx, "No plays found").await?; + on.edit(&ctx, EditMessage::new().content("No plays found")) + .await?; return Ok(()); } - paginate_reply( + paginate_with_first_message( Paginate { scores, mode }, ctx, - m, + on, std::time::Duration::from_secs(60), ) .await?; @@ -97,17 +100,19 @@ mod scores { let bm = BeatmapWithMode(beatmap, mode); let user = env .client - .user(crate::request::UserID::ID(score.user_id), |f| f) + .user(&crate::request::UserID::ID(score.user_id), |f| f) .await? .ok_or_else(|| Error::msg("user not found"))?; msg.edit( ctx, - EditMessage::new().embed({ - crate::discord::embeds::score_embed(score, &bm, &content, &user) - .footer(format!("Page {}/{}", page + 1, self.scores.len())) - .build() - }), + EditMessage::new() + .embed({ + crate::discord::embeds::score_embed(score, &bm, &content, &user) + .footer(format!("Page {}/{}", page + 1, self.scores.len())) + .build() + }) + .components(vec![score_components()]), ) .await?; save_beatmap(&env, msg.channel_id, &bm).await?; @@ -126,8 +131,9 @@ mod scores { pub mod table { use std::borrow::Cow; + use pagination::paginate_with_first_message; use serenity::builder::EditMessage; - use serenity::{framework::standard::CommandResult, model::channel::Message}; + use serenity::model::channel::Message; use youmubot_prelude::table_format::Align::{Left, Right}; use youmubot_prelude::table_format::{table_formatting, Align}; @@ -141,17 +147,18 @@ mod scores { scores: Vec, mode: Mode, ctx: &'a Context, - m: &'a Message, - ) -> CommandResult { + mut on: Message, + ) -> Result<()> { if scores.is_empty() { - m.reply(&ctx, "No plays found").await?; + on.edit(&ctx, EditMessage::new().content("No plays found")) + .await?; return Ok(()); } - paginate_reply( + paginate_with_first_message( Paginate { scores, mode }, ctx, - m, + on, std::time::Duration::from_secs(60), ) .await?; @@ -332,7 +339,7 @@ mod beatmapset { use youmubot_prelude::*; - use crate::discord::OsuEnv; + use crate::discord::{interaction::beatmap_components, OsuEnv}; use crate::{ discord::{cache::save_beatmap, oppai_cache::BeatmapInfoWithPP, BeatmapWithMode}, models::{Beatmap, Mode, Mods}, @@ -439,7 +446,8 @@ mod beatmapset { SHOW_ALL_EMOTE, )) }) - ), + ) + .components(vec![beatmap_components()]), ) .await?; let env = ctx.data.read().await.get::().unwrap().clone(); diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 2c95f67..f6e775d 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -17,6 +17,7 @@ use crate::{ }; use super::embeds::beatmap_embed; +use super::interaction::{beatmap_components, score_components}; use super::link_parser::*; /// React to /scores/{id} links. @@ -83,7 +84,8 @@ pub fn score_hook<'a>( len ) }) - .embed(score_embed(&s, &b, &c, h).build()), + .embed(score_embed(&s, &b, &c, h).build()) + .components(vec![score_components()]), ) .await .pls_ok(); @@ -301,6 +303,7 @@ async fn handle_beatmap<'a, 'b>( mods, info, )) + .components(vec![beatmap_components()]) .reference_message(reply_to), ) .await?; diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs new file mode 100644 index 0000000..fc5dabb --- /dev/null +++ b/youmubot-osu/src/discord/interaction.rs @@ -0,0 +1,78 @@ +use std::pin::Pin; + +use future::Future; +use serenity::all::{ + ComponentInteractionDataKind, CreateActionRow, CreateButton, CreateInteractionResponseMessage, + Interaction, +}; +use youmubot_prelude::*; + +use crate::Mods; + +use super::{display::ScoreListStyle, OsuEnv}; + +pub(super) const BTN_CHECK: &'static str = "youmubot_osu_btn_check"; +// pub(super) const BTN_LAST: &'static str = "youmubot_osu_btn_last"; + +/// Create an action row for score pages. +pub fn score_components() -> CreateActionRow { + CreateActionRow::Buttons(vec![check_button()]) +} + +/// Create an action row for score pages. +pub fn beatmap_components() -> CreateActionRow { + CreateActionRow::Buttons(vec![check_button()]) +} + +/// Creates a new check button. +pub fn check_button() -> CreateButton { + CreateButton::new(BTN_CHECK) + .label("Check your score") + .emoji('🔎') + .style(serenity::all::ButtonStyle::Secondary) +} + +/// Implements the `check` button on scores and beatmaps. +pub fn handle_check_button<'a>( + ctx: &'a Context, + interaction: &'a Interaction, +) -> Pin> + Send + 'a>> { + Box::pin(async move { + let comp = match interaction.as_message_component() { + Some(comp) + if comp.data.custom_id == BTN_CHECK + && matches!(comp.data.kind, ComponentInteractionDataKind::Button) => + { + comp + } + _ => return Ok(()), + }; + let (msg, author) = (&*comp.message, comp.user.id); + + let env = ctx.data.read().await.get::().unwrap().clone(); + let (bm, _) = super::load_beatmap(&env, msg).await.unwrap(); + let user_id = super::to_user_id_query(None, &env, author).await?; + + let scores = super::do_check(&env, &bm, Mods::NOMOD, &user_id).await?; + + let reply = { + comp.create_response( + &ctx, + serenity::all::CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content(format!( + "Here are the scores by `{}` on `{}`!", + &user_id, + bm.short_link(Mods::NOMOD) + )), + ), + ) + .await?; + comp.get_response(&ctx).await? + }; + ScoreListStyle::Grid + .display_scores(scores, bm.1, ctx, reply) + .await?; + + Ok(()) + }) +} diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 01fc533..cc84956 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,6 +1,7 @@ use std::{str::FromStr, sync::Arc}; use futures_util::join; +use interaction::{beatmap_components, score_components}; use rand::seq::IteratorRandom; use serenity::{ builder::{CreateMessage, EditMessage}, @@ -36,6 +37,7 @@ mod db; pub(crate) mod display; pub(crate) mod embeds; mod hook; +pub mod interaction; mod link_parser; pub(crate) mod oppai_cache; mod server_rank; @@ -201,6 +203,10 @@ pub async fn mania(ctx: &Context, msg: &Message, args: Args) -> CommandResult { pub(crate) struct BeatmapWithMode(pub Beatmap, pub Mode); impl BeatmapWithMode { + pub fn short_link(&self, mods: Mods) -> String { + self.0.short_link(Some(self.1), Some(mods)) + } + fn mode(&self) -> Mode { self.1 } @@ -221,7 +227,7 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let osu_client = &env.client; let user = args.single::()?; - let u = match osu_client.user(UserID::from_string(user), |f| f).await? { + let u = match osu_client.user(&UserID::from_string(user), |f| f).await? { Some(u) => u, None => { msg.reply(&ctx, "user not found...").await?; @@ -288,7 +294,9 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult reply .edit( &ctx, - EditMessage::new().embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info)), + EditMessage::new() + .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info)) + .components(vec![beatmap_components()]), ) .await?; let reaction = reply.react(&ctx, '👌').await?; @@ -343,7 +351,7 @@ pub async fn forcesave(ctx: &Context, msg: &Message, mut args: Args) -> CommandR let username = args.quoted().trimmed().single::()?; let user: Option = osu_client - .user(UserID::from_string(username.clone()), |f| f) + .user(&UserID::from_string(username.clone()), |f| f) .await?; match user { Some(u) => { @@ -370,7 +378,7 @@ async fn add_user(target: serenity::model::id::UserId, user: User, env: &OsuEnv) .into_iter() .map(|mode| async move { env.client - .user(UserID::ID(user.id), |f| f.mode(mode)) + .user(&UserID::ID(user.id), |f| f.mode(mode)) .await .unwrap_or_else(|err| { eprintln!("{}", err); @@ -431,12 +439,12 @@ impl FromStr for ModeArg { async fn to_user_id_query( s: Option, env: &OsuEnv, - msg: &Message, + author: serenity::all::UserId, ) -> Result { let id = match s { Some(UsernameArg::Raw(s)) => return Ok(UserID::from_string(s)), Some(UsernameArg::Tagged(r)) => r, - None => msg.author.id, + None => author, }; env.saved_users @@ -481,14 +489,14 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let user = to_user_id_query( args.quoted().trimmed().single::().ok(), &env, - msg, + msg.author.id, ) .await?; let osu_client = &env.client; let user = osu_client - .user(user, |f| f.mode(mode)) + .user(&user, |f| f.mode(mode)) .await? .ok_or_else(|| Error::msg("User not found"))?; match nth { @@ -512,6 +520,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu CreateMessage::new() .content("Here is the play that you requested".to_string()) .embed(score_embed(&recent_play, &beatmap_mode, &content, &user).build()) + .components(vec![score_components()]) .reference_message(msg), ) .await?; @@ -523,7 +532,13 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let plays = osu_client .user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50)) .await?; - style.display_scores(plays, mode, ctx, msg).await?; + let reply = msg + .reply( + ctx, + format!("Here are the recent plays by `{}`!", user.username), + ) + .await?; + style.display_scores(plays, mode, ctx, reply).await?; } } Ok(()) @@ -626,6 +641,7 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult CreateMessage::new() .content("Here is the beatmap you requested!") .embed(beatmap_embed(&b, m, mods, info)) + .components(vec![beatmap_components()]) .reference_message(msg), ) .await?; @@ -656,29 +672,51 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul return Ok(()); } }; - + let mode = bm.1; let mods = args.find::().ok().unwrap_or_default(); - let b = &bm.0; - let m = bm.1; let style = args .single::() .unwrap_or(ScoreListStyle::Grid); let username_arg = args.single::().ok(); - let user_id = match username_arg.as_ref() { - Some(UsernameArg::Tagged(v)) => Some(*v), - None => Some(msg.author.id), - _ => None, - }; - let user = to_user_id_query(username_arg, &env, msg).await?; + let user = to_user_id_query(username_arg, &env, msg.author.id).await?; - let osu_client = env.client; + let scores = do_check(&env, &bm, mods, &user).await?; + + if scores.is_empty() { + msg.reply(&ctx, "No scores found").await?; + return Ok(()); + } + let reply = msg + .reply( + &ctx, + format!( + "Here are the scores by `{}` on `{}`!", + &user, + bm.short_link(mods) + ), + ) + .await?; + style.display_scores(scores, mode, ctx, reply).await?; + + Ok(()) +} + +pub(crate) async fn do_check( + env: &OsuEnv, + bm: &BeatmapWithMode, + mods: Mods, + user: &UserID, +) -> Result> { + let BeatmapWithMode(b, m) = bm; + + let osu_client = &env.client; let user = osu_client .user(user, |f| f) .await? .ok_or_else(|| Error::msg("User not found"))?; let mut scores = osu_client - .scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m)) + .scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(*m)) .await? .into_iter() .filter(|s| s.mods.contains(mods)) @@ -688,23 +726,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul .partial_cmp(&a.pp.unwrap_or(-1.0)) .unwrap() }); - - if scores.is_empty() { - msg.reply(&ctx, "No scores found").await?; - return Ok(()); - } - - if let Some(user_id) = user_id { - // Save to database - env.user_bests - .save(user_id, m, scores.clone()) - .await - .pls_ok(); - } - - style.display_scores(scores, m, ctx, msg).await?; - - Ok(()) + Ok(scores) } #[command] @@ -722,10 +744,10 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .map(|ModeArg(t)| t) .unwrap_or(Mode::Std); - let user = to_user_id_query(args.single::().ok(), &env, msg).await?; + let user_id = to_user_id_query(args.single::().ok(), &env, msg.author.id).await?; let osu_client = &env.client; let user = osu_client - .user(user, |f| f.mode(mode)) + .user(&user_id, |f| f.mode(mode)) .await? .ok_or_else(|| Error::msg("User not found"))?; @@ -757,6 +779,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .top_record(rank) .build(), ) + .components(vec![score_components()]) }) .await?; @@ -767,7 +790,10 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let plays = osu_client .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) .await?; - style.display_scores(plays, mode, ctx, msg).await?; + let reply = msg + .reply(&ctx, format!("Here are the top plays by `{}`!", user_id)) + .await?; + style.display_scores(plays, mode, ctx, reply).await?; } } Ok(()) @@ -796,10 +822,10 @@ async fn get_user( mut args: Args, mode: Mode, ) -> CommandResult { - let user = to_user_id_query(args.single::().ok(), &env, msg).await?; + let user = to_user_id_query(args.single::().ok(), &env, msg.author.id).await?; let osu_client = &env.client; let meta_cache = &env.beatmaps; - let user = osu_client.user(user, |f| f.mode(mode)).await?; + let user = osu_client.user(&user, |f| f.mode(mode)).await?; match user { Some(u) => { diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 8b979fd..d9fb40c 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -327,12 +327,21 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C } if let ScoreListStyle::Grid = style { + let reply = msg + .reply( + &ctx, + format!( + "Here are the top scores on beatmap `{}` of this server!", + bm.short_link(Mods::NOMOD) + ), + ) + .await?; style .display_scores( scores.into_iter().map(|(_, _, a)| a).collect(), mode, ctx, - msg, + reply, ) .await?; return Ok(()); diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 0879842..733b380 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -56,7 +56,7 @@ impl Client { pub async fn user( &self, - user: UserID, + user: &UserID, f: impl FnOnce(&mut UserRequestBuilder) -> &mut UserRequestBuilder, ) -> Result, Error> { let mut r = UserRequestBuilder::new(user.clone()); @@ -66,7 +66,7 @@ impl Client { self.user_header_cache .lock() .await - .insert(id, u.clone().map(|v| v.into())); + .insert(*id, u.clone().map(|v| v.into())); } Ok(u) } @@ -77,7 +77,7 @@ impl Client { let v = self.user_header_cache.lock().await.get(&id).cloned(); match v { Some(v) => v, - None => self.user(UserID::ID(id), |f| f).await?.map(|v| v.into()), + None => self.user(&UserID::ID(id), |f| f).await?.map(|v| v.into()), } }) } diff --git a/youmubot-osu/src/request.rs b/youmubot-osu/src/request.rs index 42d7d09..1ea5057 100644 --- a/youmubot-osu/src/request.rs +++ b/youmubot-osu/src/request.rs @@ -1,3 +1,5 @@ +use core::fmt; + use crate::models::{Mode, Mods}; use crate::Client; use rosu_v2::error::OsuError; @@ -9,6 +11,15 @@ pub enum UserID { ID(u64), } +impl fmt::Display for UserID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UserID::Username(u) => u.fmt(f), + UserID::ID(id) => id.fmt(f), + } + } +} + impl From for rosu_v2::prelude::UserId { fn from(value: UserID) -> Self { match value { diff --git a/youmubot-prelude/src/hook.rs b/youmubot-prelude/src/hook.rs index 5760b17..96f4783 100644 --- a/youmubot-prelude/src/hook.rs +++ b/youmubot-prelude/src/hook.rs @@ -1,5 +1,5 @@ use crate::{async_trait, future, Context, Result}; -use serenity::model::channel::Message; +use serenity::{all::Interaction, model::channel::Message}; /// Hook represents the asynchronous hook that is run on every message. #[async_trait] @@ -22,3 +22,25 @@ where self(ctx, message).await } } + +/// InteractionHook represents the asynchronous hook that is run on every interaction. +#[async_trait] +pub trait InteractionHook: Send + Sync { + async fn call(&mut self, ctx: &Context, interaction: &Interaction) -> Result<()>; +} + +#[async_trait] +impl InteractionHook for T +where + T: for<'a> FnMut( + &'a Context, + &'a Interaction, + ) + -> std::pin::Pin> + 'a + Send>> + + Send + + Sync, +{ + async fn call(&mut self, ctx: &Context, interaction: &Interaction) -> Result<()> { + self(ctx, interaction).await + } +} diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index 0c386c6..c10cf63 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -164,7 +164,8 @@ pub async fn paginate( paginate_with_first_message(pager, ctx, message, timeout).await } -async fn paginate_with_first_message( +/// Paginate with the first message already created. +pub async fn paginate_with_first_message( mut pager: impl Paginate, ctx: &Context, mut message: Message, diff --git a/youmubot/src/main.rs b/youmubot/src/main.rs index 73fba7c..d13359f 100644 --- a/youmubot/src/main.rs +++ b/youmubot/src/main.rs @@ -1,5 +1,7 @@ use dotenv::var; +use hook::InteractionHook; use serenity::{ + all::{CreateInteractionResponseMessage, Interaction}, framework::standard::{ macros::hook, BucketBuilder, CommandResult, Configuration, DispatchError, StandardFramework, }, @@ -19,6 +21,7 @@ mod compose_framework; struct Handler { hooks: Vec>>, + interaction_hooks: Vec>>, ready_hooks: Vec CommandResult>, } @@ -26,6 +29,7 @@ impl Handler { fn new() -> Handler { Handler { hooks: vec![], + interaction_hooks: vec![], ready_hooks: vec![], } } @@ -37,6 +41,10 @@ impl Handler { fn push_ready_hook(&mut self, f: fn(&Context) -> CommandResult) { self.ready_hooks.push(f); } + + fn push_interaction_hook(&mut self, f: T) { + self.interaction_hooks.push(RwLock::new(Box::new(f))); + } } /// Environment to be passed into the framework @@ -99,6 +107,36 @@ impl EventHandler for Handler { f(&ctx).pls_ok(); } } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + let ctx = &ctx; + let interaction = &interaction; + self.interaction_hooks + .iter() + .map(|hook| { + hook.write() + .then(|mut h| async move { h.call(&ctx, &interaction).await }) + }) + .collect::>() + .for_each(|v| async move { + if let Err(e) = v { + let response = serenity::all::CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .ephemeral(true) + .content(format!("Interaction failed: {}", e)), + ); + match interaction { + Interaction::Command(c) => c.create_response(ctx, response).await.pls_ok(), + Interaction::Component(c) => { + c.create_response(ctx, response).await.pls_ok() + } + Interaction::Modal(c) => c.create_response(ctx, response).await.pls_ok(), + _ => None, + }; + } + }) + .await; + } } /// Returns whether the user has "MANAGE_MESSAGES" permission in the channel. @@ -129,6 +167,7 @@ async fn main() { handler.push_hook(youmubot_osu::discord::hook); handler.push_hook(youmubot_osu::discord::dot_osu_hook); handler.push_hook(youmubot_osu::discord::score_hook); + handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_check_button) } #[cfg(feature = "codeforces")] handler.push_hook(youmubot_cf::InfoHook);