diff --git a/Cargo.lock b/Cargo.lock index cd3cae6..583e2ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,14 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.3.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "adler32" version = "1.0.4" @@ -151,6 +160,16 @@ dependencies = [ "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "codeforces" +version = "0.1.0" +source = "git+https://github.com/natsukagami/rust-codeforces-api#3ec1dc2a97c8225a5ba6bafee517080fc9ae88f7" +dependencies = [ + "reqwest 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "command_attr" version = "0.1.7" @@ -1687,12 +1706,30 @@ version = "0.1.0" dependencies = [ "dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", "serenity 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "youmubot-cf 0.1.0", "youmubot-core 0.1.0", "youmubot-db 0.1.0", "youmubot-osu 0.1.0", "youmubot-prelude 0.1.0", ] +[[package]] +name = "youmubot-cf" +version = "0.1.0" +dependencies = [ + "Inflector 0.11.4 (registry+https://github.com/rust-lang/crates.io-index)", + "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", + "codeforces 0.1.0 (git+https://github.com/natsukagami/rust-codeforces-api)", + "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rayon 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.3.3 (registry+https://github.com/rust-lang/crates.io-index)", + "reqwest 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.104 (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", + "youmubot-prelude 0.1.0", +] + [[package]] name = "youmubot-core" version = "0.1.0" @@ -1747,6 +1784,7 @@ dependencies = [ ] [metadata] +"checksum Inflector 0.11.4 (registry+https://github.com/rust-lang/crates.io-index)" = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" "checksum adler32 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2" "checksum aho-corasick 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "58fb5e95d83b38284460a5fda7d6470aa0b8844d283a0b614b8535e880800d2d" "checksum anyhow 1.0.26 (registry+https://github.com/rust-lang/crates.io-index)" = "7825f6833612eb2414095684fcf6c635becf3ce97fe48cf6421321e93bfbd53c" @@ -1769,6 +1807,7 @@ dependencies = [ "checksum cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)" = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" "checksum chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)" = "31850b4a4d6bae316f7a09e691c944c28299298837edc0a03f755618c23cbc01" "checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +"checksum codeforces 0.1.0 (git+https://github.com/natsukagami/rust-codeforces-api)" = "" "checksum command_attr 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "b61098146d3e0ad56c4918ae30ab9f32a7222cc859fc65fbc2a8475c1e48b336" "checksum core-foundation 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)" = "25b9e03f145fd4f2bf705e07b900cd41fc636598fe5dc452fd0db1441c3f496d" "checksum core-foundation-sys 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e7ca8a5221364ef15ce201e8ed2f609fc312682a8f4e0e3d4aa5879764e0fa3b" diff --git a/Cargo.toml b/Cargo.toml index 828c358..7fd1f61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "youmubot-prelude", "youmubot-db", "youmubot-core", + "youmubot-cf", "youmubot-osu", "youmubot", ] diff --git a/youmubot-cf/Cargo.toml b/youmubot-cf/Cargo.toml new file mode 100644 index 0000000..a2d0583 --- /dev/null +++ b/youmubot-cf/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "youmubot-cf" +version = "0.1.0" +authors = ["Natsu Kagami "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +serde = { version = "1", features = ["derive"] } +reqwest = "0.10.1" +serenity = "0.8" +Inflector = "0.11" +codeforces = { git = "https://github.com/natsukagami/rust-codeforces-api" } +regex = "1" +lazy_static = "1" +rayon = "1" +chrono = { version = "0.4", features = ["serde"] } + +youmubot-prelude = { path = "../youmubot-prelude" } +youmubot-db = { path = "../youmubot-db" } diff --git a/youmubot-cf/src/announcer.rs b/youmubot-cf/src/announcer.rs new file mode 100644 index 0000000..d1ce197 --- /dev/null +++ b/youmubot-cf/src/announcer.rs @@ -0,0 +1,96 @@ +use crate::db::{CfSavedUsers, CfUser}; +use announcer::MemberToChannels; +use chrono::{DateTime, Utc}; +use codeforces::{RatingChange, User}; +use serenity::{ + framework::standard::{CommandError, CommandResult}, + http::CacheHttp, + model::id::{ChannelId, UserId}, + CacheAndHttp, +}; +use std::sync::Arc; +use youmubot_prelude::*; + +type Reqwest = ::Value; + +/// Updates the rating and rating changes of the users. +pub fn updates( + http: Arc, + data: AppData, + channels: MemberToChannels, +) -> CommandResult { + let mut users = CfSavedUsers::open(&*data.read()).borrow()?.clone(); + let reqwest = data.get_cloned::(); + + for (user_id, cfu) in users.iter_mut() { + if let Err(e) = update_user(http.clone(), &channels, &reqwest, *user_id, cfu) { + dbg!((*user_id, e)); + } + } + + *CfSavedUsers::open(&*data.read()).borrow_mut()? = users; + Ok(()) +} + +fn update_user( + http: Arc, + channels: &MemberToChannels, + reqwest: &Reqwest, + user_id: UserId, + cfu: &mut CfUser, +) -> CommandResult { + let info = User::info(reqwest, &[cfu.handle.as_str()])? + .into_iter() + .next() + .ok_or(CommandError::from("Not found"))?; + + let rating_changes = { + let mut v = info.rating_changes(reqwest)?; + v.reverse(); + v + }; + + let mut channels_list: Option> = None; + let last_update = std::mem::replace(&mut cfu.last_update, Utc::now()); + // Update the rating + cfu.rating = info.rating; + + let mut send_message = |rc: RatingChange| -> CommandResult { + let (contest, _, _) = + codeforces::Contest::standings(reqwest, rc.contest_id, |f| f.limit(1, 1))?; + let channels = + channels_list.get_or_insert_with(|| channels.channels_of(http.clone(), user_id)); + for channel in channels { + if let Err(e) = channel.send_message(http.http(), |e| { + e.content(format!("Rating change for {}!", user_id.mention())) + .embed(|c| { + crate::embed::rating_change_embed( + &rc, + &info, + &contest, + &user_id.mention(), + c, + ) + }) + }) { + dbg!(e); + } + } + Ok(()) + }; + + // Check for any good announcements to make + for rc in rating_changes { + let date: DateTime = DateTime::from_utc( + chrono::NaiveDateTime::from_timestamp(rc.rating_update_time_seconds as i64, 0), + Utc, + ); + if &date > &last_update { + if let Err(v) = send_message(rc) { + dbg!(v); + } + } + } + + Ok(()) +} diff --git a/youmubot-cf/src/db.rs b/youmubot-cf/src/db.rs new file mode 100644 index 0000000..f5dc974 --- /dev/null +++ b/youmubot-cf/src/db.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use codeforces::User; +use serenity::model::id::UserId; +use std::collections::HashMap; +use youmubot_db::DB; +use youmubot_prelude::*; + +/// A database map that stores an user with the respective handle. +pub type CfSavedUsers = DB>; + +/// A saved Codeforces user. +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] +pub struct CfUser { + pub handle: String, + pub last_update: DateTime, + pub rating: Option, +} + +impl Default for CfUser { + fn default() -> Self { + Self { + handle: "".to_owned(), + last_update: Utc::now(), + rating: None, + } + } +} + +impl From for CfUser { + fn from(u: User) -> Self { + Self { + handle: u.handle, + last_update: Utc::now(), + rating: u.rating, + } + } +} diff --git a/youmubot-cf/src/embed.rs b/youmubot-cf/src/embed.rs new file mode 100644 index 0000000..2531029 --- /dev/null +++ b/youmubot-cf/src/embed.rs @@ -0,0 +1,107 @@ +use codeforces::{Contest, RatingChange, User}; +use inflector::Inflector; +use serenity::{builder::CreateEmbed, utils::MessageBuilder}; +use std::borrow::Borrow; + +fn unwrap_or_ref<'a, T: ?Sized, B: Borrow>(opt: &'a Option, default: &'a T) -> &'a T { + opt.as_ref().map(|v| v.borrow()).unwrap_or(default) +} + +/// Create an embed representing the user. +pub fn user_embed<'a>(user: &User, e: &'a mut CreateEmbed) -> &'a mut CreateEmbed { + let rank = unwrap_or_ref(&user.rank, "Unranked").to_title_case(); + let max_rank = unwrap_or_ref(&user.max_rank, "Unranked").to_title_case(); + let rating = user.rating.unwrap_or(1500); + let max_rating = user.max_rating.unwrap_or(1500); + let name = &[&user.first_name, &user.last_name] + .iter() + .filter_map(|v| v.as_ref().map(|v| v.as_str())) + .collect::>() + .join(" "); + let place = &[&user.organization, &user.city, &user.country] + .iter() + .filter_map(|v| v.as_ref().map(|v| v.as_str())) + .collect::>() + .join(", "); + e.color(user.color()) + .author(|a| a.name(&rank)) + .thumbnail(format!("https:{}", user.title_photo)) + .title(&user.handle) + .url(user.profile_url()) + .description(format!( + "{}\n{}", + if name == "" { + "".to_owned() + } else { + format!("**{}**", name) + }, + if place == "" { + "".to_owned() + } else { + format!("from **{}**", place) + } + )) + .field( + "Rating", + format!("**{}** (max **{}**)", rating, max_rating), + true, + ) + .field("Contribution", format!("**{}**", user.contribution), true) + .field( + "Rank", + format!("**{}** (max **{}**)", &rank, max_rank), + false, + ) +} + +/// Gets an embed of the Rating Change. +pub fn rating_change_embed<'a>( + rating_change: &RatingChange, + user: &User, + contest: &Contest, + tag: &str, + e: &'a mut CreateEmbed, +) -> &'a mut CreateEmbed { + let delta = (rating_change.new_rating as i64) - (rating_change.old_rating as i64); + let color = if delta < 0 { 0xff0000 } else { 0x00ff00 }; + let message = if delta > 0 { + MessageBuilder::new() + .push(tag) + .push(" competed in ") + .push_bold_safe(&contest.name) + .push(", gaining ") + .push_bold_safe(delta) + .push(" rating placing at ") + .push_bold(format!("#{}", rating_change.rank)) + .push("! 🎂🎂🎂") + .build() + } else { + MessageBuilder::new() + .push(tag) + .push(" competed in ") + .push_bold_safe(&contest.name) + .push(", but lost ") + .push_bold_safe(-delta) + .push(" rating placing at ") + .push_bold(format!("#{}", rating_change.rank)) + .push("... 😭😭😭") + .build() + }; + + e.author(|a| { + a.icon_url(format!("http:{}", &user.avatar)) + .url(user.profile_url()) + .name(&user.handle) + }) + .color(color) + .description(message) + .field("Contest Link", contest.url(), true) + .field( + "Rating Change", + format!( + "from **{}** to **{}**", + rating_change.old_rating, rating_change.new_rating + ), + false, + ) +} diff --git a/youmubot-cf/src/hook.rs b/youmubot-cf/src/hook.rs new file mode 100644 index 0000000..a3dcd97 --- /dev/null +++ b/youmubot-cf/src/hook.rs @@ -0,0 +1,145 @@ +use codeforces::{Contest, Problem}; +use lazy_static::lazy_static; +use rayon::{iter::Either, prelude::*}; +use regex::{Captures, Regex}; +use serenity::{ + builder::CreateEmbed, + framework::standard::{CommandError, CommandResult}, + model::channel::Message, + utils::MessageBuilder, +}; +use youmubot_prelude::*; + +lazy_static! { + static ref CONTEST_LINK: Regex = Regex::new( + r"https?://codeforces\.com/(contest|gym)/(?P\d+)(?:/problem/(?P\w+))?" + ) + .unwrap(); + static ref PROBLEMSET_LINK: Regex = Regex::new( + r"https?://codeforces\.com/problemset/problem/(?P\d+)/(?P\w+)" + ) + .unwrap(); +} + +enum ContestOrProblem { + Contest(Contest, Vec), + Problem(Problem), +} + +/// Prints info whenever a problem or contest (or more) is sent on a channel. +pub fn codeforces_info_hook(ctx: &mut Context, m: &Message) { + if m.author.bot { + return; + } + let http = ctx.data.get_cloned::(); + let matches = CONTEST_LINK + .captures_iter(&m.content) + .chain(PROBLEMSET_LINK.captures_iter(&m.content)) + // .collect::>() + // .into_par_iter() + .filter_map(|v| match parse_capture(http.clone(), v) { + Ok(v) => Some(v), + Err(e) => { + dbg!(e); + None + } + }) + .collect::>(); + if !matches.is_empty() { + m.channel_id + .send_message(&ctx, |c| { + c.content("Here are the info of the given Codeforces links!") + .embed(|e| print_info_message(&matches[..], e)) + }) + .ok(); + } +} + +fn print_info_message<'a>( + info: &[(ContestOrProblem, &str)], + e: &'a mut CreateEmbed, +) -> &'a mut CreateEmbed { + let (mut problems, contests): (Vec<_>, Vec<_>) = + info.par_iter().partition_map(|(v, l)| match v { + ContestOrProblem::Problem(p) => Either::Left((p, l)), + ContestOrProblem::Contest(c, p) => Either::Right((c, p, l)), + }); + problems.sort_by(|(a, _), (b, _)| a.rating.unwrap_or(1500).cmp(&b.rating.unwrap_or(1500))); + let mut m = MessageBuilder::new(); + if !problems.is_empty() { + m.push_line("**Problems**").push_line(""); + for (problem, link) in problems { + m.push(" - [") + .push_bold_safe(format!( + "[{}{}] {}", + problem.contest_id.unwrap_or(0), + problem.index, + problem.name + )) + .push(format!("]({})", link)); + if let Some(p) = problem.points { + m.push(format!(" | **{:.0}** points", p)); + } + if let Some(p) = problem.rating { + m.push(format!(" | rating **{:.0}**", p)); + } + if !problem.tags.is_empty() { + m.push(format!(" | tags: ||`{}`||", problem.tags.join(", "))); + } + m.push_line(""); + } + } + m.push_line(""); + + if !contests.is_empty() { + m.push_bold_line("Contests").push_line(""); + for (contest, problems, link) in contests { + let duration: Duration = format!("{}s", contest.duration_seconds).parse().unwrap(); + m.push(" - [") + .push_bold_safe(&contest.name) + .push(format!("]({})", link)) + .push(format!( + " | **{}** problems | duration **{}**", + problems.len(), + duration + )); + if let Some(p) = &contest.prepared_by { + m.push(format!( + " | prepared by [{}](https://codeforces.com/profile/{})", + p, p + )); + } + m.push_line(""); + } + } + e.description(m.build()) +} + +fn parse_capture<'a>( + http: ::Value, + cap: Captures<'a>, +) -> Result<(ContestOrProblem, &'a str), CommandError> { + let contest: u64 = cap + .name("contest") + .ok_or(CommandError::from("Contest not captured"))? + .as_str() + .parse()?; + let (contest, problems, _) = Contest::standings(&http, contest, |f| f.limit(1, 1))?; + match cap.name("problem") { + Some(p) => { + for problem in problems { + if &problem.index == p.as_str() { + return Ok(( + ContestOrProblem::Problem(problem), + cap.get(0).unwrap().as_str(), + )); + } + } + Err("No such problem in contest".into()) + } + None => Ok(( + ContestOrProblem::Contest(contest, problems), + cap.get(0).unwrap().as_str(), + )), + } +} diff --git a/youmubot-cf/src/lib.rs b/youmubot-cf/src/lib.rs new file mode 100644 index 0000000..3abb68e --- /dev/null +++ b/youmubot-cf/src/lib.rs @@ -0,0 +1,207 @@ +use serenity::{ + framework::standard::{ + macros::{command, group}, + Args, CommandError as Error, CommandResult, + }, + model::channel::Message, + utils::MessageBuilder, +}; +use youmubot_prelude::*; + +mod announcer; +mod db; +mod embed; +mod hook; + +// /// Live-commentating a Codeforces round. +// pub mod live; + +use db::CfSavedUsers; + +pub use hook::codeforces_info_hook; + +/// Sets up the CF databases. +pub fn setup(path: &std::path::Path, data: &mut ShareMap, announcers: &mut AnnouncerHandler) { + CfSavedUsers::insert_into(data, path.join("cf_saved_users.yaml")) + .expect("Must be able to set up DB"); + announcers.add("codeforces", announcer::updates); +} + +#[group] +#[prefix = "cf"] +#[description = "Codeforces-related commands"] +#[commands(profile, save, ranks)] +#[default_command(profile)] +pub struct Codeforces; + +#[command] +#[aliases("p", "show", "u", "user", "get")] +#[description = "Get an user's profile"] +#[usage = "[handle or tag = yourself]"] +#[example = "natsukagami"] +#[max_args(1)] +pub fn profile(ctx: &mut Context, m: &Message, mut args: Args) -> CommandResult { + let handle = args + .single::() + .unwrap_or(UsernameArg::mention(m.author.id)); + let http = ctx.data.get_cloned::(); + + let handle = match handle { + UsernameArg::Raw(s) => s, + UsernameArg::Tagged(u) => { + let db = CfSavedUsers::open(&*ctx.data.read()); + let db = db.borrow()?; + match db.get(&u) { + Some(v) => v.handle.clone(), + None => { + m.reply(&ctx, "no saved account found.")?; + return Ok(()); + } + } + } + }; + + let account = codeforces::User::info(&http, &[&handle[..]])? + .into_iter() + .next(); + + match account { + Some(v) => m.channel_id.send_message(&ctx, |send| { + send.content(format!( + "{}: Here is the user that you requested", + m.author.mention() + )) + .embed(|e| embed::user_embed(&v, e)) + }), + None => m.reply(&ctx, "User not found"), + }?; + + Ok(()) +} + +#[command] +#[description = "Link your Codeforces account to the Discord account, to enjoy Youmu's tracking capabilities."] +#[usage = "[handle]"] +#[num_args(1)] +pub fn save(ctx: &mut Context, m: &Message, mut args: Args) -> CommandResult { + let handle = args.single::()?; + let http = ctx.data.get_cloned::(); + + let account = codeforces::User::info(&http, &[&handle[..]])? + .into_iter() + .next(); + + match account { + None => { + m.reply(&ctx, "cannot find an account with such handle")?; + } + Some(acc) => { + let db = CfSavedUsers::open(&*ctx.data.read()); + let mut db = db.borrow_mut()?; + m.reply( + &ctx, + format!("account `{}` has been linked to your account.", &acc.handle), + )?; + db.insert(m.author.id, acc.into()); + } + } + + Ok(()) +} + +#[command] +#[description = "See the leaderboard of all people in the server."] +#[only_in(guilds)] +#[num_args(0)] +pub fn ranks(ctx: &mut Context, m: &Message) -> CommandResult { + let everyone = { + let db = CfSavedUsers::open(&*ctx.data.read()); + let db = db.borrow()?; + db.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }; + let guild = m.guild_id.expect("Guild-only command"); + let mut ranks = everyone + .into_iter() + .filter_map(|(id, cf_user)| guild.member(&ctx, id).ok().map(|mem| (mem, cf_user))) + .collect::>(); + ranks.sort_by(|(_, a), (_, b)| b.rating.unwrap_or(-1).cmp(&a.rating.unwrap_or(-1))); + + if ranks.is_empty() { + m.reply(&ctx, "No saved users in this server.")?; + return Ok(()); + } + + const ITEMS_PER_PAGE: usize = 10; + let total_pages = (ranks.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; + let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap(); + + ctx.data.get_cloned::().paginate_fn( + ctx.clone(), + m.channel_id, + |page, e| { + let page = page as usize; + let start = ITEMS_PER_PAGE * page; + let end = ranks.len().min(start + ITEMS_PER_PAGE); + if start >= end { + return (e, Err(Error::from("No more pages"))); + } + let ranks = &ranks[start..end]; + + let handle_width = ranks.iter().map(|(_, cfu)| cfu.handle.len()).max().unwrap(); + let username_width = ranks + .iter() + .map(|(mem, _)| mem.distinct().len()) + .max() + .unwrap(); + + let mut m = MessageBuilder::new(); + m.push_line("```"); + + // Table header + m.push_line(format!( + "Rank | Rating | {:hw$} | {:uw$}", + "Handle", + "Username", + hw = handle_width, + uw = username_width + )); + m.push_line(format!( + "----------------{:->hw$}---{:->uw$}", + "", + "", + hw = handle_width, + uw = username_width + )); + + for (id, (mem, cfu)) in ranks.iter().enumerate() { + let id = id + start + 1; + m.push_line(format!( + "{:>4} | {:>6} | {:hw$} | {:uw$}", + format!("#{}", id), + cfu.rating + .map(|v| v.to_string()) + .unwrap_or("----".to_owned()), + cfu.handle, + mem.distinct(), + hw = handle_width, + uw = username_width + )); + } + + m.push_line("```"); + m.push(format!( + "Page **{}/{}**. Last updated **{}**", + page + 1, + total_pages, + last_updated.to_rfc2822() + )); + + (e.content(m.build()), Ok(())) + }, + std::time::Duration::from_secs(60), + )?; + + Ok(()) +} diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 1aed3fd..997d5f1 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -179,39 +179,24 @@ impl FromStr for ModeArg { } } -enum UsernameArg { - Tagged(UserId), - Raw(String), +fn to_user_id_query( + s: Option, + data: &ShareMap, + msg: &Message, +) -> Result { + let id = match s { + Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)), + Some(UsernameArg::Tagged(r)) => r, + None => msg.author.id, + }; + + let db = OsuSavedUsers::open(data); + let db = db.borrow()?; + db.get(&id) + .cloned() + .map(|u| UserID::ID(u.id)) + .ok_or(Error::from("No saved account found")) } - -impl UsernameArg { - fn to_user_id_query(s: Option, data: &ShareMap, msg: &Message) -> Result { - let id = match s { - Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)), - Some(UsernameArg::Tagged(r)) => r, - None => msg.author.id, - }; - - let db = OsuSavedUsers::open(data); - let db = db.borrow()?; - db.get(&id) - .cloned() - .map(|u| UserID::ID(u.id)) - .ok_or(Error::from("No saved account found")) - } -} - -impl FromStr for UsernameArg { - type Err = String; - fn from_str(s: &str) -> Result { - match s.parse::() { - Ok(v) => Ok(UsernameArg::Tagged(v)), - Err(_) if !s.is_empty() => Ok(UsernameArg::Raw(s.to_owned())), - Err(_) => Err("username arg cannot be empty".to_owned()), - } - } -} - struct Nth(u8); impl FromStr for Nth { @@ -234,8 +219,7 @@ impl FromStr for Nth { pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { let nth = args.single::().unwrap_or(Nth(1)).0.min(50).max(1); let mode = args.single::().unwrap_or(ModeArg(Mode::Std)).0; - let user = - UsernameArg::to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; + let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); let user = osu @@ -307,11 +291,7 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult Some(bm) => { let b = &bm.0; let m = bm.1; - let user = UsernameArg::to_user_id_query( - args.single::().ok(), - &*ctx.data.read(), - msg, - )?; + let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); @@ -347,8 +327,7 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { .map(|ModeArg(t)| t) .unwrap_or(Mode::Std); - let user = - UsernameArg::to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; + let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); let user = osu @@ -386,8 +365,7 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { } fn get_user(ctx: &mut Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult { - let user = - UsernameArg::to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; + let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); let user = osu.user(user, |f| f.mode(mode))?; match user { diff --git a/youmubot-prelude/src/args.rs b/youmubot-prelude/src/args.rs index d2e39b0..b6b96f9 100644 --- a/youmubot-prelude/src/args.rs +++ b/youmubot-prelude/src/args.rs @@ -1,4 +1,5 @@ pub use duration::Duration; +pub use username_arg::UsernameArg; mod duration { use std::fmt; @@ -169,3 +170,31 @@ mod duration { } } } + +mod username_arg { + use serenity::model::id::UserId; + use std::str::FromStr; + /// An argument that can be either a tagged user, or a raw string. + pub enum UsernameArg { + Tagged(UserId), + Raw(String), + } + + impl FromStr for UsernameArg { + type Err = String; + fn from_str(s: &str) -> Result { + match s.parse::() { + Ok(v) => Ok(UsernameArg::Tagged(v)), + Err(_) if !s.is_empty() => Ok(UsernameArg::Raw(s.to_owned())), + Err(_) => Err("username arg cannot be empty".to_owned()), + } + } + } + + impl UsernameArg { + /// Mention yourself. + pub fn mention>(v: T) -> Self { + Self::Tagged(v.into()) + } + } +} diff --git a/youmubot-prelude/src/lib.rs b/youmubot-prelude/src/lib.rs index 8a9669a..4b72eac 100644 --- a/youmubot-prelude/src/lib.rs +++ b/youmubot-prelude/src/lib.rs @@ -8,7 +8,7 @@ pub mod reaction_watch; pub mod setup; pub use announcer::{Announcer, AnnouncerHandler}; -pub use args::Duration; +pub use args::{Duration, UsernameArg}; pub use pagination::Pagination; pub use reaction_watch::{ReactionHandler, ReactionWatcher}; diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index e41944d..77ed411 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -117,6 +117,12 @@ impl PaginationHandler { } } +impl Drop for PaginationHandler { + fn drop(&mut self) { + self.message.react(&self.ctx, "🛑").ok(); + } +} + impl ReactionHandler for PaginationHandler { fn handle_reaction(&mut self, reaction: &Reaction, _is_add: bool) -> CommandResult { if reaction.message_id != self.message.id { diff --git a/youmubot/Cargo.toml b/youmubot/Cargo.toml index 5d5469a..c20d200 100644 --- a/youmubot/Cargo.toml +++ b/youmubot/Cargo.toml @@ -6,9 +6,10 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["core", "osu"] +default = ["core", "osu", "codeforces"] core = [] osu = ["youmubot-osu"] +codeforces = ["youmubot-cf"] [dependencies] serenity = "0.8" @@ -17,4 +18,5 @@ youmubot-db = { path = "../youmubot-db" } youmubot-prelude = { path = "../youmubot-prelude" } youmubot-core = { path = "../youmubot-core" } youmubot-osu = { path = "../youmubot-osu", optional = true } +youmubot-cf = { path = "../youmubot-cf", optional = true } diff --git a/youmubot/src/main.rs b/youmubot/src/main.rs index 13c6cde..307b490 100644 --- a/youmubot/src/main.rs +++ b/youmubot/src/main.rs @@ -51,6 +51,8 @@ fn main() { // Set up hooks #[cfg(feature = "osu")] handler.hooks.push(youmubot_osu::discord::hook); + #[cfg(feature = "codeforces")] + handler.hooks.push(youmubot_cf::codeforces_info_hook); // Sets up a client let mut client = { @@ -83,12 +85,17 @@ fn main() { #[cfg(feature = "osu")] youmubot_osu::discord::setup(&db_path, &mut data, &mut announcers) .expect("osu! is initialized"); + // codeforces + #[cfg(feature = "codeforces")] + youmubot_cf::setup(&db_path, &mut data, &mut announcers); } #[cfg(feature = "core")] println!("Core enabled."); #[cfg(feature = "osu")] println!("osu! enabled."); + #[cfg(feature = "codeforces")] + println!("codeforces enabled."); client.with_framework(fw); announcers.scan(std::time::Duration::from_secs(300)); @@ -175,5 +182,7 @@ fn setup_framework(client: &Client) -> StandardFramework { .group(&youmubot_core::COMMUNITY_GROUP); #[cfg(feature = "osu")] let fw = fw.group(&youmubot_osu::discord::OSU_GROUP); + #[cfg(feature = "codeforces")] + let fw = fw.group(&youmubot_cf::CODEFORCES_GROUP); fw }