mirror of
https://github.com/natsukagami/youmubot.git
synced 2025-04-18 16:28:55 +00:00
Merge branch 'codeforces' so far
This commit is contained in:
commit
4b0c7bf4b4
14 changed files with 721 additions and 45 deletions
39
Cargo.lock
generated
39
Cargo.lock
generated
|
@ -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)" = "<none>"
|
||||
"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"
|
||||
|
|
|
@ -4,6 +4,7 @@ members = [
|
|||
"youmubot-prelude",
|
||||
"youmubot-db",
|
||||
"youmubot-core",
|
||||
"youmubot-cf",
|
||||
"youmubot-osu",
|
||||
"youmubot",
|
||||
]
|
||||
|
|
20
youmubot-cf/Cargo.toml
Normal file
20
youmubot-cf/Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "youmubot-cf"
|
||||
version = "0.1.0"
|
||||
authors = ["Natsu Kagami <natsukagami@gmail.com>"]
|
||||
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" }
|
96
youmubot-cf/src/announcer.rs
Normal file
96
youmubot-cf/src/announcer.rs
Normal file
|
@ -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 = <HTTPClient as TypeMapKey>::Value;
|
||||
|
||||
/// Updates the rating and rating changes of the users.
|
||||
pub fn updates(
|
||||
http: Arc<CacheAndHttp>,
|
||||
data: AppData,
|
||||
channels: MemberToChannels,
|
||||
) -> CommandResult {
|
||||
let mut users = CfSavedUsers::open(&*data.read()).borrow()?.clone();
|
||||
let reqwest = data.get_cloned::<HTTPClient>();
|
||||
|
||||
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<CacheAndHttp>,
|
||||
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<Vec<ChannelId>> = 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<Utc> = 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(())
|
||||
}
|
37
youmubot-cf/src/db.rs
Normal file
37
youmubot-cf/src/db.rs
Normal file
|
@ -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<HashMap<UserId, CfUser>>;
|
||||
|
||||
/// A saved Codeforces user.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct CfUser {
|
||||
pub handle: String,
|
||||
pub last_update: DateTime<Utc>,
|
||||
pub rating: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for CfUser {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
handle: "".to_owned(),
|
||||
last_update: Utc::now(),
|
||||
rating: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<User> for CfUser {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
handle: u.handle,
|
||||
last_update: Utc::now(),
|
||||
rating: u.rating,
|
||||
}
|
||||
}
|
||||
}
|
107
youmubot-cf/src/embed.rs
Normal file
107
youmubot-cf/src/embed.rs
Normal file
|
@ -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<T>>(opt: &'a Option<B>, 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::<Vec<_>>()
|
||||
.join(" ");
|
||||
let place = &[&user.organization, &user.city, &user.country]
|
||||
.iter()
|
||||
.filter_map(|v| v.as_ref().map(|v| v.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.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,
|
||||
)
|
||||
}
|
145
youmubot-cf/src/hook.rs
Normal file
145
youmubot-cf/src/hook.rs
Normal file
|
@ -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<contest>\d+)(?:/problem/(?P<problem>\w+))?"
|
||||
)
|
||||
.unwrap();
|
||||
static ref PROBLEMSET_LINK: Regex = Regex::new(
|
||||
r"https?://codeforces\.com/problemset/problem/(?P<contest>\d+)/(?P<problem>\w+)"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
enum ContestOrProblem {
|
||||
Contest(Contest, Vec<Problem>),
|
||||
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::<HTTPClient>();
|
||||
let matches = CONTEST_LINK
|
||||
.captures_iter(&m.content)
|
||||
.chain(PROBLEMSET_LINK.captures_iter(&m.content))
|
||||
// .collect::<Vec<_>>()
|
||||
// .into_par_iter()
|
||||
.filter_map(|v| match parse_capture(http.clone(), v) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
dbg!(e);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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: <HTTPClient as TypeMapKey>::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(),
|
||||
)),
|
||||
}
|
||||
}
|
207
youmubot-cf/src/lib.rs
Normal file
207
youmubot-cf/src/lib.rs
Normal file
|
@ -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::<UsernameArg>()
|
||||
.unwrap_or(UsernameArg::mention(m.author.id));
|
||||
let http = ctx.data.get_cloned::<HTTPClient>();
|
||||
|
||||
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::<String>()?;
|
||||
let http = ctx.data.get_cloned::<HTTPClient>();
|
||||
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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::<Vec<_>>();
|
||||
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::<ReactionWatcher>().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(())
|
||||
}
|
|
@ -179,13 +179,11 @@ impl FromStr for ModeArg {
|
|||
}
|
||||
}
|
||||
|
||||
enum UsernameArg {
|
||||
Tagged(UserId),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
impl UsernameArg {
|
||||
fn to_user_id_query(s: Option<Self>, data: &ShareMap, msg: &Message) -> Result<UserID, Error> {
|
||||
fn to_user_id_query(
|
||||
s: Option<UsernameArg>,
|
||||
data: &ShareMap,
|
||||
msg: &Message,
|
||||
) -> Result<UserID, Error> {
|
||||
let id = match s {
|
||||
Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)),
|
||||
Some(UsernameArg::Tagged(r)) => r,
|
||||
|
@ -198,20 +196,7 @@ impl UsernameArg {
|
|||
.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<Self, Self::Err> {
|
||||
match s.parse::<UserId>() {
|
||||
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::<Nth>().unwrap_or(Nth(1)).0.min(50).max(1);
|
||||
let mode = args.single::<ModeArg>().unwrap_or(ModeArg(Mode::Std)).0;
|
||||
let user =
|
||||
UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
|
||||
let osu = ctx.data.get_cloned::<OsuClient>();
|
||||
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::<UsernameArg>().ok(),
|
||||
&*ctx.data.read(),
|
||||
msg,
|
||||
)?;
|
||||
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
|
||||
let osu = ctx.data.get_cloned::<OsuClient>();
|
||||
|
||||
|
@ -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::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
|
||||
let osu = ctx.data.get_cloned::<OsuClient>();
|
||||
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::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
|
||||
let osu = ctx.data.get_cloned::<OsuClient>();
|
||||
let user = osu.user(user, |f| f.mode(mode))?;
|
||||
match user {
|
||||
|
|
|
@ -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<Self, Self::Err> {
|
||||
match s.parse::<UserId>() {
|
||||
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<T: Into<UserId>>(v: T) -> Self {
|
||||
Self::Tagged(v.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
|
@ -117,6 +117,12 @@ impl<T: Pagination> PaginationHandler<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T: Pagination> Drop for PaginationHandler<T> {
|
||||
fn drop(&mut self) {
|
||||
self.message.react(&self.ctx, "🛑").ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Pagination> ReactionHandler for PaginationHandler<T> {
|
||||
fn handle_reaction(&mut self, reaction: &Reaction, _is_add: bool) -> CommandResult {
|
||||
if reaction.message_id != self.message.id {
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue