Skip to content

Commit cbb5e78

Browse files
authored
Merge pull request #952 from Chiffario/897-relax-vault
feat: initial API implementation for Relaxation Vault integration
2 parents 2bd562d + 99133ee commit cbb5e78

16 files changed

Lines changed: 1085 additions & 6 deletions

File tree

bathbot-client/src/client.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub struct Client {
2626
#[cfg(feature = "twitch")]
2727
twitch: bathbot_model::TwitchData,
2828
github_auth: Box<str>,
29-
ratelimiters: [LeakyBucket; 16],
29+
ratelimiters: [LeakyBucket; 17],
3030
}
3131

3232
impl Client {
@@ -79,6 +79,7 @@ impl Client {
7979
ratelimiter(2), // OsuStats
8080
ratelimiter(2), // OsuTrack
8181
ratelimiter(2), // OsuWorld
82+
ratelimiter(2), // Relaxation Vault
8283
ratelimiter(1), // Respektive
8384
ratelimiter(5), // Twitch
8485
];

bathbot-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod osekai;
1515
mod osu;
1616
mod osustats;
1717
mod osutrack;
18+
mod relax;
1819
mod respektive;
1920
mod site;
2021
mod snipe;

bathbot-client/src/relax.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
use bathbot_model::{RelaxPlayersDataResponse, RelaxScore};
2+
use bathbot_util::constants::RELAX_API;
3+
use eyre::{Result, WrapErr};
4+
use rosu_v2::prelude::CountryCode;
5+
6+
use crate::{Client, site::Site};
7+
8+
impl Client {
9+
/// /api/scores
10+
/// GET relax score leaderboard (a.k.a. highest pp relax scores)
11+
pub async fn get_relax_score_leaderboard(&self) -> Result<Vec<RelaxScore>> {
12+
let url = format!("{RELAX_API}/scores");
13+
14+
let bytes = self.make_get_request(url, Site::Relax).await?;
15+
16+
serde_json::from_slice(&bytes).wrap_err_with(|| {
17+
let body = String::from_utf8_lossy(&bytes);
18+
19+
format!("failed to deserialize relax top scores: {body}")
20+
})
21+
}
22+
23+
/// /api/scores/{id}
24+
/// GET relax score by its ID
25+
pub async fn get_relax_scores(&self, score_id: u32) -> Result<Vec<RelaxScore>> {
26+
let url = format!("{RELAX_API}/scores/{score_id}");
27+
28+
let bytes = self.make_get_request(url, Site::Relax).await?;
29+
30+
serde_json::from_slice(&bytes).wrap_err_with(|| {
31+
let body = String::from_utf8_lossy(&bytes);
32+
33+
format!("failed to deserialize player's relax scores: {body}")
34+
})
35+
}
36+
37+
/// /api/players
38+
/// GET Relax player list
39+
/// Ordered by total pp
40+
/// page: page index
41+
/// country_code: country code to get country leaderboards
42+
/// search: search query
43+
pub async fn get_relax_players(
44+
&self,
45+
page: Option<u32>,
46+
country_code: Option<CountryCode>,
47+
search: Option<String>,
48+
) -> Result<RelaxPlayersDataResponse> {
49+
let mut url = format!("{RELAX_API}/players");
50+
51+
if let Some(p) = page {
52+
url.push_str(&format!("page={p}&"));
53+
}
54+
55+
if let Some(cc) = country_code {
56+
url.push_str(&format!("countryCode={cc}&"));
57+
}
58+
59+
if let Some(q) = search {
60+
url.push_str(&format!("search={q}&"));
61+
}
62+
63+
let bytes = self.make_get_request(url, Site::Relax).await?;
64+
65+
serde_json::from_slice(&bytes).wrap_err_with(|| {
66+
let body = String::from_utf8_lossy(&bytes);
67+
68+
format!("failed to deserialize relax players: {body}")
69+
})
70+
}
71+
72+
/// /api/players/{id}
73+
/// GET Relax player by osu! ID
74+
pub async fn get_relax_player(&self, user_id: u32) -> Result<Option<RelaxPlayersDataResponse>> {
75+
let url = format!("{RELAX_API}/players/{user_id}");
76+
77+
let bytes = self.make_get_request(url, Site::Relax).await?;
78+
79+
serde_json::from_slice(&bytes).wrap_err_with(|| {
80+
let body = String::from_utf8_lossy(&bytes);
81+
82+
format!("failed to deserialize relax player: {body}")
83+
})
84+
}
85+
86+
/// /api/players/{id}/scores
87+
/// GET all relax scores set by a player
88+
pub async fn get_relax_player_scores(&self, user_id: u32) -> Result<Vec<RelaxScore>> {
89+
let url = format!("{RELAX_API}/players/{user_id}/scores");
90+
91+
let bytes = self.make_get_request(url, Site::Relax).await?;
92+
93+
serde_json::from_slice(&bytes).wrap_err_with(|| {
94+
let body = String::from_utf8_lossy(&bytes);
95+
96+
format!("failed to deserialize relax player scores: {body}")
97+
})
98+
}
99+
}

bathbot-client/src/site.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum Site {
1414
OsuMapsetCover,
1515
OsuStats,
1616
OsuTrack,
17+
Relax,
1718
Respektive,
1819
Twitch,
1920
}
@@ -35,6 +36,7 @@ impl Site {
3536
Self::OsuStats => "OsuStats",
3637
Self::OsuTrack => "OsuTrack",
3738
Self::Respektive => "Respektive",
39+
Self::Relax => "Relax",
3840
Self::Twitch => "Twitch",
3941
}
4042
}

