Improve osu (#10)

* Add rank event parsing

* Refactor osu announcer

* Increase api limits

* Register user is locked
This commit is contained in:
Natsu Kagami 2021-01-22 21:54:01 +09:00 committed by GitHub
parent 145cb01bd0
commit f05afb2b80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 509 additions and 197 deletions

View file

@ -3,15 +3,18 @@ use super::{embeds::score_embed, BeatmapWithMode};
use crate::{ use crate::{
discord::beatmap_cache::BeatmapMetaCache, discord::beatmap_cache::BeatmapMetaCache,
discord::cache::save_beatmap, discord::cache::save_beatmap,
discord::oppai_cache::BeatmapCache, discord::oppai_cache::{BeatmapCache, BeatmapContent},
models::{Mode, Score}, models::{Mode, Score, User, UserEventRank},
request::UserID, request::UserID,
Client as Osu, Client as Osu,
}; };
use announcer::MemberToChannels; use announcer::MemberToChannels;
use serenity::{ use serenity::{
http::CacheHttp, http::CacheHttp,
model::id::{ChannelId, UserId}, model::{
channel::Message,
id::{ChannelId, UserId},
},
CacheAndHttp, CacheAndHttp,
}; };
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
@ -22,12 +25,14 @@ pub const ANNOUNCER_KEY: &'static str = "osu";
/// The announcer struct implementing youmubot_prelude::Announcer /// The announcer struct implementing youmubot_prelude::Announcer
pub struct Announcer { pub struct Announcer {
client: Osu, client: Arc<Osu>,
} }
impl Announcer { impl Announcer {
pub fn new(client: Osu) -> Self { pub fn new(client: Osu) -> Self {
Self { client } Self {
client: Arc::new(client),
}
} }
} }
@ -106,65 +111,56 @@ impl Announcer {
mode: Mode, mode: Mode,
d: AppData, d: AppData,
) -> Result<Option<f64>, Error> { ) -> Result<Option<f64>, Error> {
let days_since_last_update = (chrono::Utc::now() - osu_user.last_update).num_days() + 1;
let last_update = osu_user.last_update.clone();
let (scores, user) = { let (scores, user) = {
let scores = self.scan_user(osu_user, mode).await?; let scores = self.scan_user(osu_user, mode).await?;
let user = self let user = self
.client .client
.user(UserID::ID(osu_user.id), |f| f.mode(mode)) .user(UserID::ID(osu_user.id), |f| {
f.mode(mode)
.event_days(days_since_last_update.min(31) as u8)
})
.await? .await?
.ok_or(Error::msg("user not found"))?; .ok_or(Error::msg("user not found"))?;
(scores, user) (scores, user)
}; };
let client = self.client.clone();
let pp = user.pp; let pp = user.pp;
spawn_future(async move { spawn_future(async move {
scores let event_scores = user
.into_iter() .events
.map(|(rank, score)| { .iter()
let d = d.clone(); .filter_map(|u| u.to_event_rank())
async move { .filter(|u| u.mode == mode && u.date > last_update)
let data = d.read().await; .map(|ev| CollectedScore::from_event(&*client, &user, ev, user_id, &channels[..]))
let cache = data.get::<BeatmapMetaCache>().unwrap(); .collect::<stream::FuturesUnordered<_>>()
let oppai = data.get::<BeatmapCache>().unwrap(); .filter_map(|u| future::ready(u.ok_or_print()))
let beatmap = cache.get_beatmap_default(score.beatmap_id).await?; .collect::<Vec<_>>()
let content = oppai.get_beatmap(beatmap.beatmap_id).await?;
let r: Result<_> =
Ok((rank, score, BeatmapWithMode(beatmap, mode), content));
r
}
})
.collect::<stream::FuturesOrdered<_>>()
.filter_map(|v| future::ready(v.ok()))
.for_each(move |(rank, score, beatmap, content)| {
let channels = channels.clone();
let d = d.clone();
let c = c.clone();
let user = user.clone();
async move {
let data = d.read().await;
for channel in (&channels).iter() {
if let Err(e) = channel
.send_message(c.http(), |c| {
c.content(format!("New top record from {}!", user_id.mention()))
.embed(|e| {
score_embed(
&score,
&beatmap,
&content,
&user,
Some(rank),
e,
)
})
})
.await
{
dbg!(e);
}
save_beatmap(&*data, *channel, &beatmap).ok();
}
}
})
.await; .await;
let top_scores = scores.into_iter().filter_map(|(rank, score)| {
if score.date > last_update {
Some(CollectedScore::from_top_score(
&user,
score,
mode,
rank,
user_id,
&channels[..],
))
} else {
None
}
});
let ctx = Context { data: d, c };
event_scores
.into_iter()
.chain(top_scores)
.map(|v| v.send_message(&ctx))
.collect::<stream::FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await
.ok_or_print();
}); });
Ok(pp) Ok(pp)
} }
@ -183,3 +179,147 @@ impl Announcer {
Ok(scores) Ok(scores)
} }
} }
#[derive(Clone)]
struct Context {
data: AppData,
c: Arc<CacheAndHttp>,
}
struct CollectedScore<'a> {
pub user: &'a User,
pub score: Score,
pub mode: Mode,
pub kind: ScoreType,
pub discord_user: UserId,
pub channels: &'a [ChannelId],
}
impl<'a> CollectedScore<'a> {
fn from_top_score(
user: &'a User,
score: Score,
mode: Mode,
rank: u8,
discord_user: UserId,
channels: &'a [ChannelId],
) -> Self {
Self {
user,
score,
mode,
kind: ScoreType::TopRecord(rank),
discord_user,
channels,
}
}
async fn from_event(
osu: &Osu,
user: &'a User,
event: UserEventRank,
discord_user: UserId,
channels: &'a [ChannelId],
) -> Result<CollectedScore<'a>> {
let scores = osu
.scores(event.beatmap_id, |f| {
f.user(UserID::ID(user.id)).mode(event.mode)
})
.await?;
let score = match scores.into_iter().next() {
Some(v) => v,
None => return Err(Error::msg("cannot get score for map...")),
};
Ok(Self {
user,
score,
mode: event.mode,
kind: ScoreType::WorldRecord(event.rank),
discord_user,
channels,
})
}
}
impl<'a> CollectedScore<'a> {
async fn send_message(self, ctx: &Context) -> Result<Vec<Message>> {
let (bm, content) = self.get_beatmap(&ctx).await?;
self.channels
.into_iter()
.map(|c| self.send_message_to(*c, ctx, &bm, &content))
.collect::<stream::FuturesUnordered<_>>()
.try_collect()
.await
}
async fn get_beatmap(
&self,
ctx: &Context,
) -> Result<(
BeatmapWithMode,
impl std::ops::Deref<Target = BeatmapContent>,
)> {
let data = ctx.data.read().await;
let cache = data.get::<BeatmapMetaCache>().unwrap();
let oppai = data.get::<BeatmapCache>().unwrap();
let beatmap = cache.get_beatmap_default(self.score.beatmap_id).await?;
let content = oppai.get_beatmap(beatmap.beatmap_id).await?;
Ok((BeatmapWithMode(beatmap, self.mode), content))
}
async fn send_message_to(
&self,
channel: ChannelId,
ctx: &Context,
bm: &BeatmapWithMode,
content: &BeatmapContent,
) -> Result<Message> {
let m = channel
.send_message(ctx.c.http(), |c| {
c.content(match self.kind {
ScoreType::TopRecord(_) => {
format!("New top record from {}!", self.discord_user.mention())
}
ScoreType::WorldRecord(_) => {
format!("New best score from {}!", self.discord_user.mention())
}
})
.embed(|e| {
let mut b = score_embed(&self.score, &bm, content, self.user);
match self.kind {
ScoreType::TopRecord(rank) => b.top_record(rank),
ScoreType::WorldRecord(rank) => b.world_record(rank),
}
.build(e)
})
})
.await?;
save_beatmap(&*ctx.data.read().await, channel, &bm).ok_or_print();
Ok(m)
}
}
enum ScoreType {
TopRecord(u8),
WorldRecord(u16),
}
trait OkPrint {
type Output;
fn ok_or_print(self) -> Option<Self::Output>;
}
impl<T, E: std::fmt::Debug> OkPrint for Result<T, E> {
type Output = T;
fn ok_or_print(self) -> Option<Self::Output> {
match self {
Ok(v) => Some(v),
Err(e) => {
eprintln!("Error: {:?}", e);
None
}
}
}
}

