From cf67a3e0e562f108a682d261fe7b8c92a138447d Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Fri, 2 Feb 2024 00:35:08 +0100 Subject: [PATCH] Implement user api from rosu --- youmubot-osu/src/discord/mod.rs | 8 ++- youmubot-osu/src/lib.rs | 4 +- youmubot-osu/src/models/mod.rs | 35 +++++------- youmubot-osu/src/models/parse.rs | 45 ++++++++++++++- youmubot-osu/src/models/raw.rs | 1 - youmubot-osu/src/models/rosu.rs | 74 +++++++++++++++++++++++++ youmubot-osu/src/request.rs | 95 +++++++++++++++++++++++++------- 7 files changed, 210 insertions(+), 52 deletions(-) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index a8e7ff9..702f704 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -187,7 +187,7 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let osu = data.get::().unwrap(); let user = args.single::()?; - let u = match osu.user(UserID::Auto(user), |f| f).await? { + let u = match osu.user(UserID::from_string(user), |f| f).await? { Some(u) => u, None => { msg.reply(&ctx, "user not found...").await?; @@ -300,7 +300,9 @@ pub async fn forcesave(ctx: &Context, msg: &Message, mut args: Args) -> CommandR let target = args.single::()?; let username = args.quoted().trimmed().single::()?; - let user: Option = osu.user(UserID::Auto(username.clone()), |f| f).await?; + let user: Option = osu + .user(UserID::from_string(username.clone()), |f| f) + .await?; match user { Some(u) => { add_user(target, u, &data).await?; @@ -358,7 +360,7 @@ async fn to_user_id_query( msg: &Message, ) -> Result { let id = match s { - Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)), + Some(UsernameArg::Raw(s)) => return Ok(UserID::from_string(s)), Some(UsernameArg::Tagged(r)) => r, None => msg.author.id, }; diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 64d6af5..efda800 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -80,9 +80,7 @@ impl Client { ) -> Result, Error> { let mut r = UserRequestBuilder::new(user); f(&mut r); - let res: Vec = r.build(self).await?.json().await?; - let res = vec_try_into(res)?; - Ok(res.into_iter().next()) + r.build(self).await } pub async fn scores( diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index c905782..1946ed4 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -13,10 +13,6 @@ pub(crate) mod rosu; pub use mods::Mods; use serenity::utils::MessageBuilder; -lazy_static::lazy_static! { - static ref EVENT_RANK_REGEX: Regex = Regex::new(r#"^.+achieved .*rank #(\d+).* on .+\((.+)\)$"#).unwrap(); -} - #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum ApprovalStatus { Loved, @@ -436,13 +432,15 @@ impl Beatmap { } } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct UserEvent { - pub display_html: String, - pub beatmap_id: Option, - pub beatmapset_id: Option, - pub date: DateTime, - pub epic_factor: u8, +#[derive(Clone, Debug)] +pub enum UserEvent { + Rank(UserEventRank), + OtherV1 { + display_html: String, + date: DateTime, + epic_factor: u8, + }, + OtherV2(rosu_v2::model::recent_event::RecentEvent), } /// Represents a "achieved rank #x on beatmap" event. @@ -457,19 +455,14 @@ pub struct UserEventRank { impl UserEvent { /// Try to parse the event into a "rank" event. pub fn to_event_rank(&self) -> Option { - let captures = EVENT_RANK_REGEX.captures(self.display_html.as_str())?; - let rank: u16 = captures.get(1)?.as_str().parse().ok()?; - let mode: Mode = Mode::parse_from_display(captures.get(2)?.as_str())?; - Some(UserEventRank { - beatmap_id: self.beatmap_id?, - date: self.date, - mode, - rank, - }) + match self { + UserEvent::Rank(r) => Some(r.clone()), + _ => None, + } } } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug)] pub struct User { pub id: u64, pub username: String, diff --git a/youmubot-osu/src/models/parse.rs b/youmubot-osu/src/models/parse.rs index 9dc5e46..5ae9760 100644 --- a/youmubot-osu/src/models/parse.rs +++ b/youmubot-osu/src/models/parse.rs @@ -7,12 +7,17 @@ use std::convert::TryFrom; use std::time::Duration; use std::{error::Error, fmt, str::FromStr}; +lazy_static::lazy_static! { + static ref EVENT_RANK_REGEX: Regex = Regex::new(r#"^.+achieved .*rank #(\d+).* on .+\((.+)\)$"#).unwrap(); +} + /// Errors that can be identified from parsing. #[derive(Debug)] pub enum ParseError { InvalidValue { field: &'static str, value: String }, FromStr(String), NoApprovalDate, + NotUserEventRank, DateParseError(ChronoParseError), } @@ -26,6 +31,7 @@ impl fmt::Display for ParseError { } => write!(f, "Invalid value `{}` for {}", value, field), FromStr(ref s) => write!(f, "Invalid value `{}` parsing from string", s), NoApprovalDate => write!(f, "Approval date expected but not found"), + NotUserEventRank => write!(f, "Trying to parse user event as UserEventRank"), DateParseError(ref r) => write!(f, "Error parsing date: {}", r), } } @@ -153,15 +159,48 @@ impl TryFrom for Beatmap { } fn parse_user_event(s: raw::UserEvent) -> ParseResult { - Ok(UserEvent { + match parse_user_event_rank(&s) { + Ok(r) => return Ok(UserEvent::Rank(r)), + Err(_) => (), + }; + Ok(UserEvent::OtherV1 { display_html: s.display_html, - beatmap_id: s.beatmap_id.map(parse_from_str).transpose()?, - beatmapset_id: s.beatmapset_id.map(parse_from_str).transpose()?, date: parse_date(&s.date)?, epic_factor: parse_from_str(&s.epicfactor)?, }) } +fn parse_user_event_rank(s: &raw::UserEvent) -> ParseResult { + let captures = EVENT_RANK_REGEX + .captures(s.display_html.as_str()) + .ok_or(ParseError::NotUserEventRank)?; + let rank: u16 = captures + .get(1) + .ok_or(ParseError::NotUserEventRank)? + .as_str() + .parse() + .map_err(|_| ParseError::NotUserEventRank)?; + let mode = super::Mode::parse_from_display( + captures + .get(2) + .ok_or(ParseError::NotUserEventRank)? + .as_str(), + ) + .ok_or(ParseError::NotUserEventRank)?; + let beatmap_id = s + .beatmap_id + .as_ref() + .ok_or(ParseError::NotUserEventRank)? + .parse::() + .map_err(|_| ParseError::NotUserEventRank)?; + Ok(UserEventRank { + beatmap_id, + date: parse_date(&s.date)?, + mode, + rank, + }) +} + fn parse_mode(s: impl AsRef) -> ParseResult { let t: u8 = parse_from_str(s)?; use Mode::*; diff --git a/youmubot-osu/src/models/raw.rs b/youmubot-osu/src/models/raw.rs index 38edcaf..cb42842 100644 --- a/youmubot-osu/src/models/raw.rs +++ b/youmubot-osu/src/models/raw.rs @@ -71,7 +71,6 @@ pub(crate) struct User { pub(crate) struct UserEvent { pub display_html: String, pub beatmap_id: Option, - pub beatmapset_id: Option, pub date: String, pub epicfactor: String, } diff --git a/youmubot-osu/src/models/rosu.rs b/youmubot-osu/src/models/rosu.rs index ebeb4e7..c7d9714 100644 --- a/youmubot-osu/src/models/rosu.rs +++ b/youmubot-osu/src/models/rosu.rs @@ -66,6 +66,69 @@ impl Beatmap { } } +impl User { + pub(crate) fn from_rosu( + user: rosu::user::User, + stats: rosu::user::UserStatistics, + events: Vec, + ) -> Self { + Self { + id: user.user_id as u64, + username: user.username.into_string(), + joined: time_to_utc(user.join_date), + country: user.country_code.to_string(), + count_300: 0, // why do we even want this + count_100: 0, // why do we even want this + count_50: 0, // why do we even want this + play_count: stats.playcount as u64, + played_time: Duration::from_secs(stats.playtime as u64), + ranked_score: stats.ranked_score, + total_score: stats.total_score, + count_ss: stats.grade_counts.ss as u64, + count_ssh: stats.grade_counts.ssh as u64, + count_s: stats.grade_counts.s as u64, + count_sh: stats.grade_counts.sh as u64, + count_a: stats.grade_counts.a as u64, + events: events.into_iter().map(UserEvent::from).collect(), + rank: stats.global_rank.unwrap_or(0) as u64, + country_rank: stats.country_rank.unwrap_or(0) as u64, + level: stats.level.current as f64 + stats.level.progress as f64 / 100.0, + pp: Some(stats.pp as f64), + accuracy: stats.accuracy as f64, + } + } +} + +impl From for UserEvent { + fn from(value: rosu::recent_event::RecentEvent) -> Self { + match value.event_type { + rosu::recent_event::EventType::Rank { + grade: _, + rank, + mode, + beatmap, + user: _, + } => Self::Rank(UserEventRank { + beatmap_id: { + beatmap + .url + .trim_start_matches("/b/") + .trim_end_matches("?m=0") + .trim_end_matches("?m=1") + .trim_end_matches("?m=2") + .trim_end_matches("?m=3") + .parse::() + .unwrap() + }, + rank: rank as u16, + mode: mode.into(), + date: time_to_utc(value.created_at), + }), + _ => Self::OtherV2(value), + } + } +} + impl Difficulty { pub(crate) fn from_rosu(bm: &rosu::beatmap::Beatmap) -> Self { Self { @@ -98,6 +161,17 @@ impl From for Mode { } } +impl From for rosu::GameMode { + fn from(value: Mode) -> Self { + match value { + Mode::Std => rosu::GameMode::Osu, + Mode::Taiko => rosu::GameMode::Taiko, + Mode::Catch => rosu::GameMode::Catch, + Mode::Mania => rosu::GameMode::Mania, + } + } +} + impl From for Genre { fn from(value: rosu::beatmap::Genre) -> Self { match value { diff --git a/youmubot-osu/src/request.rs b/youmubot-osu/src/request.rs index 516376a..f8da172 100644 --- a/youmubot-osu/src/request.rs +++ b/youmubot-osu/src/request.rs @@ -1,6 +1,7 @@ use crate::models::{Mode, Mods}; use crate::Client; use chrono::{DateTime, Utc}; +use rosu_v2::error::OsuError; use youmubot_prelude::*; trait ToQuery { @@ -52,7 +53,25 @@ impl ToQuery for (&'static str, DateTime) { pub enum UserID { Username(String), ID(u64), - Auto(String), +} + +impl From for rosu_v2::prelude::UserId { + fn from(value: UserID) -> Self { + match value { + UserID::Username(s) => rosu_v2::request::UserId::Name(s.into()), + UserID::ID(id) => rosu_v2::request::UserId::Id(id as u32), + } + } +} + +impl UserID { + pub fn from_string(s: impl Into) -> UserID { + let s = s.into(); + match s.parse::() { + Ok(id) => UserID::ID(id), + Err(_) => UserID::Username(s), + } + } } impl ToQuery for UserID { @@ -61,7 +80,6 @@ impl ToQuery for UserID { match self { Username(ref s) => vec![("u", s.clone()), ("type", "string".to_owned())], ID(u) => vec![("u", u.to_string()), ("type", "id".to_owned())], - Auto(ref s) => vec![("u", s.clone())], } } } @@ -82,6 +100,14 @@ impl ToQuery for BeatmapRequestKind { } } +fn handle_not_found(v: Result) -> Result, OsuError> { + match v { + Ok(v) => Ok(Some(v)), + Err(OsuError::NotFound) => Ok(None), + Err(e) => Err(e), + } +} + pub mod builders { use reqwest::Response; @@ -114,19 +140,30 @@ pub mod builders { pub(crate) async fn build(self, client: &Client) -> Result> { Ok(match self.kind { BeatmapRequestKind::Beatmap(id) => { - let mut bm = client.rosu.beatmap().map_id(id as u32).await?; - let set = bm.mapset.take().unwrap(); - vec![models::Beatmap::from_rosu(bm, &set)] + match handle_not_found(client.rosu.beatmap().map_id(id as u32).await)? { + Some(mut bm) => { + let set = bm.mapset.take().unwrap(); + vec![models::Beatmap::from_rosu(bm, &set)] + } + None => vec![], + } } BeatmapRequestKind::Beatmapset(id) => { - let mut set = client.rosu.beatmapset(id as u32).await?; + let mut set = match handle_not_found(client.rosu.beatmapset(id as u32).await)? { + Some(v) => v, + None => return Ok(vec![]), + }; let bms = set.maps.take().unwrap(); bms.into_iter() .map(|bm| models::Beatmap::from_rosu(bm, &set)) .collect() } BeatmapRequestKind::BeatmapHash(hash) => { - let mut bm = client.rosu.beatmap().checksum(hash).await?; + let mut bm = match handle_not_found(client.rosu.beatmap().checksum(hash).await)? + { + Some(v) => v, + None => return Ok(vec![]), + }; let set = bm.mapset.take().unwrap(); vec![models::Beatmap::from_rosu(bm, &set)] } @@ -159,20 +196,36 @@ pub mod builders { self } - pub(crate) async fn build(&self, client: &Client) -> Result { - Ok(client - .build_request("https://osu.ppy.sh/api/get_user") - .await? - .query(&self.user.to_query()) - .query(&self.mode.to_query()) - .query( - &self - .event_days - .map(|v| ("event_days", v.to_string())) - .to_query(), - ) - .send() - .await?) + pub(crate) async fn build(self, client: &Client) -> Result> { + let mut r = client.rosu.user(self.user); + if let Some(mode) = self.mode { + r = r.mode(mode.into()); + } + let mut user = match handle_not_found(r.await)? { + Some(v) => v, + None => return Ok(None), + }; + let now = time::OffsetDateTime::now_utc() + - time::Duration::DAY * self.event_days.unwrap_or(31); + let mut events = + handle_not_found(client.rosu.recent_events(user.user_id).limit(50).await)? + .unwrap_or(vec![]); + events.retain(|e| (now <= e.created_at)); + let stats = user.statistics.take().unwrap(); + Ok(Some(models::User::from_rosu(user, stats, events))) + // Ok(client + // .build_request("https://osu.ppy.sh/api/get_user") + // .await? + // .query(&self.user.to_query()) + // .query(&self.mode.to_query()) + // .query( + // &self + // .event_days + // .map(|v| ("event_days", v.to_string())) + // .to_query(), + // ) + // .send() + // .await?) } }