diff --git a/Cargo.lock b/Cargo.lock index da8fe82..0dbd8f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3906,6 +3906,7 @@ dependencies = [ "bitflags 1.3.2", "chrono", "dashmap 5.5.3", + "flume 0.10.14", "futures", "futures-util", "lazy_static", diff --git a/youmubot-osu/Cargo.toml b/youmubot-osu/Cargo.toml index 7f16d32..13382cf 100644 --- a/youmubot-osu/Cargo.toml +++ b/youmubot-osu/Cargo.toml @@ -29,6 +29,7 @@ rand = "0.8" futures = "0.3" futures-util = "0.3" thiserror = "2" +flume = "0.10" youmubot-db = { path = "../youmubot-db" } youmubot-db-sql = { path = "../youmubot-db-sql" } diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 4b78515..bcd5a4c 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -1,7 +1,8 @@ use chrono::{DateTime, Utc}; +use dashmap::DashMap; use futures_util::try_join; -use serenity::all::Member; -use std::collections::HashMap; +use serenity::all::{Member, MessageBuilder}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use stream::FuturesUnordered; @@ -20,7 +21,8 @@ use youmubot_prelude::*; use crate::discord::calculate_weighted_map_age; use crate::discord::db::OsuUserMode; -use crate::models::UserHeader; +use crate::discord::interaction::mapset_button; +use crate::models::{UserEventMapping, UserHeader}; use crate::scores::LazyBuffer; use crate::{ discord::cache::save_beatmap, @@ -37,15 +39,38 @@ use super::{embeds::score_embed, BeatmapWithMode}; /// osu! announcer's unique announcer key. pub const ANNOUNCER_KEY: &str = "osu"; +pub const ANNOUNCER_MAPPING_KEY: &str = "osu-mapping"; const MAX_FAILURES: u8 = 64; /// The announcer struct implementing youmubot_prelude::Announcer -pub struct Announcer {} +pub struct Announcer { + mapping_events: Arc>>, + filled: flume::Sender<()>, + filled_recv: flume::Receiver<()>, +} impl Announcer { pub fn new() -> Self { - Self {} + let (send, recv) = flume::bounded(1); + Self { + mapping_events: Arc::new(DashMap::new()), + filled: send, + filled_recv: recv, + } } + + /// Create a [MappingAnnouncer] linked to the current Announcer. + pub fn mapping_announcer(&self) -> impl youmubot_prelude::Announcer + Sync + 'static { + MappingAnnouncer { + mapping_events: self.mapping_events.clone(), + filled_recv: self.filled_recv.clone(), + } + } +} + +struct MappingAnnouncer { + mapping_events: Arc>>, + filled_recv: flume::Receiver<()>, } #[async_trait] @@ -69,6 +94,7 @@ impl youmubot_prelude::Announcer for Announcer { .collect::>() .collect::<()>() .await; + self.filled.try_send(()).ok(); Ok(()) } } @@ -153,8 +179,14 @@ impl Announcer { .pls_ok() { if let Some(header) = env.client.user_header(user.id).await.pls_ok().flatten() { + let mut mapping_events = Vec::::new(); let recents = recents .into_iter() + .inspect(|v| { + if let Some(mp) = v.to_event_mapping() { + mapping_events.push(mp); + } + }) .filter_map(|v| v.to_event_rank()) .filter(|s| Self::is_worth_announcing(s)) .filter(|s| { @@ -167,6 +199,12 @@ impl Announcer { .filter_map(|v| future::ready(v.pls_ok())) .collect::>() .await; + + self.mapping_events + .entry(user.user_id) + .or_default() + .extend(mapping_events); + to_announce = CollectedScore::merge(to_announce.into_iter().chain(recents)).collect(); } @@ -420,3 +458,132 @@ impl ScoreType { } } } + +#[async_trait] +impl youmubot_prelude::Announcer for MappingAnnouncer { + async fn updates( + &mut self, + ctx: CacheAndHttp, + d: AppData, + channels: MemberToChannels, + ) -> Result<()> { + let env = d.read().await.get::().unwrap().clone(); + self.filled_recv.recv_async().await?; + self.mapping_events + .iter_mut() + .map(|mut r| (r.key().clone(), std::mem::take(r.value_mut()))) + .map(|(user_id, maps)| { + struct R<'a> { + pub ann: &'a MappingAnnouncer, + pub ctx: &'a CacheAndHttp, + pub env: &'a OsuEnv, + pub user_id: UserId, + pub maps: Vec, + } + let r = R { + ann: self, + ctx: &ctx, + env: &env, + user_id, + maps, + }; + channels.channels_of(ctx.clone(), user_id).then(move |chs| { + r.ann + .update_user(r.ctx.clone(), r.env, r.user_id, r.maps, chs) + }) + }) + .collect::>() + .collect::<()>() + .await; + Ok(()) + } +} + +impl MappingAnnouncer { + async fn update_user( + &self, + ctx: CacheAndHttp, + env: &OsuEnv, + user_id: UserId, + mut events: Vec, + channels: Vec, + ) { + // first we filter out obsolete events + let events = { + let mut seen = HashSet::new(); + events.sort_by(|a, b| b.date.cmp(&a.date)); + events.retain(|v| seen.insert(v.beatmapset_id)); + events + }; + let Some(user) = user_id.to_user(&ctx).await.pls_ok() else { + return; + }; + for e in events { + use rosu_v2::prelude::*; + let msg = CreateMessage::new() + .content({ + let mut builder = MessageBuilder::new(); + builder + .push_bold_safe(user.display_name()) + .push("'s beatmap ") + .push_bold(format!( + "[{}]()", + e.beatmapset_title, e.beatmapset_id + )); + match e.kind { + crate::models::UserEventMappingKind::StatusChanged(rank_status) => { + let new_status = match rank_status { + RankStatus::Graveyard => "đŸĒĻ Graveyarded đŸĒĻ", + RankStatus::WIP => "đŸ› ī¸ Work in Progress đŸ› ī¸", + RankStatus::Pending => "â˛ī¸ Pending â˛ī¸", + RankStatus::Ranked => "🏆 Ranked 🏆", + RankStatus::Approved => "✅ Approved ✅", + RankStatus::Qualified => "🙏 Qualified 🙏", + RankStatus::Loved => "â¤ī¸ Loved â¤ī¸", + }; + builder.push(" is now ").push_bold(new_status); + } + crate::models::UserEventMappingKind::Deleted => { + builder.push(" has been ").push_bold("â™ģī¸ Deleted â™ģī¸"); + } + crate::models::UserEventMappingKind::Revived => { + builder.push(" has been ").push_bold("đŸ§Ÿâ€â™‚ī¸ Revived đŸ§Ÿâ€â™‚ī¸"); + } + crate::models::UserEventMappingKind::Updated => { + builder.push(" has been ").push_bold("✨ Updated ✨"); + } + crate::models::UserEventMappingKind::Uploaded => { + builder.push(" has been ").push_bold("🌐 Uploaded 🌐"); + } + } + builder.push("!").build() + }) + .button(mapset_button()) + .add_embeds(if e.kind == crate::models::UserEventMappingKind::Deleted { + vec![] + } else { + if let Some(mut beatmaps) = env + .client + .beatmaps( + crate::request::BeatmapRequestKind::Beatmapset(e.beatmapset_id), + |f| f, + ) + .await + .pls_ok() + { + beatmaps.sort_by(|a, b| { + a.difficulty.stars.partial_cmp(&b.difficulty.stars).unwrap() + }); + let embed = super::embeds::beatmapset_embed(&beatmaps, None); + vec![embed] + } else { + vec![] + } + }); + + for ch in &channels { + ch.send_message(&ctx, msg.clone()).await.pls_ok(); + } + } + } +} diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index caf4db1..697ea6f 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -130,7 +130,11 @@ pub async fn setup( let beatmap_cache = BeatmapMetaCache::new(osu_client.clone(), prelude.sql.clone()); // Announcer - announcers.add(announcer::ANNOUNCER_KEY, announcer::Announcer::new()); + let ann = announcer::Announcer::new(); + let map_ann = ann.mapping_announcer(); + announcers + .add(announcer::ANNOUNCER_KEY, ann) + .add(announcer::ANNOUNCER_MAPPING_KEY, map_ann); // Legacy data data.insert::(last_beatmaps.clone()); diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 6596220..44da5ae 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -1,10 +1,11 @@ use chrono::{DateTime, Utc}; use mods::Stats; -use rosu_v2::prelude::{GameModIntermode, ScoreStatistics}; +use rosu_v2::prelude::{GameModIntermode, RankStatus, ScoreStatistics}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fmt::{self, Display}; use std::time::Duration; +use time::OffsetDateTime; pub mod mods; pub(crate) mod rosu; @@ -474,6 +475,14 @@ impl Beatmap { format!("https://b.ppy.sh/thumb/{}l.jpg", self.beatmapset_id) } + pub fn set_title(&self) -> String { + MessageBuilder::new() + .push_safe(&self.artist) + .push(" - ") + .push_safe(&self.title) + .build() + } + /// Beatmap title and difficulty name pub fn map_title(&self) -> String { MessageBuilder::new() @@ -520,6 +529,24 @@ pub struct UserEventRank { pub date: DateTime, } +/// Represents a "beatmap updated" event. +#[derive(Clone, Debug)] +pub struct UserEventMapping { + pub kind: UserEventMappingKind, + pub beatmapset_title: String, // in case deleted + pub beatmapset_id: u64, + pub date: DateTime, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UserEventMappingKind { + StatusChanged(RankStatus), + Deleted, + Revived, + Updated, + Uploaded, +} + impl UserEvent { /// Try to parse the event into a "rank" event. pub fn to_event_rank(&self) -> Option { @@ -549,6 +576,63 @@ impl UserEvent { _ => None, } } + + /// Try to parse the event into a "mapping" event. + pub fn to_event_mapping(&self) -> Option { + use rosu_v2::model::event::EventType::*; + use rosu_v2::prelude::*; + fn parse_beatmapset_id(p: &EventBeatmapset) -> Option { + p.url.trim_start_matches("/beatmapsets/").parse().ok() + } + fn make( + kind: UserEventMappingKind, + eb: &EventBeatmapset, + date: OffsetDateTime, + ) -> Option { + Some(UserEventMapping { + kind, + beatmapset_title: eb.title.clone(), + beatmapset_id: parse_beatmapset_id(eb)?, + date: rosu::time_to_utc(date), + }) + } + match &self.0.event_type { + // When a beatmapset changes state + BeatmapsetApprove { + approval, + beatmapset, + user: _, + } => make( + UserEventMappingKind::StatusChanged(*approval), + beatmapset, + self.0.created_at, + ), + // When a beatmapset is deleted + BeatmapsetDelete { beatmapset } => { + make(UserEventMappingKind::Deleted, beatmapset, self.0.created_at) + } + // When a beatmapset in graveyard is updated + BeatmapsetRevive { + beatmapset, + user: _, + } => make(UserEventMappingKind::Revived, beatmapset, self.0.created_at), + // When a beatmapset is updated + BeatmapsetUpdate { + beatmapset, + user: _, + } => make(UserEventMappingKind::Updated, beatmapset, self.0.created_at), + // When a new beatmapset is uploaded + BeatmapsetUpload { + beatmapset, + user: _, + } => make( + UserEventMappingKind::Uploaded, + beatmapset, + self.0.created_at, + ), + _ => None, + } + } } #[derive(Clone, Debug)]