View file

@ -160,16 +160,49 @@ pub fn beatmapset_embed<'a>(
})) }))
} }
pub(crate) fn score_embed<'a>( pub(crate) struct ScoreEmbedBuilder<'a> {
s: &Score, s: &'a Score,
bm: &BeatmapWithMode, bm: &'a BeatmapWithMode,
content: &BeatmapContent, content: &'a BeatmapContent,
u: &User, u: &'a User,
top_record: Option<u8>, top_record: Option<u8>,
m: &'a mut CreateEmbed, world_record: Option<u16>,
) -> &'a mut CreateEmbed { }
let mode = bm.mode();
let b = &bm.0; impl<'a> ScoreEmbedBuilder<'a> {
pub fn top_record(&mut self, rank: u8) -> &mut Self {
self.top_record = Some(rank);
self
}
pub fn world_record(&mut self, rank: u16) -> &mut Self {
self.world_record = Some(rank);
self
}
}
pub(crate) fn score_embed<'a>(
s: &'a Score,
bm: &'a BeatmapWithMode,
content: &'a BeatmapContent,
u: &'a User,
) -> ScoreEmbedBuilder<'a> {
ScoreEmbedBuilder {
s,
bm,
content,
u,
top_record: None,
world_record: None,
}
}
impl<'a> ScoreEmbedBuilder<'a> {
pub fn build<'b>(&self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed {
let mode = self.bm.mode();
let b = &self.bm.0;
let s = self.s;
let content = self.content;
let u = self.u;
let accuracy = s.accuracy(mode); let accuracy = s.accuracy(mode);
let stars = mode let stars = mode
.to_oppai_mode() .to_oppai_mode()
@ -220,7 +253,7 @@ pub(crate) fn score_embed<'a>(
pp.map(|v| v.1) pp.map(|v| v.1)
}; };
let pp_gained = s.pp.map(|full_pp| { let pp_gained = s.pp.map(|full_pp| {
top_record self.top_record
.map(|top| { .map(|top| {
let after_pp = u.pp.unwrap(); let after_pp = u.pp.unwrap();
let effective_pp = full_pp * (0.95f64).powi(top as i32 - 1); let effective_pp = full_pp * (0.95f64).powi(top as i32 - 1);
@ -240,14 +273,19 @@ pub(crate) fn score_embed<'a>(
.max_combo .max_combo
.map(|max| format!("**{}x**/{}x", s.max_combo, max)) .map(|max| format!("**{}x**/{}x", s.max_combo, max))
.unwrap_or_else(|| format!("**{}x**", s.max_combo)); .unwrap_or_else(|| format!("**{}x**", s.max_combo));
let top_record = top_record let top_record = self
.top_record
.map(|v| format!("| #{} top record!", v)) .map(|v| format!("| #{} top record!", v))
.unwrap_or("".to_owned()); .unwrap_or("".to_owned());
let world_record = self
.world_record
.map(|v| format!("| #{} on Global Rankings!", v))
.unwrap_or("".to_owned());
let diff = b.difficulty.apply_mods(s.mods, Some(stars)); let diff = b.difficulty.apply_mods(s.mods, Some(stars));
m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url())) m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url()))
.color(0xffb6c1) .color(0xffb6c1)
.title(format!( .title(format!(
"{} | {} - {} [{}] {} ({:.2}\\*) by {} | {} {}", "{} | {} - {} [{}] {} ({:.2}\\*) by {} | {} {} {}",
u.username, u.username,
b.artist, b.artist,
b.title, b.title,
@ -256,7 +294,8 @@ pub(crate) fn score_embed<'a>(
stars, stars,
b.creator, b.creator,
score_line, score_line,
top_record top_record,
world_record,
)) ))
.description(format!( .description(format!(
r#"**Beatmap**: {} - {} [{}]**{} ** r#"**Beatmap**: {} - {} [{}]**{} **
@ -299,6 +338,7 @@ pub(crate) fn score_embed<'a>(
} }
m m
} }
}
pub(crate) fn user_embed<'a>( pub(crate) fn user_embed<'a>(
u: User, u: User,

View file

@ -23,6 +23,7 @@ mod db;
pub(crate) mod embeds; pub(crate) mod embeds;
mod hook; mod hook;
pub(crate) mod oppai_cache; pub(crate) mod oppai_cache;
mod register_user;
mod server_rank; mod server_rank;
use db::OsuUser; use db::OsuUser;
@ -95,6 +96,7 @@ pub fn setup(
catch, catch,
mania, mania,
save, save,
forcesave,
recent, recent,
last, last,
check, check,
@ -168,14 +170,32 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
let user: Option<User> = osu.user(UserID::Auto(user), |f| f).await?; let user: Option<User> = osu.user(UserID::Auto(user), |f| f).await?;
match user { match user {
Some(u) => { Some(u) => {
OsuSavedUsers::open(&*data).borrow_mut()?.insert( let check_beatmap_id = register_user::user_register_beatmap_id(&u);
msg.author.id, let check = osu
OsuUser { .user_recent(UserID::ID(u.id), |f| f.mode(Mode::Std).limit(1))
id: u.id, .await?
last_update: chrono::Utc::now(), .into_iter()
pp: vec![], .take(1)
}, .any(|s| s.beatmap_id == check_beatmap_id);
); if !check {
let msg = msg.reply(&ctx, format!("To set your osu username, please make your most recent play be the following map: `/b/{}` in **osu! standard** mode! It does **not** have to be a pass.", check_beatmap_id));
let beatmap = osu
.beatmaps(
crate::request::BeatmapRequestKind::Beatmap(check_beatmap_id),
|f| f,
)
.await?
.into_iter()
.next()
.unwrap();
msg.await?
.edit(&ctx, |f| {
f.embed(|e| beatmap_embed(&beatmap, Mode::Std, Mods::NOMOD, None, e))
})
.await?;
return Ok(());
}
add_user(msg.author.id, u.id, &*data)?;
msg.reply( msg.reply(
&ctx, &ctx,
MessageBuilder::new() MessageBuilder::new()
@ -192,6 +212,55 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
Ok(()) Ok(())
} }
#[command]
#[description = "Save the given username as someone's username."]
#[owners_only]
#[usage = "[ping user]/[username or user_id]"]
#[num_args(2)]
pub async fn forcesave(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await;
let osu = data.get::<OsuClient>().unwrap();
let target = args.single::<serenity::model::id::UserId>()?;
let user = args.single::<String>()?;
let user: Option<User> = osu.user(UserID::Auto(user), |f| f).await?;
match user {
Some(u) => {
add_user(target, u.id, &*data)?;
msg.reply(
&ctx,
MessageBuilder::new()
.push("user has been set to ")
.push_mono_safe(u.username)
.build(),
)
.await?;
}
None => {
msg.reply(&ctx, "user not found...").await?;
}
}
Ok(())
}
fn add_user(target: serenity::model::id::UserId, user_id: u64, data: &TypeMap) -> Result<()> {
OsuSavedUsers::open(data).borrow_mut()?.insert(
target,
OsuUser {
id: user_id,
last_update: chrono::Utc::now(),
pp: vec![],
},
);
OsuUserBests::open(data)
.borrow_mut()?
.iter_mut()
.for_each(|(_, r)| {
r.remove(&target);
});
Ok(())
}
struct ModeArg(Mode); struct ModeArg(Mode);
impl FromStr for ModeArg { impl FromStr for ModeArg {
@ -438,7 +507,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu
"{}: here is the play that you requested", "{}: here is the play that you requested",
msg.author msg.author
)) ))
.embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user, None, m)) .embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user).build(m))
}) })
.await?; .await?;
@ -537,7 +606,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
for score in scores.iter() { for score in scores.iter() {
msg.channel_id msg.channel_id
.send_message(&ctx, |c| { .send_message(&ctx, |c| {
c.embed(|m| score_embed(&score, &bm, &content, &user, None, m)) c.embed(|m| score_embed(&score, &bm, &content, &user).build(m))
}) })
.await?; .await?;
} }
@ -601,7 +670,11 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
"{}: here is the play that you requested", "{}: here is the play that you requested",
msg.author msg.author
)) ))
.embed(|m| score_embed(&top_play, &beatmap, &content, &user, Some(rank), m)) .embed(|m| {
score_embed(&top_play, &beatmap, &content, &user)
.top_record(rank)
.build(m)
})
}) })
.await?; .await?;

