Implement mapping announcement as another announcer

This commit is contained in:
Natsu Kagami 2025-05-20 20:25:35 +02:00
parent 98278de2f3
commit a4974c3074
Signed by: nki
GPG key ID: 55A032EB38B49ADB
5 changed files with 264 additions and 7 deletions

1
Cargo.lock generated
View file

@ -3906,6 +3906,7 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"chrono", "chrono",
"dashmap 5.5.3", "dashmap 5.5.3",
"flume 0.10.14",
"futures", "futures",
"futures-util", "futures-util",
"lazy_static", "lazy_static",

View file

@ -29,6 +29,7 @@ rand = "0.8"
futures = "0.3" futures = "0.3"
futures-util = "0.3" futures-util = "0.3"
thiserror = "2" thiserror = "2"
flume = "0.10"
youmubot-db = { path = "../youmubot-db" } youmubot-db = { path = "../youmubot-db" }
youmubot-db-sql = { path = "../youmubot-db-sql" } youmubot-db-sql = { path = "../youmubot-db-sql" }

View file

@ -1,7 +1,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use dashmap::DashMap;
use futures_util::try_join; use futures_util::try_join;
use serenity::all::Member; use serenity::all::{Member, MessageBuilder};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use stream::FuturesUnordered; use stream::FuturesUnordered;
@ -20,7 +21,8 @@ use youmubot_prelude::*;
use crate::discord::calculate_weighted_map_age; use crate::discord::calculate_weighted_map_age;
use crate::discord::db::OsuUserMode; 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::scores::LazyBuffer;
use crate::{ use crate::{
discord::cache::save_beatmap, discord::cache::save_beatmap,
@ -37,15 +39,38 @@ use super::{embeds::score_embed, BeatmapWithMode};
/// osu! announcer's unique announcer key. /// osu! announcer's unique announcer key.
pub const ANNOUNCER_KEY: &str = "osu"; pub const ANNOUNCER_KEY: &str = "osu";
pub const ANNOUNCER_MAPPING_KEY: &str = "osu-mapping";
const MAX_FAILURES: u8 = 64; const MAX_FAILURES: u8 = 64;
/// The announcer struct implementing youmubot_prelude::Announcer /// The announcer struct implementing youmubot_prelude::Announcer
pub struct Announcer {} pub struct Announcer {
mapping_events: Arc<DashMap<UserId, Vec<UserEventMapping>>>,
filled: flume::Sender<()>,
filled_recv: flume::Receiver<()>,
}
impl Announcer { impl Announcer {
pub fn new() -> Self { 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<DashMap<UserId, Vec<UserEventMapping>>>,
filled_recv: flume::Receiver<()>,
} }
#[async_trait] #[async_trait]
@ -69,6 +94,7 @@ impl youmubot_prelude::Announcer for Announcer {
.collect::<stream::FuturesUnordered<_>>() .collect::<stream::FuturesUnordered<_>>()
.collect::<()>() .collect::<()>()
.await; .await;
self.filled.try_send(()).ok();
Ok(()) Ok(())
} }
} }
@ -153,8 +179,14 @@ impl Announcer {
.pls_ok() .pls_ok()
{ {
if let Some(header) = env.client.user_header(user.id).await.pls_ok().flatten() { if let Some(header) = env.client.user_header(user.id).await.pls_ok().flatten() {
let mut mapping_events = Vec::<UserEventMapping>::new();
let recents = recents let recents = recents
.into_iter() .into_iter()
.inspect(|v| {
if let Some(mp) = v.to_event_mapping() {
mapping_events.push(mp);
}
})
.filter_map(|v| v.to_event_rank()) .filter_map(|v| v.to_event_rank())
.filter(|s| Self::is_worth_announcing(s)) .filter(|s| Self::is_worth_announcing(s))
.filter(|s| { .filter(|s| {
@ -167,6 +199,12 @@ impl Announcer {
.filter_map(|v| future::ready(v.pls_ok())) .filter_map(|v| future::ready(v.pls_ok()))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await; .await;
self.mapping_events
.entry(user.user_id)
.or_default()
.extend(mapping_events);
to_announce = to_announce =
CollectedScore::merge(to_announce.into_iter().chain(recents)).collect(); 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::<OsuEnv>().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<UserEventMapping>,
}
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::<stream::FuturesUnordered<_>>()
.collect::<()>()
.await;
Ok(())
}
}
impl MappingAnnouncer {
async fn update_user(
&self,
ctx: CacheAndHttp,
env: &OsuEnv,
user_id: UserId,
mut events: Vec<UserEventMapping>,
channels: Vec<ChannelId>,
) {
// 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!(
"[{}](<https://osu.ppy.sh/beatmapsets/{}>)",
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();
}
}
}
}

View file

@ -130,7 +130,11 @@ pub async fn setup(
let beatmap_cache = BeatmapMetaCache::new(osu_client.clone(), prelude.sql.clone()); let beatmap_cache = BeatmapMetaCache::new(osu_client.clone(), prelude.sql.clone());
// Announcer // 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 // Legacy data
data.insert::<OsuLastBeatmap>(last_beatmaps.clone()); data.insert::<OsuLastBeatmap>(last_beatmaps.clone());

View file

@ -1,10 +1,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use mods::Stats; use mods::Stats;
use rosu_v2::prelude::{GameModIntermode, ScoreStatistics}; use rosu_v2::prelude::{GameModIntermode, RankStatus, ScoreStatistics};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime;
pub mod mods; pub mod mods;
pub(crate) mod rosu; pub(crate) mod rosu;
@ -474,6 +475,14 @@ impl Beatmap {
format!("https://b.ppy.sh/thumb/{}l.jpg", self.beatmapset_id) 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 /// Beatmap title and difficulty name
pub fn map_title(&self) -> String { pub fn map_title(&self) -> String {
MessageBuilder::new() MessageBuilder::new()
@ -520,6 +529,24 @@ pub struct UserEventRank {
pub date: DateTime<Utc>, pub date: DateTime<Utc>,
} }
/// 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<Utc>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UserEventMappingKind {
StatusChanged(RankStatus),
Deleted,
Revived,
Updated,
Uploaded,
}
impl UserEvent { impl UserEvent {
/// Try to parse the event into a "rank" event. /// Try to parse the event into a "rank" event.
pub fn to_event_rank(&self) -> Option<UserEventRank> { pub fn to_event_rank(&self) -> Option<UserEventRank> {
@ -549,6 +576,63 @@ impl UserEvent {
_ => None, _ => None,
} }
} }
/// Try to parse the event into a "mapping" event.
pub fn to_event_mapping(&self) -> Option<UserEventMapping> {
use rosu_v2::model::event::EventType::*;
use rosu_v2::prelude::*;
fn parse_beatmapset_id(p: &EventBeatmapset) -> Option<u64> {
p.url.trim_start_matches("/beatmapsets/").parse().ok()
}
fn make(
kind: UserEventMappingKind,
eb: &EventBeatmapset,
date: OffsetDateTime,
) -> Option<UserEventMapping> {
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)] #[derive(Clone, Debug)]