diff --git a/Cargo.lock b/Cargo.lock index 6f9104b..cd3cae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,6 +1740,7 @@ name = "youmubot-prelude" version = "0.1.0" dependencies = [ "crossbeam-channel 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rayon 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "serenity 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "youmubot-db 0.1.0", diff --git a/youmubot-cf/src/live.rs b/youmubot-cf/src/live.rs new file mode 100644 index 0000000..745cf93 --- /dev/null +++ b/youmubot-cf/src/live.rs @@ -0,0 +1,121 @@ +use codeforces::{Problem, ProblemResult, ProblemResultType, RanklistRow}; +use serenity::{ + framework::standard::CommandResult, + model::{ + guild::Member, + id::{ChannelId, GuildId}, + }, + utils::MessageBuilder, +}; +use youmubot_prelude::*; + +/// Watch and commentate a contest. +/// +/// Does the thing on a channel, block until the contest ends. +pub fn watch_contest( + ctx: &mut Context, + guild: GuildId, + channel: ChannelId, + contest_id: u64, +) -> CommandResult { + unimplemented!() +} + +fn scan_changes( + http: ::Value, + members: &[(Member, &str)], +) -> Vec { + unimplemented!() +} + +fn translate_change( + handle: &str, + row: &RanklistRow, + member: &Member, + problem: &Problem, + res: &ProblemResult, + change: Change, +) -> String { + let mut m = MessageBuilder::new(); + m.push_bold_safe(handle) + .push(" (") + .push_safe(member.distinct()) + .push(")"); + + use Change::*; + match change { + PretestsPassed => m + .push(" passed the pretest on problem ") + .push_bold_safe(format!("{} - {}", problem.index, problem.name)) + .push(", scoring ") + .push_bold(format!("{:.0}", res.points)) + .push(" points, now at ") + .push_bold(format!("#{}", row.rank)) + .push("! 🍷"), + Attempted => m + .push(format!( + " made another attempt (total **{}**) on problem ", + res.rejected_attempt_count + )) + .push_bold_safe(format!("{} - {}", problem.index, problem.name)) + .push(", but did not get the right answer. Keep going! 🏃‍♂️"), + Hacked => m + .push(" got **hacked** on problem ") + .push_bold_safe(format!("{} - {}", problem.index, problem.name)) + .push(", now at rank ") + .push_bold(format!("#{}", row.rank)) + .push("... Find your bug!🐛"), + Accepted => m + .push(" got **Accepted** on the **final tests** on problem ") + .push_bold_safe(format!("{} - {}", problem.index, problem.name)) + .push(", permanently scoring ") + .push_bold(format!("{:.0}", res.points)) + .push(" points, now at ") + .push_bold(format!("#{}", row.rank)) + .push(" 🎉"), + TestFailed => m + .push(" **failed** on the **final tests** on problem ") + .push_bold_safe(format!("{} - {}", problem.index, problem.name)) + .push(", now at ") + .push_bold(format!("#{}", row.rank)) + .push(" 😭"), + }; + m.build() +} + +enum Change { + PretestsPassed, + Attempted, + Hacked, + Accepted, + TestFailed, +} + +fn analyze_change(old: &ProblemResult, new: &ProblemResult) -> Option { + use Change::*; + if old.points == new.points { + if new.rejected_attempt_count > old.rejected_attempt_count { + return Some(Attempted); + } + if old.result_type != new.result_type { + return Some(Accepted); + } + None + } else { + if new.points == 0.0 { + if new.result_type == ProblemResultType::Preliminary { + Some(Hacked) + } else { + Some(TestFailed) + } + } else if new.points > old.points { + if new.result_type == ProblemResultType::Preliminary { + Some(PretestsPassed) + } else { + Some(Accepted) + } + } else { + Some(PretestsPassed) + } + } +} diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index f7e3353..03a1676 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -5,87 +5,79 @@ use crate::{ request::{BeatmapRequestKind, UserID}, Client as Osu, }; +use announcer::MemberToChannels; use rayon::prelude::*; use serenity::{ framework::standard::{CommandError as Error, CommandResult}, - http::Http, - model::id::{ChannelId, UserId}, + http::CacheHttp, + CacheAndHttp, }; +use std::sync::Arc; use youmubot_prelude::*; +/// osu! announcer's unique announcer key. +pub const ANNOUNCER_KEY: &'static str = "osu"; + /// Announce osu! top scores. -pub struct OsuAnnouncer; - -impl Announcer for OsuAnnouncer { - fn announcer_key() -> &'static str { - "osu" - } - fn send_messages( - c: &Http, - d: AppData, - channels: impl Fn(UserId) -> Vec + Sync, - ) -> CommandResult { - let osu = d.get_cloned::(); - // For each user... - let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone(); - 'user_loop: for (user_id, osu_user) in data.iter_mut() { - let mut pp_values = vec![]; // Store the pp values here... - for mode in &[Mode::Std, Mode::Taiko, Mode::Mania, Mode::Catch] { - let scores = OsuAnnouncer::scan_user(&osu, osu_user, *mode)?; - if scores.is_empty() && !osu_user.pp.is_empty() { - // Nothing to update: no new scores and pp is there. - pp_values.push(osu_user.pp[*mode as usize]); - continue; - } - let user = match osu.user(UserID::ID(osu_user.id), |f| f.mode(*mode)) { - Ok(Some(u)) => u, - _ => continue 'user_loop, - }; - pp_values.push(user.pp); - scores - .into_par_iter() - .filter_map(|(rank, score)| { - let beatmap = osu - .beatmaps(BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f) - .map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), *mode)); - let channels = channels(*user_id); - match beatmap { - Ok(v) => Some((rank, score, v, channels)), - Err(e) => { - dbg!(e); - None - } - } - }) - .for_each(|(rank, score, beatmap, channels)| { - for channel in channels { - if let Err(e) = channel.send_message(c, |c| { - c.content(format!("New top record from {}!", user_id.mention())) - .embed(|e| score_embed(&score, &beatmap, &user, Some(rank), e)) - }) { - dbg!(e); - } - } - }); +pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> CommandResult { + let osu = d.get_cloned::(); + // For each user... + let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone(); + 'user_loop: for (user_id, osu_user) in data.iter_mut() { + let mut pp_values = vec![]; // Store the pp values here... + for mode in &[Mode::Std, Mode::Taiko, Mode::Mania, Mode::Catch] { + let scores = scan_user(&osu, osu_user, *mode)?; + if scores.is_empty() && !osu_user.pp.is_empty() { + // Nothing to update: no new scores and pp is there. + pp_values.push(osu_user.pp[*mode as usize]); + continue; } - osu_user.last_update = chrono::Utc::now(); - osu_user.pp = pp_values; + let user = match osu.user(UserID::ID(osu_user.id), |f| f.mode(*mode)) { + Ok(Some(u)) => u, + _ => continue 'user_loop, + }; + pp_values.push(user.pp); + scores + .into_par_iter() + .filter_map(|(rank, score)| { + let beatmap = osu + .beatmaps(BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f) + .map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), *mode)); + let channels = channels.channels_of(c.clone(), *user_id); + match beatmap { + Ok(v) => Some((rank, score, v, channels)), + Err(e) => { + dbg!(e); + None + } + } + }) + .for_each(|(rank, score, beatmap, channels)| { + for channel in channels { + 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, &user, Some(rank), e)) + }) { + dbg!(e); + } + } + }); } - // Update users - *OsuSavedUsers::open(&*d.read()).borrow_mut()? = data; - Ok(()) + osu_user.last_update = chrono::Utc::now(); + osu_user.pp = pp_values; } + // Update users + *OsuSavedUsers::open(&*d.read()).borrow_mut()? = data; + Ok(()) } -impl OsuAnnouncer { - fn scan_user(osu: &Osu, u: &OsuUser, mode: Mode) -> Result, Error> { - let scores = osu.user_best(UserID::ID(u.id), |f| f.mode(mode).limit(25))?; - let scores = scores - .into_iter() - .filter(|s: &Score| s.date >= u.last_update) - .enumerate() - .map(|(i, v)| ((i + 1) as u8, v)) - .collect(); - Ok(scores) - } +fn scan_user(osu: &Osu, u: &OsuUser, mode: Mode) -> Result, Error> { + let scores = osu.user_best(UserID::ID(u.id), |f| f.mode(mode).limit(25))?; + let scores = scores + .into_iter() + .filter(|s: &Score| s.date >= u.last_update) + .enumerate() + .map(|(i, v)| ((i + 1) as u8, v)) + .collect(); + Ok(scores) } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 70dadea..1aed3fd 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -21,7 +21,6 @@ pub(crate) mod embeds; mod hook; mod server_rank; -pub use announcer::OsuAnnouncer; use db::OsuUser; use db::{OsuLastBeatmap, OsuSavedUsers}; use embeds::{beatmap_embed, score_embed, user_embed}; @@ -48,8 +47,8 @@ impl TypeMapKey for OsuClient { /// pub fn setup( path: &std::path::Path, - client: &serenity::client::Client, data: &mut ShareMap, + announcers: &mut AnnouncerHandler, ) -> CommandResult { // Databases OsuSavedUsers::insert_into(&mut *data, &path.join("osu_saved_users.yaml"))?; @@ -63,7 +62,7 @@ pub fn setup( )); // Announcer - OsuAnnouncer::scan(&client, std::time::Duration::from_secs(300)); + announcers.add(announcer::ANNOUNCER_KEY, announcer::updates); Ok(()) } diff --git a/youmubot-prelude/Cargo.toml b/youmubot-prelude/Cargo.toml index c2eb702..f15ce4a 100644 --- a/youmubot-prelude/Cargo.toml +++ b/youmubot-prelude/Cargo.toml @@ -11,3 +11,4 @@ serenity = "0.8" youmubot-db = { path = "../youmubot-db" } crossbeam-channel = "0.4" reqwest = "0.10" +rayon = "1" diff --git a/youmubot-prelude/src/announcer.rs b/youmubot-prelude/src/announcer.rs index 47e7400..62b50ab 100644 --- a/youmubot-prelude/src/announcer.rs +++ b/youmubot-prelude/src/announcer.rs @@ -1,80 +1,252 @@ -use crate::AppData; +use crate::{AppData, GetCloned}; +use rayon::prelude::*; use serenity::{ - framework::standard::{CommandError as Error, CommandResult}, - http::{CacheHttp, Http}, - model::id::{ChannelId, GuildId, UserId}, + framework::standard::{ + macros::{command, group}, + Args, CommandError as Error, CommandResult, + }, + http::CacheHttp, + model::{ + channel::Message, + id::{ChannelId, GuildId, UserId}, + }, + prelude::*, + utils::MessageBuilder, + CacheAndHttp, }; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, + sync::Arc, thread::{spawn, JoinHandle}, }; use youmubot_db::DB; +/// A list of assigned channels for an announcer. pub(crate) type AnnouncerChannels = DB>>; -pub trait Announcer { - fn announcer_key() -> &'static str; - fn send_messages( - c: &Http, +/// The Announcer trait. +/// +/// Every announcer needs to implement a method to look for updates. +/// This method is called "updates", which takes: +/// - A CacheHttp implementation, for interaction with Discord itself. +/// - An AppData, which can be used for interacting with internal databases. +/// - A function "channels", which takes an UserId and returns the list of ChannelIds, which any update related to that user should be +/// sent to. +pub trait Announcer: Send { + /// Look for updates and send them to respective channels. + /// + /// Errors returned from this function gets ignored and logged down. + fn updates( + &mut self, + c: Arc, d: AppData, - channels: impl Fn(UserId) -> Vec + Sync, + channels: MemberToChannels, ) -> CommandResult; +} - fn set_channel(d: AppData, guild: GuildId, channel: ChannelId) -> CommandResult { - AnnouncerChannels::open(&*d.read()) - .borrow_mut()? - .entry(Self::announcer_key().to_owned()) - .or_default() - .insert(guild, channel); - Ok(()) +impl Announcer for T +where + T: FnMut(Arc, AppData, MemberToChannels) -> CommandResult + Send, +{ + fn updates( + &mut self, + c: Arc, + d: AppData, + channels: MemberToChannels, + ) -> CommandResult { + self(c, d, channels) + } +} + +/// A simple struct that allows looking up the relevant channels to an user. +pub struct MemberToChannels(Vec<(GuildId, ChannelId)>); + +impl MemberToChannels { + /// Gets the channel list of an user related to that channel. + pub fn channels_of( + &self, + http: impl CacheHttp + Clone + Sync, + u: impl Into, + ) -> Vec { + let u = u.into(); + self.0 + .par_iter() + .filter_map(|(guild, channel)| { + guild.member(http.clone(), u).ok().map(|_| channel.clone()) + }) + .collect::>() + } +} + +/// The announcer handler. +/// +/// This struct manages the list of all Announcers, firing them in a certain interval. +pub struct AnnouncerHandler { + cache_http: Arc, + data: AppData, + announcers: HashMap<&'static str, Box>, +} + +// Querying for the AnnouncerHandler in the internal data returns a vec of keys. +impl TypeMapKey for AnnouncerHandler { + type Value = Vec<&'static str>; +} + +/// Announcer-managing related. +impl AnnouncerHandler { + /// Create a new instance of the handler. + pub fn new(client: &serenity::Client) -> Self { + Self { + cache_http: client.cache_and_http.clone(), + data: client.data.clone(), + announcers: HashMap::new(), + } } - fn get_guilds(d: AppData) -> Result, Error> { + /// Insert a new announcer into the handler. + /// + /// The handler must take an unique key. If a duplicate is found, this method panics. + pub fn add(&mut self, key: &'static str, announcer: impl Announcer + 'static) -> &mut Self { + if let Some(_) = self.announcers.insert(key, Box::new(announcer)) { + panic!( + "Announcer keys must be unique: another announcer with key `{}` was found", + key + ) + } else { + self + } + } +} + +/// Execution-related. +impl AnnouncerHandler { + /// Collect the list of guilds and their respective channels, by the key of the announcer. + fn get_guilds(&self, key: &'static str) -> Result, Error> { + let d = &self.data; let data = AnnouncerChannels::open(&*d.read()) .borrow()? - .get(Self::announcer_key()) + .get(key) .map(|m| m.iter().map(|(a, b)| (*a, *b)).collect()) .unwrap_or_else(|| vec![]); Ok(data) } - fn announce(c: impl AsRef, d: AppData) -> CommandResult { - let guilds: Vec<_> = Self::get_guilds(d.clone())?; - let member_sets = { - let mut v = Vec::with_capacity(guilds.len()); - for (guild, channel) in guilds.into_iter() { - let mut s = HashSet::new(); - for user in guild - .members_iter(c.as_ref()) - .take_while(|u| u.is_ok()) - .filter_map(|u| u.ok()) - { - s.insert(user.user_id()); - } - v.push((s, channel)) - } - v - }; - Self::send_messages(c.as_ref(), d, |user_id| { - let mut v = Vec::new(); - for (members, channel) in member_sets.iter() { - if members.contains(&user_id) { - v.push(*channel); - } - } - v - })?; + /// Run the announcing sequence on a certain announcer. + fn announce(&mut self, key: &'static str) -> CommandResult { + let guilds: Vec<_> = self.get_guilds(key)?; + let channels = MemberToChannels(guilds); + let cache_http = self.cache_http.clone(); + let data = self.data.clone(); + let announcer = self + .announcers + .get_mut(&key) + .expect("Key is from announcers"); + announcer.updates(cache_http, data, channels)?; Ok(()) } - fn scan(client: &serenity::Client, cooldown: std::time::Duration) -> JoinHandle<()> { - let c = client.cache_and_http.clone(); - let data = client.data.clone(); + /// Start the AnnouncerHandler, moving it into another thread. + /// + /// It will run all the announcers in sequence every *cooldown* seconds. + pub fn scan(mut self, cooldown: std::time::Duration) -> JoinHandle<()> { + // First we store all the keys inside the database. + let keys = self.announcers.keys().cloned().collect::>(); + self.data.write().insert::(keys.clone()); spawn(move || loop { - if let Err(e) = Self::announce(c.http(), data.clone()) { - dbg!(e); + for key in &keys { + if let Err(e) = self.announce(key) { + dbg!(e); + } } std::thread::sleep(cooldown); }) } } + +#[command("register")] +#[description = "Register the current channel with an announcer"] +#[usage = "[announcer key]"] +#[required_permissions(MANAGE_CHANNELS)] +#[only_in(guilds)] +#[num_args(1)] +pub fn register_announcer(ctx: &mut Context, m: &Message, mut args: Args) -> CommandResult { + let key = args.single::()?; + let keys = ctx.data.get_cloned::(); + if !keys.contains(&key.as_str()) { + m.reply( + &ctx, + format!( + "Key not found. Available announcer keys are: `{}`", + keys.join(", ") + ), + )?; + return Ok(()); + } + let guild = m.guild(&ctx).expect("Guild-only command"); + let guild = guild.read(); + let channel = m.channel_id.to_channel(&ctx)?; + AnnouncerChannels::open(&*ctx.data.read()) + .borrow_mut()? + .entry(key.clone()) + .or_default() + .insert(guild.id, m.channel_id); + m.reply( + &ctx, + MessageBuilder::new() + .push("Announcer ") + .push_mono_safe(key) + .push(" has been activated for server ") + .push_bold_safe(&guild.name) + .push(" on channel ") + .push_bold_safe(channel) + .build(), + )?; + Ok(()) +} + +#[command("remove")] +#[description = "Remove an announcer from the server"] +#[usage = "[announcer key]"] +#[required_permissions(MANAGE_CHANNELS)] +#[only_in(guilds)] +#[num_args(1)] +pub fn remove_announcer(ctx: &mut Context, m: &Message, mut args: Args) -> CommandResult { + let key = args.single::()?; + let keys = ctx.data.get_cloned::(); + if !keys.contains(&key.as_str()) { + m.reply( + &ctx, + format!( + "Key not found. Available announcer keys are: `{}`", + keys.join(", ") + ), + )?; + return Ok(()); + } + let guild = m.guild(&ctx).expect("Guild-only command"); + let guild = guild.read(); + AnnouncerChannels::open(&*ctx.data.read()) + .borrow_mut()? + .entry(key.clone()) + .and_modify(|m| { + m.remove(&guild.id); + }); + m.reply( + &ctx, + MessageBuilder::new() + .push("Announcer ") + .push_mono_safe(key) + .push(" has been de-activated for server ") + .push_bold_safe(&guild.name) + .build(), + )?; + Ok(()) +} + +#[group("announcer")] +#[prefix("announcer")] +#[only_in(guilds)] +#[required_permissions(MANAGE_CHANNELS)] +#[description = "Manage the announcers in the server."] +#[commands(remove_announcer, register_announcer)] +pub struct AnnouncerCommands; diff --git a/youmubot-prelude/src/lib.rs b/youmubot-prelude/src/lib.rs index 77a31a1..8a9669a 100644 --- a/youmubot-prelude/src/lib.rs +++ b/youmubot-prelude/src/lib.rs @@ -7,7 +7,7 @@ pub mod pagination; pub mod reaction_watch; pub mod setup; -pub use announcer::Announcer; +pub use announcer::{Announcer, AnnouncerHandler}; pub use args::Duration; pub use pagination::Pagination; pub use reaction_watch::{ReactionHandler, ReactionWatcher}; @@ -50,3 +50,28 @@ impl GetCloned for AppData { self.read().get::().cloned().expect("Should be there") } } + +pub mod prelude_commands { + use crate::announcer::ANNOUNCERCOMMANDS_GROUP; + use serenity::{ + framework::standard::{ + macros::{command, group}, + CommandResult, + }, + model::channel::Message, + prelude::Context, + }; + + #[group("Prelude")] + #[description = "All the commands that makes the base of Youmu"] + #[commands(ping)] + #[sub_groups(AnnouncerCommands)] + pub struct Prelude; + + #[command] + #[description = "pong!"] + fn ping(ctx: &mut Context, m: &Message) -> CommandResult { + m.reply(&ctx, "Pong!")?; + Ok(()) + } +} diff --git a/youmubot/src/main.rs b/youmubot/src/main.rs index 5488850..13c6cde 100644 --- a/youmubot/src/main.rs +++ b/youmubot/src/main.rs @@ -25,7 +25,7 @@ impl EventHandler for Handler { } fn message(&self, mut ctx: Context, message: Message) { - self.hooks.iter().for_each(|f| f(&mut ctx, &message)); + self.hooks.iter().for_each(|f| f(&mut ctx, &message)); } fn reaction_add(&self, ctx: Context, reaction: Reaction) { @@ -63,6 +63,9 @@ fn main() { // Set up base framework let mut fw = setup_framework(&client); + // Set up announcer handler + let mut announcers = AnnouncerHandler::new(&client); + // Setup each package starting from the prelude. { let mut data = client.data.write(); @@ -78,7 +81,8 @@ fn main() { youmubot_core::setup(&db_path, &client, &mut data).expect("Setup db should succeed"); // osu! #[cfg(feature = "osu")] - youmubot_osu::discord::setup(&db_path, &client, &mut data).expect("osu! is initialized"); + youmubot_osu::discord::setup(&db_path, &mut data, &mut announcers) + .expect("osu! is initialized"); } #[cfg(feature = "core")] @@ -87,6 +91,7 @@ fn main() { println!("osu! enabled."); client.with_framework(fw); + announcers.scan(std::time::Duration::from_secs(300)); println!("Starting..."); if let Err(v) = client.start() { @@ -160,7 +165,8 @@ fn setup_framework(client: &Client) -> StandardFramework { .bucket("images", |c| c.time_span(60).limit(2)) .bucket("community", |c| { c.delay(30).time_span(30).limit(1) - }); + }) + .group(&prelude_commands::PRELUDE_GROUP); // groups here #[cfg(feature = "core")] let fw = fw