View file

@ -0,0 +1,19 @@
use crate::models::User;
const BEATMAP_IDS: [u64; 100] = [
2469345, 2084862, 2486881, 2330357, 2546607, 1655981, 1626537, 888015, 1062394, 1319547,
1852572, 1944926, 2129143, 1057509, 2022718, 1097543, 1736329, 1056207, 930249, 1936782,
1919312, 1570203, 2201460, 1495498, 965549, 2428358, 2118444, 1849433, 820619, 999944, 1571309,
1055147, 1619555, 338682, 1438917, 954692, 824891, 2026320, 764014, 2237466, 2058788, 1969946,
1892257, 1473301, 2336704, 774965, 657509, 1031604, 898576, 714001, 1872396, 831705, 1917082,
978326, 795232, 1814494, 713867, 2077126, 1612329, 1314214, 1849273, 1829925, 1640362, 801158,
431957, 1054501, 1627148, 816600, 1857519, 1080094, 1642274, 1232440, 1843653, 953586, 2044362,
1489536, 951053, 1069111, 2154507, 1007699, 1099936, 1077323, 1874119, 909032, 760466, 1911308,
1820921, 1231520, 954254, 425779, 1586059, 2198684, 1040044, 799913, 994933, 969681, 888016,
1100327, 1063410, 2078961,
];
pub fn user_register_beatmap_id(u: &User) -> u64 {
let now = chrono::Utc::now();
BEATMAP_IDS[(u.id + (now.timestamp() / 3600) as u64) as usize % BEATMAP_IDS.len()]
}