bathbot-model/src/deser.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ pub(super) mod datetime_rfc3339 {
217217
d.deserialize_str(DateTimeVisitor)
218218
}
219219

220-
struct DateTimeVisitor;
220+
pub(crate) struct DateTimeVisitor;
221221

222222
impl Visitor<'_> for DateTimeVisitor {
223223
type Value = OffsetDateTime;
@@ -233,6 +233,40 @@ pub(super) mod datetime_rfc3339 {
233233
}
234234
}
235235
}
236+
pub(super) mod option_datetime_rfc3339 {
237+
use super::{datetime_rfc3339::DateTimeVisitor, *};
238+
239+
pub fn deserialize<'de, D: Deserializer<'de>>(
240+
d: D,
241+
) -> Result<Option<OffsetDateTime>, D::Error> {
242+
d.deserialize_option(OptionDateTimeVisitor)
243+
}
244+
245+
struct OptionDateTimeVisitor;
246+
247+
impl<'de> Visitor<'de> for OptionDateTimeVisitor {
248+
type Value = Option<OffsetDateTime>;
249+
250+
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251+
f.write_str("an optional RFC3339 datetime string ending on `Z`")
252+
}
253+
254+
#[inline]
255+
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
256+
d.deserialize_str(DateTimeVisitor).map(Some)
257+
}
258+
259+
#[inline]
260+
fn visit_none<E: Error>(self) -> Result<Self::Value, E> {
261+
self.visit_unit()
262+
}
263+
264+
#[inline]
265+
fn visit_unit<E: Error>(self) -> Result<Self::Value, E> {
266+
Ok(None)
267+
}
268+
}
269+
}
236270

