From bd845d96620d980f42b5c7943ceaa249a1566974 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Tue, 2 Feb 2021 02:13:40 +0900 Subject: [PATCH] More osu improvements (#11) * Don't show DT when NC is on * Allow leading + in mods parsing * Extend Pagination * Implement beatmapset display * Move OkPrint to prelude * Beatmapset display seems to work * Put reaction handler into static * Make clippy happy * Add beatmapset caching and last --set * Delay loading of beatmap info * Simplify hook link handling * Replies everywhere! * Replies everywhereee! --- youmubot-cf/src/lib.rs | 8 +- youmubot-core/src/community/mod.rs | 1 + youmubot-core/src/community/roles.rs | 4 +- youmubot-core/src/fun/images.rs | 4 +- youmubot-osu/src/discord/announcer.rs | 25 +--- youmubot-osu/src/discord/beatmap_cache.rs | 40 +++++- youmubot-osu/src/discord/display.rs | 163 +++++++++++++++++++++ youmubot-osu/src/discord/hook.rs | 165 +++++++++++----------- youmubot-osu/src/discord/mod.rs | 72 +++++++--- youmubot-osu/src/discord/server_rank.rs | 8 +- youmubot-osu/src/models/mods.rs | 14 +- youmubot-prelude/src/lib.rs | 24 +++- youmubot-prelude/src/pagination.rs | 78 +++++++++- 13 files changed, 450 insertions(+), 156 deletions(-) create mode 100644 youmubot-osu/src/discord/display.rs diff --git a/youmubot-cf/src/lib.rs b/youmubot-cf/src/lib.rs index f0b276f..011407e 100644 --- a/youmubot-cf/src/lib.rs +++ b/youmubot-cf/src/lib.rs @@ -171,7 +171,7 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult { let total_pages = (ranks.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap(); - paginate_fn( + paginate_reply_fn( move |page, ctx, msg| { let ranks = ranks.clone(); Box::pin(async move { @@ -237,7 +237,7 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult { }) }, ctx, - m.channel_id, + m, std::time::Duration::from_secs(60), ) .await?; @@ -301,7 +301,7 @@ pub async fn contestranks(ctx: &Context, m: &Message, mut args: Args) -> Command const ITEMS_PER_PAGE: usize = 10; let total_pages = (ranks.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; - paginate_fn( + paginate_reply_fn( move |page, ctx, msg| { let contest = contest.clone(); let problems = problems.clone(); @@ -391,7 +391,7 @@ pub async fn contestranks(ctx: &Context, m: &Message, mut args: Args) -> Command }) }, ctx, - m.channel_id, + m, Duration::from_secs(60), ) .await?; diff --git a/youmubot-core/src/community/mod.rs b/youmubot-core/src/community/mod.rs index 974017f..731c64e 100644 --- a/youmubot-core/src/community/mod.rs +++ b/youmubot-core/src/community/mod.rs @@ -114,6 +114,7 @@ pub async fn choose(ctx: &Context, m: &Message, mut args: Args) -> CommandResult .push(". Congrats! 🎉 🎊 🥳") .build(), ) + .reference_message(m) }) .await?; diff --git a/youmubot-core/src/community/roles.rs b/youmubot-core/src/community/roles.rs index 79e0042..4062ed1 100644 --- a/youmubot-core/src/community/roles.rs +++ b/youmubot-core/src/community/roles.rs @@ -33,7 +33,7 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult { const ROLES_PER_PAGE: usize = 8; let pages = (roles.len() + ROLES_PER_PAGE - 1) / ROLES_PER_PAGE; - paginate_fn( + paginate_reply_fn( |page, ctx, msg| { let roles = roles.clone(); Box::pin(async move { @@ -99,7 +99,7 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult { }) }, ctx, - m.channel_id, + m, std::time::Duration::from_secs(60 * 10), ) .await?; diff --git a/youmubot-core/src/fun/images.rs b/youmubot-core/src/fun/images.rs index f41e3a1..f3869b8 100644 --- a/youmubot-core/src/fun/images.rs +++ b/youmubot-core/src/fun/images.rs @@ -64,7 +64,7 @@ async fn message_command( return Ok(()); } let images = std::sync::Arc::new(images); - paginate_fn( + paginate_reply_fn( move |page, ctx, msg: &mut Message| { let images = images.clone(); Box::pin(async move { @@ -87,7 +87,7 @@ async fn message_command( }) }, ctx, - msg.channel_id, + msg, std::time::Duration::from_secs(120), ) .await?; diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 88a4d4d..f1ce773 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -144,7 +144,7 @@ impl Announcer { .filter(|u| u.mode == mode && u.date > last_update) .map(|ev| CollectedScore::from_event(&*client, &user, ev, user_id, &channels[..])) .collect::>() - .filter_map(|u| future::ready(u.ok_or_print())) + .filter_map(|u| future::ready(u.pls_ok())) .collect::>() .await; let top_scores = scores.into_iter().filter_map(|(rank, score)| { @@ -169,7 +169,7 @@ impl Announcer { .collect::>() .try_collect::>() .await - .ok_or_print(); + .pls_ok(); }); Ok(pp) } @@ -304,7 +304,7 @@ impl<'a> CollectedScore<'a> { }) }) .await?; - save_beatmap(&*ctx.data.read().await, channel, &bm).ok_or_print(); + save_beatmap(&*ctx.data.read().await, channel, &bm).pls_ok(); Ok(m) } } @@ -313,22 +313,3 @@ enum ScoreType { TopRecord(u8), WorldRecord(u16), } - -trait OkPrint { - type Output; - fn ok_or_print(self) -> Option; -} - -impl OkPrint for Result { - type Output = T; - - fn ok_or_print(self) -> Option { - match self { - Ok(v) => Some(v), - Err(e) => { - eprintln!("Error: {:?}", e); - None - } - } - } -} diff --git a/youmubot-osu/src/discord/beatmap_cache.rs b/youmubot-osu/src/discord/beatmap_cache.rs index 2528dd1..08aec70 100644 --- a/youmubot-osu/src/discord/beatmap_cache.rs +++ b/youmubot-osu/src/discord/beatmap_cache.rs @@ -11,6 +11,7 @@ use youmubot_prelude::*; pub struct BeatmapMetaCache { client: Arc, cache: DashMap<(u64, Mode), Beatmap>, + beatmapsets: DashMap>, } impl TypeMapKey for BeatmapMetaCache { @@ -23,6 +24,7 @@ impl BeatmapMetaCache { BeatmapMetaCache { client, cache: DashMap::new(), + beatmapsets: DashMap::new(), } } async fn insert_if_possible(&self, id: u64, mode: Option) -> Result { @@ -54,17 +56,47 @@ impl BeatmapMetaCache { Ok( match (&[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania]) .iter() - .filter_map(|&mode| { + .find_map(|&mode| { self.cache .get(&(id, mode)) .filter(|b| b.mode == mode) .map(|b| b.clone()) - }) - .next() - { + }) { Some(v) => v, None => self.insert_if_possible(id, None).await?, }, ) } + + /// Get a beatmapset from its ID. + pub async fn get_beatmapset(&self, id: u64) -> Result> { + match self.beatmapsets.get(&id).map(|v| v.clone()) { + Some(v) => { + v.into_iter() + .map(|id| self.get_beatmap_default(id)) + .collect::>() + .try_collect() + .await + } + None => { + let beatmaps = self + .client + .beatmaps(crate::BeatmapRequestKind::Beatmapset(id), |f| f) + .await?; + if beatmaps.is_empty() { + return Err(Error::msg("beatmapset not found")); + } + if let ApprovalStatus::Ranked(_) = &beatmaps[0].approval { + // Save each beatmap. + beatmaps.iter().for_each(|b| { + self.cache.insert((b.beatmap_id, b.mode), b.clone()); + }); + // Save the beatmapset mapping. + self.beatmapsets + .insert(id, beatmaps.iter().map(|v| v.beatmap_id).collect()); + } + Ok(beatmaps) + } + } + } } diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs new file mode 100644 index 0000000..47e0760 --- /dev/null +++ b/youmubot-osu/src/discord/display.rs @@ -0,0 +1,163 @@ +pub use beatmapset::display_beatmapset; + +mod beatmapset { + use crate::{ + discord::{cache::save_beatmap, oppai_cache::BeatmapInfo, BeatmapCache, BeatmapWithMode}, + models::{Beatmap, Mode, Mods}, + }; + use serenity::{ + collector::ReactionAction, model::channel::Message, model::channel::ReactionType, + }; + use youmubot_prelude::*; + + const SHOW_ALL_EMOTE: &str = "🗒️"; + + pub async fn display_beatmapset( + ctx: &Context, + beatmapset: Vec, + mode: Option, + mods: Option, + reply_to: &Message, + message: impl AsRef, + ) -> Result { + let mods = mods.unwrap_or(Mods::NOMOD); + + if beatmapset.is_empty() { + return Ok(false); + } + + let p = Paginate { + infos: vec![None; beatmapset.len()], + maps: beatmapset, + mode, + mods, + message: message.as_ref().to_owned(), + }; + + let ctx = ctx.clone(); + let reply_to = reply_to.clone(); + spawn_future(async move { + pagination::paginate_reply(p, &ctx, &reply_to, std::time::Duration::from_secs(60)) + .await + .pls_ok(); + }); + Ok(true) + } + + struct Paginate { + maps: Vec, + infos: Vec>>, + mode: Option, + mods: Mods, + message: String, + } + + impl Paginate { + async fn get_beatmap_info(&self, ctx: &Context, b: &Beatmap) -> Option { + let data = ctx.data.read().await; + let cache = data.get::().unwrap(); + let mode = self.mode.unwrap_or(b.mode).to_oppai_mode(); + cache + .get_beatmap(b.beatmap_id) + .map(move |v| { + v.ok() + .and_then(move |v| v.get_info_with(Some(mode?), self.mods).ok()) + }) + .await + } + } + + #[async_trait] + impl pagination::Paginate for Paginate { + async fn render( + &mut self, + page: u8, + ctx: &Context, + m: &mut serenity::model::channel::Message, + ) -> Result { + let page = page as usize; + if page == self.maps.len() { + m.edit(ctx, |f| { + f.embed(|em| { + crate::discord::embeds::beatmapset_embed(&self.maps[..], self.mode, em) + }) + }) + .await?; + return Ok(true); + } + if page > self.maps.len() { + return Ok(false); + } + + let map = &self.maps[page]; + let info = match &self.infos[page] { + Some(info) => info.clone(), + None => { + let info = self.get_beatmap_info(ctx, map).await; + self.infos[page] = Some(info.clone()); + info + } + }; + m.edit(ctx, |e| { + e.content(self.message.as_str()).embed(|em| { + crate::discord::embeds::beatmap_embed( + map, + self.mode.unwrap_or(map.mode), + self.mods, + info, + em, + ) + .footer(|f| { + f.text(format!( + "Difficulty {}/{}. To show all difficulties in a single embed (old style), react {}", + page + 1, + self.maps.len(), + SHOW_ALL_EMOTE, + )) + }) + }) + }) + .await?; + save_beatmap( + &*ctx.data.read().await, + m.channel_id, + &BeatmapWithMode(map.clone(), self.mode.unwrap_or(map.mode)), + ) + .ok(); + + Ok(true) + } + + async fn prerender( + &mut self, + ctx: &Context, + m: &mut serenity::model::channel::Message, + ) -> Result<()> { + m.react(&ctx, SHOW_ALL_EMOTE.parse::().unwrap()) + .await?; + Ok(()) + } + + async fn handle_reaction( + &mut self, + page: u8, + ctx: &Context, + message: &mut serenity::model::channel::Message, + reaction: &ReactionAction, + ) -> Result> { + // Render the old style. + let v = match reaction { + ReactionAction::Added(v) | ReactionAction::Removed(v) => v, + }; + if let ReactionType::Unicode(s) = &v.emoji { + if s == SHOW_ALL_EMOTE { + self.render(self.maps.len() as u8, ctx, message).await?; + return Ok(Some(self.maps.len() as u8)); + } + } + pagination::handle_pagination_reaction(page, self, ctx, message, reaction) + .await + .map(Some) + } + } +} diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index a6348ad..6c33f9e 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -1,17 +1,15 @@ -use super::OsuClient; use crate::{ discord::beatmap_cache::BeatmapMetaCache, discord::oppai_cache::{BeatmapCache, BeatmapInfo}, models::{Beatmap, Mode, Mods}, - request::BeatmapRequestKind, }; use lazy_static::lazy_static; use regex::Regex; -use serenity::{builder::CreateMessage, model::channel::Message, utils::MessageBuilder}; +use serenity::{model::channel::Message, utils::MessageBuilder}; use std::str::FromStr; use youmubot_prelude::*; -use super::embeds::{beatmap_embed, beatmapset_embed}; +use super::embeds::beatmap_embed; lazy_static! { static ref OLD_LINK_REGEX: Regex = Regex::new( @@ -38,39 +36,32 @@ pub fn hook<'a>( handle_new_links(ctx, &msg.content), handle_short_links(ctx, &msg, &msg.content), ); - let last_beatmap = stream::select(old_links, stream::select(new_links, short_links)) + stream::select(old_links, stream::select(new_links, short_links)) .then(|l| async move { - let mut bm: Option = None; - msg.channel_id - .send_message(&ctx, |m| match l.embed { - EmbedType::Beatmap(b, info, mods) => { - let t = handle_beatmap(&b, info, l.link, l.mode, mods, m); - let mode = l.mode.unwrap_or(b.mode); - bm = Some(super::BeatmapWithMode(b, mode)); - t - } - EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, m), - }) - .await?; - let r: Result<_> = Ok(bm); - r - }) - .filter_map(|v| async move { - match v { - Ok(v) => v, - Err(e) => { - eprintln!("{}", e); - None + match l.embed { + EmbedType::Beatmap(b, info, mods) => { + handle_beatmap(ctx, &b, info, l.link, l.mode, mods, msg) + .await + .pls_ok(); + let mode = l.mode.unwrap_or(b.mode); + let bm = super::BeatmapWithMode(b, mode); + crate::discord::cache::save_beatmap( + &*ctx.data.read().await, + msg.channel_id, + &bm, + ) + .pls_ok(); + } + EmbedType::Beatmapset(b) => { + handle_beatmapset(ctx, b, l.link, l.mode, msg) + .await + .pls_ok(); } } }) - .fold(None, |_, v| async move { Some(v) }) + .collect::<()>() .await; - // Save the beatmap for query later. - if let Some(t) = last_beatmap { - super::cache::save_beatmap(&*ctx.data.read().await, msg.channel_id, &t)?; - } Ok(()) }) } @@ -94,14 +85,9 @@ fn handle_old_links<'a>( .captures_iter(content) .map(move |capture| async move { let data = ctx.data.read().await; - let osu = data.get::().unwrap(); let cache = data.get::().unwrap(); + let osu = data.get::().unwrap(); let req_type = capture.name("link_type").unwrap().as_str(); - let req = match req_type { - "b" => BeatmapRequestKind::Beatmap(capture["id"].parse()?), - "s" => BeatmapRequestKind::Beatmapset(capture["id"].parse()?), - _ => unreachable!(), - }; let mode = capture .name("mode") .map(|v| v.as_str().parse()) @@ -115,12 +101,14 @@ fn handle_old_links<'a>( _ => return None, }) }); - let beatmaps = osu - .beatmaps(req, |v| match mode { - Some(m) => v.mode(m, true), - None => v, - }) - .await?; + let beatmaps = match req_type { + "b" => vec![match mode { + Some(mode) => osu.get_beatmap(capture["id"].parse()?, mode).await?, + None => osu.get_beatmap_default(capture["id"].parse()?).await?, + }], + "s" => osu.get_beatmapset(capture["id"].parse()?).await?, + _ => unreachable!(), + }; if beatmaps.is_empty() { return Ok(None); } @@ -130,7 +118,7 @@ fn handle_old_links<'a>( // collect beatmap info let mods = capture .name("mods") - .map(|v| Mods::from_str(v.as_str()).ok()) + .map(|v| Mods::from_str(v.as_str()).pls_ok()) .flatten() .unwrap_or(Mods::NOMOD); let info = match mode.unwrap_or(b.mode).to_oppai_mode() { @@ -138,7 +126,7 @@ fn handle_old_links<'a>( .get_beatmap(b.beatmap_id) .await .and_then(|b| b.get_info_with(Some(mode), mods)) - .ok(), + .pls_ok(), None => None, }; Some(ToPrint { @@ -176,24 +164,22 @@ fn handle_new_links<'a>( .captures_iter(content) .map(|capture| async move { let data = ctx.data.read().await; - let osu = data.get::().unwrap(); + let osu = data.get::().unwrap(); let cache = data.get::().unwrap(); let mode = capture .name("mode") .and_then(|v| Mode::parse_from_new_site(v.as_str())); let link = capture.get(0).unwrap().as_str(); - let req = match capture.name("beatmap_id") { - Some(ref v) => BeatmapRequestKind::Beatmap(v.as_str().parse()?), - None => BeatmapRequestKind::Beatmapset( - capture.name("set_id").unwrap().as_str().parse()?, - ), + let beatmaps = match capture.name("beatmap_id") { + Some(ref v) => vec![match mode { + Some(mode) => osu.get_beatmap(v.as_str().parse()?, mode).await?, + None => osu.get_beatmap_default(v.as_str().parse()?).await?, + }], + None => { + osu.get_beatmapset(capture.name("set_id").unwrap().as_str().parse()?) + .await? + } }; - let beatmaps = osu - .beatmaps(req, |v| match mode { - Some(m) => v.mode(m, true), - None => v, - }) - .await?; if beatmaps.is_empty() { return Ok(None); } @@ -203,14 +189,14 @@ fn handle_new_links<'a>( // collect beatmap info let mods = capture .name("mods") - .and_then(|v| Mods::from_str(v.as_str()).ok()) + .and_then(|v| Mods::from_str(v.as_str()).pls_ok()) .unwrap_or(Mods::NOMOD); let info = match mode.unwrap_or(beatmap.mode).to_oppai_mode() { Some(mode) => cache .get_beatmap(beatmap.beatmap_id) .await .and_then(|b| b.get_info_with(Some(mode), mods)) - .ok(), + .pls_ok(), None => None, }; Some(ToPrint { @@ -269,14 +255,14 @@ fn handle_short_links<'a>( }?; let mods = capture .name("mods") - .and_then(|v| Mods::from_str(v.as_str()).ok()) + .and_then(|v| Mods::from_str(v.as_str()).pls_ok()) .unwrap_or(Mods::NOMOD); let info = match mode.unwrap_or(beatmap.mode).to_oppai_mode() { Some(mode) => cache .get_beatmap(beatmap.beatmap_id) .await .and_then(|b| b.get_info_with(Some(mode), mods)) - .ok(), + .pls_ok(), None => None, }; let r: Result<_> = Ok(ToPrint { @@ -298,40 +284,47 @@ fn handle_short_links<'a>( }) } -fn handle_beatmap<'a, 'b>( +async fn handle_beatmap<'a, 'b>( + ctx: &Context, beatmap: &Beatmap, info: Option, link: &'_ str, mode: Option, mods: Mods, - m: &'a mut CreateMessage<'b>, -) -> &'a mut CreateMessage<'b> { - m.content( - MessageBuilder::new() - .push("Beatmap information for ") - .push_mono_safe(link) - .build(), - ) - .embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), mods, info, b)) + reply_to: &Message, +) -> Result<()> { + reply_to + .channel_id + .send_message(ctx, |m| { + m.content( + MessageBuilder::new() + .push("Beatmap information for ") + .push_mono_safe(link) + .build(), + ) + .embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), mods, info, b)) + .reference_message(reply_to) + }) + .await?; + Ok(()) } -fn handle_beatmapset<'a, 'b>( +async fn handle_beatmapset<'a, 'b>( + ctx: &Context, beatmaps: Vec, link: &'_ str, mode: Option, - m: &'a mut CreateMessage<'b>, -) -> &'a mut CreateMessage<'b> { - let mut beatmaps = beatmaps; - beatmaps.sort_by(|a, b| { - (mode.unwrap_or(a.mode) as u8, a.difficulty.stars) - .partial_cmp(&(mode.unwrap_or(b.mode) as u8, b.difficulty.stars)) - .unwrap() - }); - m.content( - MessageBuilder::new() - .push("Beatmapset information for ") - .push_mono_safe(link) - .build(), + reply_to: &Message, +) -> Result<()> { + crate::discord::display::display_beatmapset( + &ctx, + beatmaps, + mode, + None, + reply_to, + format!("Beatmapset information for `{}`", link), ) - .embed(|b| beatmapset_embed(&beatmaps, mode, b)) + .await + .pls_ok(); + Ok(()) } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 102846f..465359c 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -8,7 +8,7 @@ use crate::{ use serenity::{ framework::standard::{ macros::{command, group}, - Args, CommandError as Error, CommandResult, + Args, CommandResult, }, model::channel::Message, utils::MessageBuilder, @@ -20,6 +20,7 @@ mod announcer; pub(crate) mod beatmap_cache; mod cache; mod db; +pub(crate) mod display; pub(crate) mod embeds; mod hook; pub(crate) mod oppai_cache; @@ -292,7 +293,7 @@ fn to_user_id_query( db.get(&id) .cloned() .map(|u| UserID::ID(u.id)) - .ok_or(Error::from("No saved account found")) + .ok_or(Error::msg("No saved account found")) } enum Nth { @@ -306,7 +307,7 @@ impl FromStr for Nth { if s == "--all" || s == "-a" || s == "##" { Ok(Nth::All) } else if !s.starts_with("#") { - Err(Error::from("Not an order")) + Err(Error::msg("Not an order")) } else { let v = s.split_at("#".len()).1.parse()?; Ok(Nth::Nth(v)) @@ -328,7 +329,7 @@ async fn list_plays<'a>( const ITEMS_PER_PAGE: usize = 5; let total_pages = (plays.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; - paginate_fn( + paginate_reply_fn( move |page, ctx, msg| { let plays = plays.clone(); Box::pin(async move { @@ -464,7 +465,7 @@ async fn list_plays<'a>( }) }, ctx, - m.channel_id, + m, std::time::Duration::from_secs(60), ) .await?; @@ -488,7 +489,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let user = osu .user(user, |f| f.mode(mode)) .await? - .ok_or(Error::from("User not found"))?; + .ok_or(Error::msg("User not found"))?; match nth { Nth::Nth(nth) => { let recent_play = osu @@ -496,18 +497,18 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu .await? .into_iter() .last() - .ok_or(Error::from("No such play"))?; + .ok_or(Error::msg("No such play"))?; let beatmap = meta_cache.get_beatmap(recent_play.beatmap_id, mode).await?; let content = oppai.get_beatmap(beatmap.beatmap_id).await?; let beatmap_mode = BeatmapWithMode(beatmap, mode); msg.channel_id .send_message(&ctx, |m| { - m.content(format!( - "{}: here is the play that you requested", - msg.author - )) - .embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user).build(m)) + m.content(format!("Here is the play that you requested",)) + .embed(|m| { + score_embed(&recent_play, &beatmap_mode, &content, &user).build(m) + }) + .reference_message(msg) }) .await?; @@ -524,17 +525,46 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu Ok(()) } +/// Get beatmapset. +struct OptBeatmapset; + +impl FromStr for OptBeatmapset { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "--set" | "-s" | "--beatmapset" => Ok(Self), + _ => Err(Error::msg("not opt beatmapset")), + } + } +} + #[command] #[description = "Show information from the last queried beatmap."] -#[usage = "[mods = no mod]"] -#[max_args(1)] +#[usage = "[--set/-s/--beatmapset] / [mods = no mod]"] +#[max_args(2)] pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let data = ctx.data.read().await; let b = cache::get_beatmap(&*data, msg.channel_id)?; + let beatmapset = args.find::().is_ok(); match b { Some(BeatmapWithMode(b, m)) => { let mods = args.find::().unwrap_or(Mods::NOMOD); + if beatmapset { + let beatmap_cache = data.get::().unwrap(); + let beatmapset = beatmap_cache.get_beatmapset(b.beatmapset_id).await?; + display::display_beatmapset( + ctx, + beatmapset, + None, + Some(mods), + msg, + "Here is the beatmapset you requested!", + ) + .await?; + return Ok(()); + } let info = data .get::() .unwrap() @@ -544,11 +574,9 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .ok(); msg.channel_id .send_message(&ctx, |f| { - f.content(format!( - "{}: here is the beatmap you requested!", - msg.author - )) - .embed(|c| beatmap_embed(&b, m, mods, info, c)) + f.content("Here is the beatmap you requested!") + .embed(|c| beatmap_embed(&b, m, mods, info, c)) + .reference_message(msg) }) .await?; } @@ -594,7 +622,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul let user = osu .user(user, |f| f) .await? - .ok_or(Error::from("User not found"))?; + .ok_or(Error::msg("User not found"))?; let scores = osu .scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m)) .await?; @@ -646,7 +674,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let user = osu .user(user, |f| f.mode(mode)) .await? - .ok_or(Error::from("User not found"))?; + .ok_or(Error::msg("User not found"))?; match nth { Nth::Nth(nth) => { @@ -659,7 +687,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let top_play = top_play .into_iter() .last() - .ok_or(Error::from("No such play"))?; + .ok_or(Error::msg("No such play"))?; let beatmap = meta_cache.get_beatmap(top_play.beatmap_id, mode).await?; let content = oppai.get_beatmap(beatmap.beatmap_id).await?; let beatmap = BeatmapWithMode(beatmap, mode); diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 9fed9db..2d7f9f5 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -60,7 +60,7 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR let users = std::sync::Arc::new(users); let last_update = last_update.unwrap(); - paginate_fn( + paginate_reply_fn( move |page: u8, ctx: &Context, m: &mut Message| { const ITEMS_PER_PAGE: usize = 10; let users = users.clone(); @@ -98,7 +98,7 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR }) }, ctx, - m.channel_id, + m, std::time::Duration::from_secs(60), ) .await?; @@ -380,7 +380,7 @@ async fn show_leaderboard( .await?; return Ok(()); } - paginate_fn( + paginate_reply_fn( move |page: u8, ctx: &Context, m: &mut Message| { const ITEMS_PER_PAGE: usize = 5; let start = (page as usize) * ITEMS_PER_PAGE; @@ -516,7 +516,7 @@ async fn show_leaderboard( }) }, ctx, - m.channel_id, + m, std::time::Duration::from_secs(60), ) .await?; diff --git a/youmubot-osu/src/models/mods.rs b/youmubot-osu/src/models/mods.rs index 5a49984..e830728 100644 --- a/youmubot-osu/src/models/mods.rs +++ b/youmubot-osu/src/models/mods.rs @@ -74,6 +74,10 @@ impl std::str::FromStr for Mods { type Err = String; fn from_str(mut s: &str) -> Result { let mut res = Self::default(); + // Strip leading + + if s.starts_with("+") { + s = &s[1..]; + } while s.len() >= 2 { let (m, nw) = s.split_at(2); s = nw; @@ -87,7 +91,7 @@ impl std::str::FromStr for Mods { "DT" => res |= Mods::DT, "RX" => res |= Mods::RX, "HT" => res |= Mods::HT, - "NC" => res |= Mods::NC, + "NC" => res |= Mods::NC | Mods::DT, "FL" => res |= Mods::FL, "AT" => res |= Mods::AT, "SO" => res |= Mods::SO, @@ -121,9 +125,13 @@ impl fmt::Display for Mods { } write!(f, "+")?; for p in MODS_WITH_NAMES.iter() { - if self.contains(p.0) { - write!(f, "{}", p.1)?; + if !self.contains(p.0) { + continue; } + if p.0 == Mods::DT && self.contains(Mods::NC) { + continue; + } + write!(f, "{}", p.1)?; } Ok(()) } diff --git a/youmubot-prelude/src/lib.rs b/youmubot-prelude/src/lib.rs index 6e93af5..4d4c512 100644 --- a/youmubot-prelude/src/lib.rs +++ b/youmubot-prelude/src/lib.rs @@ -15,13 +15,14 @@ pub use announcer::{Announcer, AnnouncerHandler}; pub use args::{Duration, UsernameArg}; pub use hook::Hook; pub use member_cache::MemberCache; -pub use pagination::{paginate, paginate_fn}; +pub use pagination::{paginate, paginate_fn, paginate_reply, paginate_reply_fn, Paginate}; /// Re-exporting async_trait helps with implementing Announcer. pub use async_trait::async_trait; /// Re-export the anyhow errors pub use anyhow::{Error, Result}; +pub use debugging_ok::OkPrint; /// Re-export useful future and stream utils pub use futures_util::{future, stream, FutureExt, StreamExt, TryFutureExt, TryStreamExt}; @@ -63,3 +64,24 @@ pub mod prelude_commands { Ok(()) } } + +mod debugging_ok { + pub trait OkPrint { + type Output; + fn pls_ok(self) -> Option; + } + + impl OkPrint for Result { + type Output = T; + + fn pls_ok(self) -> Option { + match self { + Ok(v) => Some(v), + Err(e) => { + eprintln!("Error: {:?}", e); + None + } + } + } + } +} diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index 2c3023b..3f6c0ea 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -13,9 +13,32 @@ use tokio::time as tokio_time; const ARROW_RIGHT: &'static str = "➡️"; const ARROW_LEFT: &'static str = "⬅️"; +/// A trait that provides the implementation of a paginator. #[async_trait::async_trait] -pub trait Paginate: Send { +pub trait Paginate: Send + Sized { + /// Render the given page. async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result; + + /// Any setting-up before the rendering stage. + async fn prerender(&mut self, _ctx: &Context, _m: &mut Message) -> Result<()> { + Ok(()) + } + + /// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling + /// before handing the functionality over. + /// + /// Return the resulting current page, or `None` if the pagination should stop. + async fn handle_reaction( + &mut self, + page: u8, + ctx: &Context, + message: &mut Message, + reaction: &ReactionAction, + ) -> Result> { + handle_pagination_reaction(page, self, ctx, message, reaction) + .await + .map(Some) + } } #[async_trait::async_trait] @@ -33,17 +56,40 @@ where } } +// Paginate! with a pager function, and replying to a message. +/// If awaited, will block until everything is done. +pub async fn paginate_reply( + pager: impl Paginate, + ctx: &Context, + reply_to: &Message, + timeout: std::time::Duration, +) -> Result<()> { + let message = reply_to + .reply(&ctx, "Youmu is loading the first page...") + .await?; + paginate_with_first_message(pager, ctx, message, timeout).await +} + // Paginate! with a pager function. /// If awaited, will block until everything is done. pub async fn paginate( - mut pager: impl Paginate, + pager: impl Paginate, ctx: &Context, channel: ChannelId, timeout: std::time::Duration, ) -> Result<()> { - let mut message = channel + let message = channel .send_message(&ctx, |e| e.content("Youmu is loading the first page...")) .await?; + paginate_with_first_message(pager, ctx, message, timeout).await +} + +async fn paginate_with_first_message( + mut pager: impl Paginate, + ctx: &Context, + mut message: Message, + timeout: std::time::Duration, +) -> Result<()> { // React to the message message .react(&ctx, ReactionType::try_from(ARROW_LEFT)?) @@ -51,6 +97,7 @@ pub async fn paginate( message .react(&ctx, ReactionType::try_from(ARROW_RIGHT)?) .await?; + pager.prerender(&ctx, &mut message).await?; pager.render(0, ctx, &mut message).await?; // Build a reaction collector let mut reaction_collector = message.await_reactions(&ctx).removed(true).await; @@ -62,8 +109,12 @@ pub async fn paginate( Err(_) => break Ok(()), Ok(None) => break Ok(()), Ok(Some(reaction)) => { - page = match handle_reaction(page, &mut pager, ctx, &mut message, &reaction).await { - Ok(v) => v, + page = match pager + .handle_reaction(page, ctx, &mut message, &reaction) + .await + { + Ok(Some(v)) => v, + Ok(None) => break Ok(()), Err(e) => break Err(e), }; } @@ -90,8 +141,23 @@ pub async fn paginate_fn( paginate(pager, ctx, channel, timeout).await } +/// Same as `paginate_reply`, but for function inputs, especially anonymous functions. +pub async fn paginate_reply_fn( + pager: impl for<'m> FnMut( + u8, + &'m Context, + &'m mut Message, + ) -> std::pin::Pin> + Send + 'm>> + + Send, + ctx: &Context, + reply_to: &Message, + timeout: std::time::Duration, +) -> Result<()> { + paginate_reply(pager, ctx, reply_to, timeout).await +} + // Handle the reaction and return a new page number. -async fn handle_reaction( +pub async fn handle_pagination_reaction( page: u8, pager: &mut impl Paginate, ctx: &Context,