diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index b1ce920..ffa1f57 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -10,12 +10,23 @@ use request::builders::*; use request::*; use reqwest::Client as HTTPClient; use serenity::framework::standard::CommandError as Error; +use std::convert::TryInto; /// Client is the client that will perform calls to the osu! api server. pub struct Client { key: String, } +fn vec_try_into>(v: Vec) -> Result, T::Error> { + let mut res = Vec::with_capacity(v.len()); + + for u in v.into_iter() { + res.push(u.try_into()?); + } + + Ok(res) +} + impl Client { /// Create a new client from the given API key. pub fn new(key: impl AsRef) -> Client { @@ -42,8 +53,8 @@ impl Client { ) -> Result, Error> { let mut r = BeatmapRequestBuilder::new(kind); f(&mut r); - let res = self.build_request(client, r.build(client))?.json()?; - Ok(res) + let res: Vec = self.build_request(client, r.build(client))?.json()?; + Ok(vec_try_into(res)?) } pub fn user( @@ -54,7 +65,8 @@ impl Client { ) -> Result, Error> { let mut r = UserRequestBuilder::new(user); f(&mut r); - let res: Vec<_> = self.build_request(client, r.build(client))?.json()?; + let res: Vec = self.build_request(client, r.build(client))?.json()?; + let res = vec_try_into(res)?; Ok(res.into_iter().next()) } @@ -66,7 +78,8 @@ impl Client { ) -> Result, Error> { let mut r = ScoreRequestBuilder::new(beatmap_id); f(&mut r); - let mut res: Vec = self.build_request(client, r.build(client))?.json()?; + let res: Vec = self.build_request(client, r.build(client))?.json()?; + let mut res: Vec = vec_try_into(res)?; // with a scores request you need to fill the beatmap ids yourself res.iter_mut().for_each(|v| { @@ -102,7 +115,8 @@ impl Client { ) -> Result, Error> { let mut r = UserScoreRequestBuilder::new(u, user); f(&mut r); - let res = self.build_request(client, r.build(client))?.json()?; + let res: Vec = self.build_request(client, r.build(client))?.json()?; + let res = vec_try_into(res)?; Ok(res) } } diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 5f955a4..adcdc53 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -1,13 +1,15 @@ -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use std::fmt; +use std::time::Duration; -pub mod deser; pub mod mods; +pub mod parse; pub(crate) mod raw; pub use mods::Mods; -#[derive(Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum ApprovalStatus { Loved, Qualified, @@ -28,7 +30,7 @@ impl fmt::Display for ApprovalStatus { } } -#[derive(Debug)] +#[derive(Debug, Deserialize, Serialize)] pub struct Difficulty { pub stars: f64, pub aim: Option, @@ -45,7 +47,7 @@ pub struct Difficulty { pub max_combo: Option, } -#[derive(Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)] pub enum Genre { Any, Unspecified, @@ -70,7 +72,7 @@ impl fmt::Display for Genre { } } -#[derive(Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Language { Any, Other, @@ -92,7 +94,7 @@ impl fmt::Display for Language { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Mode { Std, Taiko, @@ -116,7 +118,7 @@ impl fmt::Display for Mode { } } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct Beatmap { // Beatmapset info pub approval: ApprovalStatus, @@ -169,7 +171,7 @@ impl Beatmap { } } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct UserEvent { pub display_html: String, pub beatmap_id: u64, @@ -178,7 +180,7 @@ pub struct UserEvent { pub epic_factor: u8, } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct User { pub id: u64, pub username: String, @@ -216,7 +218,7 @@ impl User { } } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] pub enum Rank { SS, SSH, @@ -253,7 +255,7 @@ impl fmt::Display for Rank { } } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct Score { pub id: Option, // No id if you fail pub user_id: u64, diff --git a/youmubot-osu/src/models/mods.rs b/youmubot-osu/src/models/mods.rs index 43b92f4..48dcc69 100644 --- a/youmubot-osu/src/models/mods.rs +++ b/youmubot-osu/src/models/mods.rs @@ -1,8 +1,9 @@ +use serde::{Deserialize, Serialize}; use std::fmt; bitflags::bitflags! { /// The mods available to osu! - #[derive(std::default::Default)] + #[derive(std::default::Default, Serialize, Deserialize)] pub struct Mods: u64 { const NOMOD = 0; const NF = 1 << 0; diff --git a/youmubot-osu/src/models/deser.rs b/youmubot-osu/src/models/parse.rs similarity index 68% rename from youmubot-osu/src/models/deser.rs rename to youmubot-osu/src/models/parse.rs index 6ce6020..a230ad9 100644 --- a/youmubot-osu/src/models/deser.rs +++ b/youmubot-osu/src/models/parse.rs @@ -1,17 +1,43 @@ use super::*; use chrono::{ format::{parse, Item, Numeric, Pad, Parsed}, - DateTime, Duration, Utc, + DateTime, ParseError as ChronoParseError, Utc, }; -use serde::{de, Deserialize, Deserializer}; -use std::str::FromStr; +use std::convert::TryFrom; +use std::time::Duration; +use std::{error::Error, fmt, str::FromStr}; -impl<'de> Deserialize<'de> for Score { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw: raw::Score = raw::Score::deserialize(deserializer)?; +/// Errors that can be identified from parsing. +#[derive(Debug)] +pub enum ParseError { + InvalidValue { field: &'static str, value: String }, + FromStr(String), + NoApprovalDate, + DateParseError(ChronoParseError), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use ParseError::*; + match self { + InvalidValue { + ref field, + ref value, + } => 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"), + DateParseError(ref r) => write!(f, "Error parsing date: {}", r), + } + } +} + +impl Error for ParseError {} + +type ParseResult = Result; + +impl TryFrom for Score { + type Error = ParseError; + fn try_from(raw: raw::Score) -> Result { Ok(Score { id: raw.score_id.map(parse_from_str).transpose()?, user_id: parse_from_str(&raw.user_id)?, @@ -41,12 +67,9 @@ impl<'de> Deserialize<'de> for Score { } } -impl<'de> Deserialize<'de> for User { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw: raw::User = raw::User::deserialize(deserializer)?; +impl TryFrom for User { + type Error = ParseError; + fn try_from(raw: raw::User) -> Result { Ok(User { id: parse_from_str(&raw.user_id)?, username: raw.username, @@ -80,12 +103,9 @@ impl<'de> Deserialize<'de> for User { } } -impl<'de> Deserialize<'de> for Beatmap { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw: raw::Beatmap = raw::Beatmap::deserialize(deserializer)?; +impl TryFrom for Beatmap { + type Error = ParseError; + fn try_from(raw: raw::Beatmap) -> Result { Ok(Beatmap { approval: parse_approval_status(&raw)?, submit_date: parse_date(&raw.submit_date)?, @@ -129,7 +149,7 @@ impl<'de> Deserialize<'de> for Beatmap { } } -fn parse_user_event(s: raw::UserEvent) -> Result { +fn parse_user_event(s: raw::UserEvent) -> ParseResult { Ok(UserEvent { display_html: s.display_html, beatmap_id: parse_from_str(&s.beatmap_id)?, @@ -139,7 +159,7 @@ fn parse_user_event(s: raw::UserEvent) -> Result { }) } -fn parse_mode(s: impl AsRef) -> Result { +fn parse_mode(s: impl AsRef) -> ParseResult { let t: u8 = parse_from_str(s)?; use Mode::*; Ok(match t { @@ -147,11 +167,16 @@ fn parse_mode(s: impl AsRef) -> Result { 1 => Taiko, 2 => Catch, 3 => Mania, - _ => return Err(E::custom(format!("invalid value {} for mode", t))), + _ => { + return Err(ParseError::InvalidValue { + field: "mode", + value: t.to_string(), + }) + } }) } -fn parse_language(s: impl AsRef) -> Result { +fn parse_language(s: impl AsRef) -> ParseResult { let t: u8 = parse_from_str(s)?; use Language::*; Ok(match t { @@ -167,11 +192,16 @@ fn parse_language(s: impl AsRef) -> Result { 9 => Swedish, 10 => Spanish, 11 => Italian, - _ => return Err(E::custom(format!("invalid value {} for language", t))), + _ => { + return Err(ParseError::InvalidValue { + field: "langugae", + value: t.to_string(), + }) + } }) } -fn parse_genre(s: impl AsRef) -> Result { +fn parse_genre(s: impl AsRef) -> ParseResult { let t: u8 = parse_from_str(s)?; use Genre::*; Ok(match t { @@ -185,45 +215,57 @@ fn parse_genre(s: impl AsRef) -> Result { 7 => Novelty, 9 => HipHop, 10 => Electronic, - _ => return Err(E::custom(format!("invalid value {} for genre", t))), + _ => { + return Err(ParseError::InvalidValue { + field: "genre", + value: t.to_string(), + }) + } }) } -fn parse_duration(s: impl AsRef) -> Result { - Ok(Duration::seconds(parse_from_str(s)?)) +fn parse_duration(s: impl AsRef) -> ParseResult { + Ok(Duration::from_secs(parse_from_str(s)?)) } -fn parse_from_str(s: impl AsRef) -> Result { - T::from_str(s.as_ref()).map_err(|_| E::custom(format!("invalid value {}", s.as_ref()))) +fn parse_from_str(s: impl AsRef) -> ParseResult { + let v = s.as_ref(); + T::from_str(v).map_err(|_| ParseError::FromStr(v.to_owned())) } -fn parse_bool(b: impl AsRef) -> Result { +fn parse_bool(b: impl AsRef) -> ParseResult { match b.as_ref() { "1" => Ok(true), "0" => Ok(false), - _ => Err(E::custom("invalid value for bool")), + t => Err(ParseError::InvalidValue { + field: "bool", + value: t.to_owned(), + }), } } -fn parse_approval_status(b: &raw::Beatmap) -> Result { +fn parse_approval_status(b: &raw::Beatmap) -> ParseResult { use ApprovalStatus::*; Ok(match &b.approved[..] { "4" => Loved, "3" => Qualified, "2" => Approved, "1" => Ranked(parse_date( - b.approved_date - .as_ref() - .ok_or(E::custom("expected approved date got none"))?, + b.approved_date.as_ref().ok_or(ParseError::NoApprovalDate)?, )?), "0" => Pending, "-1" => WIP, "-2" => Graveyarded, - _ => return Err(E::custom("invalid value for approval status")), + t => { + return Err(ParseError::InvalidValue { + field: "approval status", + value: t.to_owned(), + }) + } }) } -fn parse_date(date: impl AsRef) -> Result, E> { +fn parse_date(date: impl AsRef) -> ParseResult> { let mut parsed = Parsed::new(); parse( &mut parsed, @@ -243,6 +285,8 @@ fn parse_date(date: impl AsRef) -> Result, E> { ]) .iter(), ) - .map_err(E::custom)?; - parsed.to_datetime_with_timezone(&Utc {}).map_err(E::custom) + .map_err(ParseError::DateParseError)?; + parsed + .to_datetime_with_timezone(&Utc {}) + .map_err(ParseError::DateParseError) } diff --git a/youmubot/src/commands/admin/soft_ban.rs b/youmubot/src/commands/admin/soft_ban.rs index ef613fa..29e7653 100644 --- a/youmubot/src/commands/admin/soft_ban.rs +++ b/youmubot/src/commands/admin/soft_ban.rs @@ -61,7 +61,7 @@ pub fn soft_ban(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResu msg.reply(&ctx, format!("⛓ Soft-banning user {}.", user.tag()))?; } Some(v) => { - let until = Utc::now() + v.0; + let until = Utc::now() + chrono::Duration::from_std(v.0)?; let until = server_ban .periodical_bans .entry(user.id) diff --git a/youmubot/src/commands/args.rs b/youmubot/src/commands/args.rs index 2e38dea..37a5843 100644 --- a/youmubot/src/commands/args.rs +++ b/youmubot/src/commands/args.rs @@ -1,8 +1,8 @@ pub use duration::Duration; mod duration { - use chrono::Duration as StdDuration; use std::fmt; + use std::time::Duration as StdDuration; use String as Error; // Parse a single duration unit fn parse_duration_string(s: &str) -> Result { @@ -18,7 +18,7 @@ mod duration { .try_fold( ParseStep { current_value: None, - current_duration: StdDuration::zero(), + current_duration: StdDuration::from_secs(0), }, |s, item| match (item, s.current_value) { ('0'..='9', v) => Ok(ParseStep { @@ -30,13 +30,13 @@ mod duration { current_value: None, current_duration: s.current_duration + match item.to_ascii_lowercase() { - 's' => StdDuration::seconds, - 'm' => StdDuration::minutes, - 'h' => StdDuration::hours, - 'd' => StdDuration::days, - 'w' => StdDuration::weeks, + 's' => StdDuration::from_secs(1), + 'm' => StdDuration::from_secs(60), + 'h' => StdDuration::from_secs(60 * 60), + 'd' => StdDuration::from_secs(60 * 60 * 24), + 'w' => StdDuration::from_secs(60 * 60 * 24 * 7), _ => return Err(Error::from("Not a valid duration")), - }(v as i64), + } * (v as u32), }), }, ) @@ -65,9 +65,27 @@ mod duration { } } + impl Duration { + fn num_weeks(&self) -> u64 { + self.0.as_secs() / (60 * 60 * 24 * 7) + } + fn num_days(&self) -> u64 { + self.0.as_secs() / (60 * 60 * 24) + } + fn num_hours(&self) -> u64 { + self.0.as_secs() / (60 * 60) + } + fn num_minutes(&self) -> u64 { + self.0.as_secs() / 60 + } + fn num_seconds(&self) -> u64 { + self.0.as_secs() + } + } + impl fmt::Display for Duration { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let d = &self.0; + let d = self; // weeks let weeks = d.num_weeks(); let days = d.num_days() - d.num_weeks() * 7; @@ -103,17 +121,17 @@ mod duration { #[cfg(test)] mod tests { use super::*; - use chrono::Duration as StdDuration; + use std::time::Duration as StdDuration; #[test] fn test_parse_success() { let tests = [ ( "2D2h1m", - StdDuration::seconds(2 * 60 * 60 * 24 + 2 * 60 * 60 + 1 * 60), + StdDuration::from_secs(2 * 60 * 60 * 24 + 2 * 60 * 60 + 1 * 60), ), ( "1W2D3h4m5s", - StdDuration::seconds( + StdDuration::from_secs( 1 * 7 * 24 * 60 * 60 + // 1W 2 * 24 * 60 * 60 + // 2D 3 * 60 * 60 + // 3h @@ -123,7 +141,7 @@ mod duration { ), ( "1W2D3h4m5s6W", - StdDuration::seconds( + StdDuration::from_secs( 1 * 7 * 24 * 60 * 60 + // 1W 2 * 24 * 60 * 60 + // 2D 3 * 60 * 60 + // 3h diff --git a/youmubot/src/commands/community/votes.rs b/youmubot/src/commands/community/votes.rs index b324736..67de2e8 100644 --- a/youmubot/src/commands/community/votes.rs +++ b/youmubot/src/commands/community/votes.rs @@ -1,5 +1,4 @@ use crate::commands::args::Duration as ParseDuration; -use chrono::Duration; use serenity::framework::standard::CommandError as Error; use serenity::prelude::*; use serenity::{ @@ -12,6 +11,7 @@ use serenity::{ }; use std::collections::HashMap as Map; use std::thread; +use std::time::Duration; #[command] #[description = "🎌 Cast a poll upon everyone and ask them for opinions!"] @@ -25,7 +25,7 @@ pub fn vote(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { let args = args.quoted(); let _duration = args.single::()?; let duration = &_duration.0; - if *duration < Duration::minutes(2) || *duration > Duration::days(1) { + if *duration < Duration::from_secs(2 * 60) || *duration > Duration::from_secs(60 * 60 * 24) { msg.reply(ctx, format!("😒 Invalid duration ({}). The voting time should be between **2 minutes** and **1 day**.", _duration))?; return Ok(()); } @@ -95,7 +95,7 @@ pub fn vote(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { .try_for_each(|(v, _)| panel.react(&ctx, *v))?; // Start sleeping - thread::sleep(duration.to_std()?); + thread::sleep(*duration); let result = collect_reactions(ctx, &panel, &choices)?; if result.len() == 0 { diff --git a/youmubot/src/commands/osu/mod.rs b/youmubot/src/commands/osu/mod.rs index aa35067..487d211 100644 --- a/youmubot/src/commands/osu/mod.rs +++ b/youmubot/src/commands/osu/mod.rs @@ -18,6 +18,7 @@ use youmubot_osu::{ Client as OsuClient, }; +mod cache; mod hook; pub use hook::hook; @@ -68,6 +69,25 @@ pub fn mania(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult { get_user(ctx, msg, args, Mode::Mania) } +pub(crate) struct BeatmapWithMode(pub Beatmap, pub Mode); + +impl BeatmapWithMode { + /// Whether this beatmap-with-mode is a converted beatmap. + fn is_converted(&self) -> bool { + self.0.mode != self.1 + } + + fn mode(&self) -> Mode { + self.1 + } +} + +impl AsRef for BeatmapWithMode { + fn as_ref(&self) -> &Beatmap { + &self.0 + } +} + #[command] #[description = "Save the given username as your username."] #[usage = "[username or user_id]"] @@ -160,8 +180,6 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult } }; - dbg!((nth, &mode, &user, &args)); - let reqwest = data.get::().unwrap(); let osu: &OsuClient = data.get::().unwrap(); let user = osu @@ -180,6 +198,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult )? .into_iter() .next() + .map(|v| BeatmapWithMode(v, mode)) .unwrap(); msg.channel_id.send_message(&ctx, |m| { @@ -187,7 +206,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult "{}: here is the play that you requested", msg.author )) - .embed(|m| score_embed(&recent_play, &beatmap, &user, &mode, None, m)) + .embed(|m| score_embed(&recent_play, &beatmap, &user, None, m)) })?; Ok(()) @@ -222,8 +241,10 @@ fn get_user(ctx: &mut Context, msg: &Message, args: Args, mode: Mode) -> Command .into_iter() .next() .map(|m| { - osu.beatmaps(reqwest, BeatmapRequestKind::Beatmap(m.beatmap_id), |f| f) - .map(|map| (m, map.into_iter().next().unwrap())) + osu.beatmaps(reqwest, BeatmapRequestKind::Beatmap(m.beatmap_id), |f| { + f.mode(mode, true) + }) + .map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode))) }) .transpose()?; msg.channel_id.send_message(&ctx, |m| { @@ -241,13 +262,14 @@ fn get_user(ctx: &mut Context, msg: &Message, args: Args, mode: Mode) -> Command fn score_embed<'a>( s: &Score, - b: &Beatmap, + bm: &BeatmapWithMode, u: &User, - mode: &Mode, top_record: Option, m: &'a mut CreateEmbed, ) -> &'a mut CreateEmbed { - let accuracy = s.accuracy(*mode); + let mode = bm.mode(); + let b = &bm.0; + let accuracy = s.accuracy(mode); let score_line = match &s.rank { Rank::SS | Rank::SSH => format!("SS"), _ if s.perfect => format!("{:2}% FC", accuracy), @@ -293,7 +315,12 @@ fn score_embed<'a>( .push_bold(format!("{:.2}⭐", b.difficulty.stars)) .push(", ") .push_bold_line( - b.mode.to_string() + if b.mode == *mode { "" } else { " (Converted)" }, + b.mode.to_string() + + if bm.is_converted() { + "" + } else { + " (Converted)" + }, ) .push("CS") .push_bold(format!("{:.1}", b.difficulty.cs)) @@ -313,7 +340,7 @@ fn score_embed<'a>( fn user_embed<'a>( u: User, - best: Option<(Score, Beatmap)>, + best: Option<(Score, BeatmapWithMode)>, m: &'a mut CreateEmbed, ) -> &'a mut CreateEmbed { m.title(u.username) @@ -356,6 +383,7 @@ fn user_embed<'a>( false, ) .fields(best.map(|(v, map)| { + let map = map.0; ( "Best Record", MessageBuilder::new() @@ -364,7 +392,14 @@ fn user_embed<'a>( v.pp.unwrap() /*Top record should have pp*/ )) .push(" - ") - .push_line(format!("{:.1} ago", Duration(Utc::now() - v.date))) + .push_line(format!( + "{:.1} ago", + Duration( + (Utc::now() - v.date) + .to_std() + .unwrap_or(std::time::Duration::from_secs(1)) + ) + )) .push("on ") .push(format!( "[{} - {}]({})",