237271
pub(super) mod datetime_rfc2822 {
238272
use time::format_description::well_known::Rfc2822;

bathbot-model/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod osekai;
99
mod osu_stats;
1010
mod osutrack;
1111
mod ranking_entries;
12+
mod relax;
1213
mod respektive;
1314
mod score_slim;
1415
mod twitch;
@@ -24,5 +25,5 @@ pub mod rkyv_util;
2425
pub use self::{
2526
country_code::*, deser::ModeAsSeed, either::Either, games::*, github::*, huismetbenen::*,
2627
kittenroleplay::*, osekai::*, osu_stats::*, osutrack::RankAccPeaks, ranking_entries::*,
27-
respektive::*, score_slim::*, twitch::*, user_stats::*,
28+
relax::*, respektive::*, score_slim::*, twitch::*, user_stats::*,
2829
};

bathbot-model/src/relax.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use std::fmt::{Formatter, Result as FmtResult};
2+
3+
use rosu_mods::{GameMod, GameMode, GameMods};
4+
use rosu_v2::model::Grade;
5+
use serde::{Deserialize, Deserializer, de};
6+
use time::OffsetDateTime;
7+
8+
use crate::deser::{adjust_acc, datetime_rfc3339, option_datetime_rfc3339};
9+
10+
fn deserialize_mods<'de, D: Deserializer<'de>>(d: D) -> Result<GameMods, D::Error> {
11+
struct Visitor;
12+
13+
impl<'de> de::Visitor<'de> for Visitor {
14+
type Value = GameMods;
15+
16+
fn expecting(&self, f: &mut Formatter) -> FmtResult {
17+
f.write_str("mods")
18+
}
19+
20+
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
21+
let mut mods = GameMods::new();
22+
23+
while let Some(s) = seq.next_element::<&'de str>()? {
24+
let (acronym, clock_rate) = s.split_once('x').unwrap_or((s, ""));
25+
26+
let mut gamemod = GameMod::new(acronym, GameMode::Osu);
27+
28+
match (!clock_rate.is_empty()).then(|| clock_rate.parse()) {
29+
None => {}
30+
Some(Ok(clock_rate)) => match gamemod {
31+
GameMod::DoubleTimeOsu(ref mut m) => m.speed_change = Some(clock_rate),
32+
GameMod::NightcoreOsu(ref mut m) => m.speed_change = Some(clock_rate),
33+
GameMod::HalfTimeOsu(ref mut m) => m.speed_change = Some(clock_rate),
34+
GameMod::DaycoreOsu(ref mut m) => m.speed_change = Some(clock_rate),
35+
_ => {}
36+
},
37+
Some(Err(_)) => {
38+
return Err(de::Error::custom(format!(
39+
"expected clock rate; got `{clock_rate}`"
40+
)));
41+
}
42+
}
43+
44+
mods.insert(gamemod);
45+
}
46+
47+
Ok(mods)
48+
}
49+
}
50+
51+
d.deserialize_seq(Visitor)
52+
}
53+
54+
#[derive(Debug, Deserialize)]
55+
#[serde(rename_all = "camelCase")]
56+
pub struct RelaxScore {
57+
pub id: u64,
58+
pub user_id: u32,
59+
// Comes as a null from /api/players/{user_id}/scores
60+
pub user: Option<RelaxUser>,
61+
pub beatmap_id: u32,
62+
pub beatmap: RelaxBeatmap,
63+
pub grade: Grade,
64+
#[serde(with = "adjust_acc")]
65+
pub accuracy: f32,
66+
pub combo: u32,
67+
#[serde(deserialize_with = "deserialize_mods")]
68+
pub mods: GameMods,
69+
#[serde(with = "datetime_rfc3339")]
70+
pub date: OffsetDateTime,
71+
pub total_score: u32,
72+
pub count_50: u32,
73+
pub count_100: u32,
74+
pub count_300: u32,
75+
pub count_miss: u32,
76+
pub spinner_bonus: Option<u32>,
77+
pub spinner_spins: Option<u32>,
78+
pub legacy_slider_ends: Option<u32>,
79+
pub slider_ticks: Option<u32>,
80+
pub slider_ends: Option<u32>,
81+
pub legacy_slider_end_misses: Option<u32>,
82+
pub slider_tick_misses: Option<u32>,
83+
pub pp: Option<f64>,
84+
pub is_best: bool,
85+
}
86+
87+
#[derive(Debug, Deserialize)]
88+
#[serde(rename_all = "camelCase")]
89+
pub struct RelaxUser {
90+
pub id: u32,
91+
pub country_code: Option<String>,
92+
pub username: Option<String>,
93+
pub total_pp: Option<f64>,
94+
pub total_accuracy: Option<f64>,
95+
#[serde(with = "option_datetime_rfc3339")]
96+
pub updated_at: Option<OffsetDateTime>,
97+
}
98+
99+
#[derive(Debug, Deserialize)]
100+
#[serde(rename_all = "camelCase")]
101+
pub struct RelaxBeatmap {
102+
pub id: u32,
103+
pub artist: Option<String>,
104+
pub title: Option<String>,
105+
pub creator_id: u32,
106+
pub beatmap_set_id: u32,
107+
pub difficulty_name: Option<String>,
108+
pub approach_rate: f64,
109+
pub overall_difficulty: f64,
110+
pub circle_size: f64,
111+
pub health_drain: f64,
112+
pub beats_per_minute: f64,
113+
pub circles: u32,
114+
pub sliders: u32,
115+
pub spinners: u32,
116+
pub star_rating_normal: f64,
117+
pub star_rating: Option<f64>,
118+
pub status: RelaxBeatmapStatus,
119+
pub max_combo: u32,
120+
}
121+
122+
#[derive(Debug, Deserialize)]
123+
pub enum RelaxBeatmapStatus {
124+
Graveyard,
125+
Wip,
126+
Pending,
127+
Ranked,
128+
Approved,
129+
Qualified,
130+
Loved,
131+
}
132+
133+
#[derive(Debug, Deserialize)]
134+
#[serde(rename_all = "camelCase")]
135+
pub struct RelaxPlaycountPerMonth {
136+
#[serde(with = "datetime_rfc3339")]
137+
pub date: OffsetDateTime,
138+
pub playcount: u32,
139+
}
140+
141+
#[derive(Debug, Deserialize)]
142+
#[serde(rename_all = "camelCase")]
143+
pub struct RelaxPlayersDataResponse {
144+
pub id: u32,
145+
pub country_code: Option<String>,
146+
pub username: Option<String>,
147+
pub total_pp: Option<f64>,
148+
pub total_accuracy: Option<f64>,
149+
#[serde(with = "option_datetime_rfc3339")]
150+
pub updated_at: Option<OffsetDateTime>,
151+
pub rank: Option<u32>,
152+
pub country_rank: Option<u32>,
153+
pub playcount: u32,
154+
#[serde(rename = "countSS")]
155+
pub count_ss: u32,
156+
#[serde(rename = "countS")]
157+
pub count_s: u32,
158+
#[serde(rename = "countA")]
159+
pub count_a: u32,
160+
pub playcounts_per_month: Vec<RelaxPlaycountPerMonth>,
161+
}

bathbot-util/src/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub const OSU_BASE: &str = "https://osu.ppy.sh/";
1414
pub const MAP_THUMB_URL: &str = "https://b.ppy.sh/thumb/";
1515
pub const AVATAR_URL: &str = "https://a.ppy.sh/";
1616
pub const HUISMETBENEN: &str = "https://api.snipe.huismetbenen.nl/";
17+
pub const RELAX_API: &str = "https://rx.stanr.info/api";
18+
pub const RELAX: &str = "https://rx.stanr.info";
19+
pub const RELAX_ICON_URL: &str = "https://rx.stanr.info/rv-yellowlight-192.png";
1720

1821
// twitch
1922
pub const TWITCH_BASE: &str = "https://www.twitch.tv/";

bathbot/src/active/impls/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ mod profile;
6060
mod ranking;
6161
mod ranking_countries;
6262
mod recent_list;
63+
pub mod relax;
6364
mod render;
6465
mod simulate;
6566
mod single_score;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod top;

0 commit comments

Comments
 (0)