Merge branch 'master' into codeforces

This commit is contained in:
Natsu Kagami 2020-02-09 17:37:10 -05:00
commit 3998617f97
Signed by: nki
GPG key ID: 73376E117CD20735
8 changed files with 444 additions and 127 deletions

1
Cargo.lock generated
View file

@ -1777,6 +1777,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",

121
youmubot-cf/src/live.rs Normal file
View file

@ -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: <HTTPClient as TypeMapKey>::Value,
members: &[(Member, &str)],
) -> Vec<String> {
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<Change> {
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)
}
}
}

View file

@ -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<ChannelId> + Sync,
) -> CommandResult {
let osu = d.get_cloned::<OsuClient>();
// 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<CacheAndHttp>, d: AppData, channels: MemberToChannels) -> CommandResult {
let osu = d.get_cloned::<OsuClient>();
// 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<Vec<(u8, Score)>, 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<Vec<(u8, Score)>, 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)
}

View file

@ -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(())
}

View file

@ -11,3 +11,4 @@ serenity = "0.8"
youmubot-db = { path = "../youmubot-db" }
crossbeam-channel = "0.4"
reqwest = "0.10"
rayon = "1"

View file

@ -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<HashMap<String, HashMap<GuildId, ChannelId>>>;
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<CacheAndHttp>,
d: AppData,
channels: impl Fn(UserId) -> Vec<ChannelId> + 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<T> Announcer for T
where
T: FnMut(Arc<CacheAndHttp>, AppData, MemberToChannels) -> CommandResult + Send,
{
fn updates(
&mut self,
c: Arc<CacheAndHttp>,
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<UserId>,
) -> Vec<ChannelId> {
let u = u.into();
self.0
.par_iter()
.filter_map(|(guild, channel)| {
guild.member(http.clone(), u).ok().map(|_| channel.clone())
})
.collect::<Vec<_>>()
}
}
/// The announcer handler.
///
/// This struct manages the list of all Announcers, firing them in a certain interval.
pub struct AnnouncerHandler {
cache_http: Arc<CacheAndHttp>,
data: AppData,
announcers: HashMap<&'static str, Box<dyn Announcer>>,
}
// 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<Vec<(GuildId, ChannelId)>, 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<Vec<(GuildId, ChannelId)>, 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<Http>, 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::<Vec<_>>();
self.data.write().insert::<Self>(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::<String>()?;
let keys = ctx.data.get_cloned::<AnnouncerHandler>();
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::<String>()?;
let keys = ctx.data.get_cloned::<AnnouncerHandler>();
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;

View file

@ -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, UsernameArg};
pub use pagination::Pagination;
pub use reaction_watch::{ReactionHandler, ReactionWatcher};
@ -50,3 +50,28 @@ impl GetCloned for AppData {
self.read().get::<T>().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(())
}
}

View file

@ -65,6 +65,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();
@ -80,7 +83,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");
// codeforces
#[cfg(feature = "codeforces")]
youmubot_cf::setup(&db_path, &mut data);
@ -94,6 +98,7 @@ fn main() {
println!("codeforces enabled.");
client.with_framework(fw);
announcers.scan(std::time::Duration::from_secs(300));
println!("Starting...");
if let Err(v) = client.start() {
@ -167,7 +172,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