View file

@ -13,7 +13,7 @@ use std::convert::TryInto;
use youmubot_prelude::{ratelimit::Ratelimit, *}; use youmubot_prelude::{ratelimit::Ratelimit, *};
/// The number of requests per minute to the osu! server. /// The number of requests per minute to the osu! server.
const REQUESTS_PER_MINUTE: usize = 60; const REQUESTS_PER_MINUTE: usize = 100;
/// Client is the client that will perform calls to the osu! api server. /// Client is the client that will perform calls to the osu! api server.
pub struct Client { pub struct Client {

View file

@ -1,4 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
@ -11,6 +12,10 @@ pub(crate) mod raw;
pub use mods::Mods; pub use mods::Mods;
use serenity::utils::MessageBuilder; 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)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum ApprovalStatus { pub enum ApprovalStatus {
Loved, Loved,
@ -264,6 +269,17 @@ impl Mode {
}) })
} }
/// Parse from the display output of the enum itself.
pub fn parse_from_display(s: &str) -> Option<Self> {
Some(match s {
"osu!" => Mode::Std,
"osu!taiko" => Mode::Taiko,
"osu!mania" => Mode::Catch,
"osu!catch" => Mode::Mania,
_ => return None,
})
}
/// Parse from the new site's convention. /// Parse from the new site's convention.
pub fn parse_from_new_site(s: &str) -> Option<Self> { pub fn parse_from_new_site(s: &str) -> Option<Self> {
Some(match s { Some(match s {
@ -377,6 +393,30 @@ pub struct UserEvent {
pub epic_factor: u8, pub epic_factor: u8,
} }
/// Represents a "achieved rank #x on beatmap" event.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserEventRank {
pub beatmap_id: u64,
pub rank: u16,
pub mode: Mode,
pub date: DateTime<Utc>,
}
impl UserEvent {
/// Try to parse the event into a "rank" event.
pub fn to_event_rank(&self) -> Option<UserEventRank> {
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.clone(),
mode,
rank,
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: u64, pub id: u64,