Merge branch 'split-packages'

This commit is contained in:
Natsu Kagami 2020-02-05 18:03:57 -05:00
commit aea5c5dfcb
Signed by: nki
GPG key ID: 73376E117CD20735
32 changed files with 543 additions and 423 deletions

47
Cargo.lock generated
View file

@ -1677,18 +1677,37 @@ dependencies = [
name = "youmubot" name = "youmubot"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",
"dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", "dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "serenity 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"youmubot-core 0.1.0",
"youmubot-db 0.1.0",
"youmubot-osu 0.1.0",
"youmubot-prelude 0.1.0",
]
[[package]]
name = "youmubot-core"
version = "0.1.0"
dependencies = [
"chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",
"rand 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)", "rand 0.7.3 (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)",
"rustbreak 2.0.0-rc3 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.104 (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)", "serenity 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"static_assertions 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "static_assertions 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"youmubot-osu 0.1.0", "youmubot-db 0.1.0",
"youmubot-prelude 0.1.0",
]
[[package]]
name = "youmubot-db"
version = "0.1.0"
dependencies = [
"chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",
"dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rayon 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rustbreak 2.0.0-rc3 (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)",
] ]
[[package]] [[package]]
@ -1697,10 +1716,24 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",
"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)", "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 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)", "serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)",
"serenity 0.8.0 (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-prelude"
version = "0.1.0"
dependencies = [
"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",
] ]
[metadata] [metadata]

View file

@ -1,6 +1,9 @@
[workspace] [workspace]
members = [ members = [
"youmubot-prelude",
"youmubot-db",
"youmubot-core",
"youmubot-osu", "youmubot-osu",
"youmubot", "youmubot",
] ]

17
README.md Normal file
View file

@ -0,0 +1,17 @@
# youmubot
A Discord bot made specifically for server "Dự tuyển Tổng Hợp". Written in Rust.
All PRs welcome.
## Project structure
- `youmubot`: The main command. Collect configurations and dispatch commands.
- `youmubot-prelude`: Base structures and handy functions for command parsing / service handling.
- `youmubot-db`: Base database structures.
- `youmubot-core`: Core commands: admin, fun, community
- `youmubot-osu`: osu!-related commands.
## License
Basically MIT.

17
youmubot-core/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "youmubot-core"
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]
serenity = "0.8"
rand = "0.7"
serde = { version = "1", features = ["derive"] }
chrono = "0.4"
static_assertions = "1.1"
youmubot-db = { path = "../youmubot-db" }
youmubot-prelude = { path = "../youmubot-prelude" }

View file

@ -1,4 +1,3 @@
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
@ -11,6 +10,7 @@ use serenity::{
}; };
use soft_ban::{SOFT_BAN_COMMAND, SOFT_BAN_INIT_COMMAND}; use soft_ban::{SOFT_BAN_COMMAND, SOFT_BAN_INIT_COMMAND};
use std::{thread::sleep, time::Duration}; use std::{thread::sleep, time::Duration};
use youmubot_prelude::*;
mod soft_ban; mod soft_ban;
pub use soft_ban::watch_soft_bans; pub use soft_ban::watch_soft_bans;

View file

@ -1,9 +1,5 @@
use crate::{ use crate::db::{ServerSoftBans, SoftBans};
commands::args,
db::{DBWriteGuard, ServerSoftBans, SoftBans},
};
use chrono::offset::Utc; use chrono::offset::Utc;
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{macros::command, Args, CommandError as Error, CommandResult}, framework::standard::{macros::command, Args, CommandError as Error, CommandResult},
model::{ model::{
@ -12,6 +8,7 @@ use serenity::{
}, },
}; };
use std::cmp::max; use std::cmp::max;
use youmubot_prelude::*;
#[command] #[command]
#[required_permissions(ADMINISTRATOR)] #[required_permissions(ADMINISTRATOR)]
@ -33,13 +30,9 @@ pub fn soft_ban(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResu
}; };
let guild = msg.guild_id.ok_or(Error::from("Command is guild only"))?; let guild = msg.guild_id.ok_or(Error::from("Command is guild only"))?;
let mut data = ctx.data.write(); let db = SoftBans::open(&*ctx.data.read());
let mut data = data let mut db = db.borrow_mut()?;
.get_mut::<SoftBans>() let mut server_ban = db.get_mut(&guild).and_then(|v| match v {
.ok_or(Error::from("DB initialized"))
.map(|v| DBWriteGuard::from(v))?;
let mut data = data.borrow_mut()?;
let mut server_ban = data.get_mut(&guild).and_then(|v| match v {
ServerSoftBans::Unimplemented => None, ServerSoftBans::Unimplemented => None,
ServerSoftBans::Implemented(ref mut v) => Some(v), ServerSoftBans::Implemented(ref mut v) => Some(v),
}); });
@ -98,14 +91,10 @@ pub fn soft_ban_init(ctx: &mut Context, msg: &Message, mut args: Args) -> Comman
))); )));
} }
// Check if we already set up // Check if we already set up
let mut data = ctx.data.write(); let db = SoftBans::open(&*ctx.data.read());
let mut db: DBWriteGuard<_> = data
.get_mut::<SoftBans>()
.ok_or(Error::from("DB uninitialized"))?
.into();
let mut db = db.borrow_mut()?; let mut db = db.borrow_mut()?;
let server = db let server = db
.get_mut(&guild.id) .get(&guild.id)
.map(|v| match v { .map(|v| match v {
ServerSoftBans::Unimplemented => false, ServerSoftBans::Unimplemented => false,
_ => true, _ => true,
@ -122,7 +111,7 @@ pub fn soft_ban_init(ctx: &mut Context, msg: &Message, mut args: Args) -> Comman
} }
// Watch the soft bans. // Watch the soft bans.
pub fn watch_soft_bans(client: &mut serenity::Client) -> impl FnOnce() -> () + 'static { pub fn watch_soft_bans(client: &serenity::Client) -> impl FnOnce() -> () + 'static {
let cache_http = { let cache_http = {
let cache_http = client.cache_and_http.clone(); let cache_http = client.cache_and_http.clone();
let cache: serenity::cache::CacheRwLock = cache_http.cache.clone().into(); let cache: serenity::cache::CacheRwLock = cache_http.cache.clone().into();
@ -135,12 +124,9 @@ pub fn watch_soft_bans(client: &mut serenity::Client) -> impl FnOnce() -> () + '
// Scope so that locks are released // Scope so that locks are released
{ {
// Poll the data for any changes. // Poll the data for any changes.
let mut data = data.write(); let db = data.read();
let mut db: DBWriteGuard<_> = data let db = SoftBans::open(&*db);
.get_mut::<SoftBans>() let mut db = db.borrow_mut().expect("Borrowable");
.expect("DB wrongly initialized")
.into();
let mut db = db.borrow_mut().expect("cannot unpack DB");
let now = Utc::now(); let now = Utc::now();
for (server_id, soft_bans) in db.iter_mut() { for (server_id, soft_bans) in db.iter_mut() {
let server_name: String = match server_id.to_partial_guild(cache_http) { let server_name: String = match server_id.to_partial_guild(cache_http) {

View file

@ -2,7 +2,6 @@ use rand::{
distributions::{Distribution, Uniform}, distributions::{Distribution, Uniform},
thread_rng, thread_rng,
}; };
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
@ -14,6 +13,7 @@ use serenity::{
}, },
utils::MessageBuilder, utils::MessageBuilder,
}; };
use youmubot_prelude::*;
mod votes; mod votes;

View file

@ -1,6 +1,4 @@
use crate::commands::args::Duration as ParseDuration;
use serenity::framework::standard::CommandError as Error; use serenity::framework::standard::CommandError as Error;
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{macros::command, Args, CommandResult}, framework::standard::{macros::command, Args, CommandResult},
model::{ model::{
@ -12,6 +10,7 @@ use serenity::{
use std::collections::HashMap as Map; use std::collections::HashMap as Map;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use youmubot_prelude::{Duration as ParseDuration, *};
#[command] #[command]
#[description = "🎌 Cast a poll upon everyone and ask them for opinions!"] #[description = "🎌 Cast a poll upon everyone and ask them for opinions!"]

38
youmubot-core/src/db.rs Normal file
View file

@ -0,0 +1,38 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serenity::{
model::id::{RoleId, UserId},
};
use std::collections::HashMap;
use youmubot_db::{GuildMap, DB};
/// A list of SoftBans for all servers.
pub type SoftBans = DB<GuildMap<ServerSoftBans>>;
/// For the admin commands:
/// - Each server might have a `soft ban` role implemented.
/// - We allow periodical `soft ban` applications.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ServerSoftBans {
Implemented(ImplementedSoftBans),
Unimplemented,
}
impl ServerSoftBans {
// Create a new, implemented role.
pub fn new_implemented(role: RoleId) -> ServerSoftBans {
ServerSoftBans::Implemented(ImplementedSoftBans {
role,
periodical_bans: HashMap::new(),
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImplementedSoftBans {
/// The soft-ban role.
pub role: RoleId,
/// List of all to-unban people.
pub periodical_bans: HashMap<UserId, DateTime<Utc>>,
}

View file

@ -1,8 +1,5 @@
use crate::http::HTTP;
use reqwest::blocking::Client as HTTPClient;
use serde::Deserialize; use serde::Deserialize;
use serenity::framework::standard::CommandError as Error; use serenity::framework::standard::CommandError as Error;
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
macros::{check, command}, macros::{check, command},
@ -11,6 +8,7 @@ use serenity::{
model::channel::{Channel, Message}, model::channel::{Channel, Message},
}; };
use std::string::ToString; use std::string::ToString;
use youmubot_prelude::*;
#[command] #[command]
#[checks(nsfw)] #[checks(nsfw)]
@ -45,9 +43,8 @@ fn nsfw_check(ctx: &mut Context, msg: &Message, _: &mut Args, _: &CommandOptions
fn message_command(ctx: &mut Context, msg: &Message, args: Args, rating: Rating) -> CommandResult { fn message_command(ctx: &mut Context, msg: &Message, args: Args, rating: Rating) -> CommandResult {
let tags = args.remains().unwrap_or("touhou"); let tags = args.remains().unwrap_or("touhou");
let http = ctx.data.read(); let http = ctx.data.get_cloned::<HTTPClient>();
let http = http.get::<HTTP>().unwrap(); let image = get_image(&http, rating, tags)?;
let image = get_image(http, rating, tags)?;
match image { match image {
None => msg.reply(&ctx, "🖼️ No image found...\n💡 Tip: In danbooru, character names follow Japanese standards (last name before first name), so **Hakurei Reimu** might give you an image while **Reimu Hakurei** won't."), None => msg.reply(&ctx, "🖼️ No image found...\n💡 Tip: In danbooru, character names follow Japanese standards (last name before first name), so **Hakurei Reimu** might give you an image while **Reimu Hakurei** won't."),
Some(url) => msg.reply( Some(url) => msg.reply(
@ -59,7 +56,11 @@ fn message_command(ctx: &mut Context, msg: &Message, args: Args, rating: Rating)
} }
// Gets an image URL. // Gets an image URL.
fn get_image(client: &HTTPClient, rating: Rating, tags: &str) -> Result<Option<String>, Error> { fn get_image(
client: &<HTTPClient as TypeMapKey>::Value,
rating: Rating,
tags: &str,
) -> Result<Option<String>, Error> {
// Fix the tags: change whitespaces to + // Fix the tags: change whitespaces to +
let tags = tags.split_whitespace().collect::<Vec<_>>().join("_"); let tags = tags.split_whitespace().collect::<Vec<_>>().join("_");
let req = client let req = client

View file

@ -2,7 +2,6 @@ use rand::{
distributions::{Distribution, Uniform}, distributions::{Distribution, Uniform},
thread_rng, thread_rng,
}; };
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
@ -11,6 +10,7 @@ use serenity::{
model::{channel::Message, id::UserId}, model::{channel::Message, id::UserId},
utils::MessageBuilder, utils::MessageBuilder,
}; };
use youmubot_prelude::*;
mod images; mod images;
mod names; mod names;

View file

@ -1,4 +1,3 @@
use serenity::prelude::*;
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
help_commands, macros::help, Args, CommandGroup, CommandResult, HelpOptions, help_commands, macros::help, Args, CommandGroup, CommandResult, HelpOptions,
@ -6,21 +5,30 @@ use serenity::{
model::{channel::Message, id::UserId}, model::{channel::Message, id::UserId},
}; };
use std::collections::HashSet; use std::collections::HashSet;
use youmubot_prelude::*;
mod announcer;
mod args;
pub mod admin; pub mod admin;
pub mod community; pub mod community;
mod db;
pub mod fun; pub mod fun;
pub mod osu;
pub use admin::ADMIN_GROUP; pub use admin::ADMIN_GROUP;
pub use community::COMMUNITY_GROUP; pub use community::COMMUNITY_GROUP;
pub use fun::FUN_GROUP; pub use fun::FUN_GROUP;
pub use osu::OSU_GROUP;
pub use announcer::Announcer; /// Sets up all databases in the client.
pub fn setup(
path: &std::path::Path,
client: &serenity::client::Client,
data: &mut youmubot_prelude::ShareMap,
) -> serenity::framework::standard::CommandResult {
db::SoftBans::insert_into(&mut *data, &path.join("soft_bans.yaml"))?;
// Create handler threads
std::thread::spawn(admin::watch_soft_bans(client));
Ok(())
}
// A help command // A help command
#[help] #[help]

24
youmubot-db/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "youmubot-db"
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]
serenity = "0.8"
dotenv = "0.15"
serde = { version = "1.0", features = ["derive"] }
chrono = "0.4.9"
# rand = "0.7.2"
# static_assertions = "1.1.0"
# reqwest = "0.10.1"
# regex = "1"
# lazy_static = "1"
# youmubot-osu = { path = "../youmubot-osu" }
rayon = "1.1"
[dependencies.rustbreak]
version = "2.0.0-rc3"
features = ["yaml_enc"]

66
youmubot-db/src/lib.rs Normal file
View file

@ -0,0 +1,66 @@
use rustbreak::{deser::Yaml as Ron, FileDatabase};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serenity::{framework::standard::CommandError as Error, model::id::GuildId, prelude::*};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
/// GuildMap defines the guild-map type.
/// It is basically a HashMap from a GuildId to a data structure.
pub type GuildMap<V> = HashMap<GuildId, V>;
/// The generic DB type we will be using.
pub struct DB<T>(std::marker::PhantomData<T>);
impl<T: std::any::Any> serenity::prelude::TypeMapKey for DB<T> {
type Value = Arc<FileDatabase<T, Ron>>;
}
impl<T: std::any::Any + Default + Send + Sync + Clone + Serialize + std::fmt::Debug> DB<T>
where
for<'de> T: Deserialize<'de>,
{
/// Insert into a ShareMap.
pub fn insert_into(data: &mut ShareMap, path: impl AsRef<Path>) -> Result<(), Error> {
let db = FileDatabase::<T, Ron>::from_path(path, T::default())?;
db.load().or_else(|e| {
dbg!(e);
db.save()
})?;
data.insert::<DB<T>>(Arc::new(db));
Ok(())
}
/// Open a previously inserted DB.
pub fn open(data: &ShareMap) -> DBWriteGuard<T> {
data.get::<Self>().expect("DB initialized").clone().into()
}
}
/// The write guard for our FileDatabase.
/// It wraps the FileDatabase in a write-on-drop lock.
#[derive(Debug)]
pub struct DBWriteGuard<T>(Arc<FileDatabase<T, Ron>>)
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned;
impl<T> From<Arc<FileDatabase<T, Ron>>> for DBWriteGuard<T>
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned,
{
fn from(v: Arc<FileDatabase<T, Ron>>) -> Self {
DBWriteGuard(v)
}
}
impl<T> DBWriteGuard<T>
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned,
{
/// Borrows the FileDatabase.
pub fn borrow(&self) -> Result<std::sync::RwLockReadGuard<T>, rustbreak::RustbreakError> {
(*self).0.borrow_data()
}
/// Borrows the FileDatabase for writing.
pub fn borrow_mut(&self) -> Result<std::sync::RwLockWriteGuard<T>, rustbreak::RustbreakError> {
(*self).0.borrow_data_mut()
}
}

View file

@ -12,6 +12,12 @@ chrono = "0.4.10"
reqwest = "0.10.1" reqwest = "0.10.1"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
bitflags = "1" bitflags = "1"
rayon = "1.1"
lazy_static = "1"
regex = "1"
youmubot-db = { path = "../youmubot-db" }
youmubot-prelude = { path = "../youmubot-prelude" }
[dev-dependencies] [dev-dependencies]
serde_json = "1" serde_json = "1"

View file

@ -1,25 +1,17 @@
use super::{embeds::score_embed, BeatmapWithMode}; use super::db::{OsuSavedUsers, OsuUser};
use super::{embeds::score_embed, BeatmapWithMode, OsuClient};
use crate::{ use crate::{
commands::announcer::Announcer, models::{Mode, Score},
db::{OsuSavedUsers, OsuUser}, request::{BeatmapRequestKind, UserID},
http::{Osu, HTTP}, Client as Osu,
}; };
use rayon::prelude::*; use rayon::prelude::*;
use reqwest::blocking::Client as HTTPClient;
use serenity::{ use serenity::{
framework::standard::{CommandError as Error, CommandResult}, framework::standard::{CommandError as Error, CommandResult},
http::Http, http::Http,
model::{ model::id::{ChannelId, UserId},
id::{ChannelId, UserId},
misc::Mentionable,
},
prelude::ShareMap,
};
use youmubot_osu::{
models::{Mode, Score},
request::{BeatmapRequestKind, UserID},
Client as OsuClient,
}; };
use youmubot_prelude::*;
/// Announce osu! top scores. /// Announce osu! top scores.
pub struct OsuAnnouncer; pub struct OsuAnnouncer;
@ -30,33 +22,36 @@ impl Announcer for OsuAnnouncer {
} }
fn send_messages( fn send_messages(
c: &Http, c: &Http,
d: &mut ShareMap, d: AppData,
channels: impl Fn(UserId) -> Vec<ChannelId> + Sync, channels: impl Fn(UserId) -> Vec<ChannelId> + Sync,
) -> CommandResult { ) -> CommandResult {
let http = d.get::<HTTP>().expect("HTTP"); let osu = d.get_cloned::<OsuClient>();
let osu = d.get::<Osu>().expect("osu!client");
// For each user... // For each user...
let mut data = d let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone();
.get::<OsuSavedUsers>()
.expect("DB initialized")
.read(|f| f.clone())?;
for (user_id, osu_user) in data.iter_mut() { for (user_id, osu_user) in data.iter_mut() {
let mut user = None; let mut user = None;
for mode in &[Mode::Std, Mode::Taiko, Mode::Mania, Mode::Catch] { for mode in &[Mode::Std, Mode::Taiko, Mode::Mania, Mode::Catch] {
let scores = OsuAnnouncer::scan_user(http, osu, osu_user, *mode)?; let scores = OsuAnnouncer::scan_user(&osu, osu_user, *mode)?;
if scores.is_empty() { if scores.is_empty() {
continue; continue;
} }
let user = user.get_or_insert_with(|| { let user = {
osu.user(http, UserID::ID(osu_user.id), |f| f) let user = &mut user;
.unwrap() if let None = user {
.unwrap() match osu.user(UserID::ID(osu_user.id), |f| f.mode(*mode)) {
}); Ok(u) => {
*user = u;
}
Err(_) => continue,
}
};
user.as_ref().unwrap()
};
scores scores
.into_par_iter() .into_par_iter()
.filter_map(|(rank, score)| { .filter_map(|(rank, score)| {
let beatmap = osu let beatmap = osu
.beatmaps(http, BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f) .beatmaps(BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f)
.map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), *mode)); .map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), *mode));
let channels = channels(*user_id); let channels = channels(*user_id);
match beatmap { match beatmap {
@ -81,21 +76,14 @@ impl Announcer for OsuAnnouncer {
osu_user.last_update = chrono::Utc::now(); osu_user.last_update = chrono::Utc::now();
} }
// Update users // Update users
let f = d.get_mut::<OsuSavedUsers>().expect("DB initialized"); *OsuSavedUsers::open(&*d.read()).borrow_mut()? = data;
f.write(|f| *f = data)?;
f.save()?;
Ok(()) Ok(())
} }
} }
impl OsuAnnouncer { impl OsuAnnouncer {
fn scan_user( fn scan_user(osu: &Osu, u: &OsuUser, mode: Mode) -> Result<Vec<(u8, Score)>, Error> {
http: &HTTPClient, let scores = osu.user_best(UserID::ID(u.id), |f| f.mode(mode).limit(25))?;
osu: &OsuClient,
u: &OsuUser,
mode: Mode,
) -> Result<Vec<(u8, Score)>, Error> {
let scores = osu.user_best(http, UserID::ID(u.id), |f| f.mode(mode).limit(25))?;
let scores = scores let scores = scores
.into_iter() .into_iter()
.filter(|s: &Score| s.date >= u.last_update) .filter(|s: &Score| s.date >= u.last_update)

View file

@ -1,5 +1,5 @@
use super::db::OsuLastBeatmap;
use super::BeatmapWithMode; use super::BeatmapWithMode;
use crate::db::{DBWriteGuard, OsuLastBeatmap};
use serenity::{ use serenity::{
framework::standard::{CommandError as Error, CommandResult}, framework::standard::{CommandError as Error, CommandResult},
model::id::ChannelId, model::id::ChannelId,
@ -8,14 +8,11 @@ use serenity::{
/// Save the beatmap into the server data storage. /// Save the beatmap into the server data storage.
pub(crate) fn save_beatmap( pub(crate) fn save_beatmap(
data: &mut ShareMap, data: &ShareMap,
channel_id: ChannelId, channel_id: ChannelId,
bm: &BeatmapWithMode, bm: &BeatmapWithMode,
) -> CommandResult { ) -> CommandResult {
let mut db: DBWriteGuard<_> = data let db = OsuLastBeatmap::open(data);
.get_mut::<OsuLastBeatmap>()
.expect("DB is implemented")
.into();
let mut db = db.borrow_mut()?; let mut db = db.borrow_mut()?;
db.insert(channel_id, (bm.0.clone(), bm.mode())); db.insert(channel_id, (bm.0.clone(), bm.mode()));
@ -28,8 +25,8 @@ pub(crate) fn get_beatmap(
data: &ShareMap, data: &ShareMap,
channel_id: ChannelId, channel_id: ChannelId,
) -> Result<Option<BeatmapWithMode>, Error> { ) -> Result<Option<BeatmapWithMode>, Error> {
let db = data.get::<OsuLastBeatmap>().expect("DB is implemented"); let db = OsuLastBeatmap::open(data);
let db = db.borrow_data()?; let db = db.borrow()?;
Ok(db Ok(db
.get(&channel_id) .get(&channel_id)

View file

@ -0,0 +1,22 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serenity::{
model::id::{ChannelId, UserId},
};
use std::collections::HashMap;
use youmubot_db::{DB};
use crate::models::{Beatmap, Mode};
/// Save the user IDs.
pub type OsuSavedUsers = DB<HashMap<UserId, OsuUser>>;
/// Save each channel's last requested beatmap.
pub type OsuLastBeatmap = DB<HashMap<ChannelId, (Beatmap, Mode)>>;
/// An osu! saved user.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OsuUser {
pub id: u64,
pub last_update: DateTime<Utc>,
}

View file

@ -1,8 +1,8 @@
use super::BeatmapWithMode; use super::BeatmapWithMode;
use crate::commands::args::Duration; use crate::models::{Beatmap, Mode, Rank, Score, User};
use chrono::Utc; use chrono::Utc;
use serenity::{builder::CreateEmbed, utils::MessageBuilder}; use serenity::{builder::CreateEmbed, utils::MessageBuilder};
use youmubot_osu::models::{Beatmap, Mode, Rank, Score, User}; use youmubot_prelude::*;
fn format_mode(actual: Mode, original: Mode) -> String { fn format_mode(actual: Mode, original: Mode) -> String {
if actual == original { if actual == original {

View file

@ -1,17 +1,17 @@
use crate::http; use super::OsuClient;
use crate::{
models::{Beatmap, Mode},
request::BeatmapRequestKind,
};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use serenity::{ use serenity::{
builder::CreateMessage, builder::CreateMessage,
framework::standard::{CommandError as Error, CommandResult}, framework::standard::{CommandError as Error, CommandResult},
model::channel::Message, model::channel::Message,
prelude::*,
utils::MessageBuilder, utils::MessageBuilder,
}; };
use youmubot_osu::{ use youmubot_prelude::*;
models::{Beatmap, Mode},
request::BeatmapRequestKind,
};
use super::embeds::{beatmap_embed, beatmapset_embed}; use super::embeds::{beatmap_embed, beatmapset_embed};
@ -47,7 +47,7 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () {
} }
// Save the beatmap for query later. // Save the beatmap for query later.
if let Some(t) = last_beatmap { if let Some(t) = last_beatmap {
if let Err(v) = super::cache::save_beatmap(&mut *ctx.data.write(), msg.channel_id, &t) { if let Err(v) = super::cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &t) {
dbg!(v); dbg!(v);
} }
} }
@ -71,9 +71,7 @@ struct ToPrint<'a> {
} }
fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> { fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let data = ctx.data.write(); let osu = ctx.data.get_cloned::<OsuClient>();
let reqwest = data.get::<http::HTTP>().unwrap();
let osu = data.get::<http::Osu>().unwrap();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new(); let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
for capture in OLD_LINK_REGEX.captures_iter(content) { for capture in OLD_LINK_REGEX.captures_iter(content) {
let req_type = capture.name("link_type").unwrap().as_str(); let req_type = capture.name("link_type").unwrap().as_str();
@ -95,7 +93,7 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
_ => return None, _ => return None,
}) })
}); });
let beatmaps = osu.beatmaps(reqwest, req, |v| match mode { let beatmaps = osu.beatmaps(req, |v| match mode {
Some(m) => v.mode(m, true), Some(m) => v.mode(m, true),
None => v, None => v,
})?; })?;
@ -123,9 +121,7 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
} }
fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> { fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let data = ctx.data.write(); let osu = ctx.data.get_cloned::<OsuClient>();
let reqwest = data.get::<http::HTTP>().unwrap();
let osu = data.get::<http::Osu>().unwrap();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new(); let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
for capture in NEW_LINK_REGEX.captures_iter(content) { for capture in NEW_LINK_REGEX.captures_iter(content) {
let mode = capture.name("mode").and_then(|v| { let mode = capture.name("mode").and_then(|v| {
@ -145,7 +141,7 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
BeatmapRequestKind::Beatmapset(capture.name("set_id").unwrap().as_str().parse()?) BeatmapRequestKind::Beatmapset(capture.name("set_id").unwrap().as_str().parse()?)
} }
}; };
let beatmaps = osu.beatmaps(reqwest, req, |v| match mode { let beatmaps = osu.beatmaps(req, |v| match mode {
Some(m) => v.mode(m, true), Some(m) => v.mode(m, true),
None => v, None => v,
})?; })?;

View file

@ -1,30 +1,70 @@
use crate::db::{DBWriteGuard, OsuSavedUsers, OsuUser}; use crate::{
use crate::http; models::{Beatmap, Mode, User},
request::{BeatmapRequestKind, UserID},
Client as OsuHttpClient,
};
use serenity::{ use serenity::{
framework::standard::{ framework::standard::{
macros::{command, group}, macros::{command, group},
Args, CommandError as Error, CommandResult, Args, CommandError as Error, CommandResult,
}, },
model::{channel::Message, id::UserId}, model::{channel::Message, id::UserId},
prelude::*,
utils::MessageBuilder, utils::MessageBuilder,
}; };
use std::str::FromStr; use std::str::FromStr;
use youmubot_osu::{ use youmubot_prelude::*;
models::{Beatmap, Mode, User},
request::{BeatmapRequestKind, UserID},
Client as OsuClient,
};
mod announcer; mod announcer;
mod cache; mod cache;
mod db;
pub(crate) mod embeds; pub(crate) mod embeds;
mod hook; mod hook;
pub use announcer::OsuAnnouncer; pub use announcer::OsuAnnouncer;
use db::OsuUser;
use db::{OsuLastBeatmap, OsuSavedUsers};
use embeds::{beatmap_embed, score_embed, user_embed}; use embeds::{beatmap_embed, score_embed, user_embed};
pub use hook::hook; pub use hook::hook;
/// The osu! client.
pub(crate) struct OsuClient;
impl TypeMapKey for OsuClient {
type Value = OsuHttpClient;
}
/// Sets up the osu! command handling section.
///
/// This automatically enables:
/// - Related databases
/// - An announcer system (that will eventually be revamped)
/// - The osu! API client.
///
/// This does NOT automatically enable:
/// - Commands on the "osu" prefix
/// - Hooks. Hooks are completely opt-in.
///
pub fn setup(
path: &std::path::Path,
client: &serenity::client::Client,
data: &mut ShareMap,
) -> CommandResult {
// Databases
OsuSavedUsers::insert_into(&mut *data, &path.join("osu_saved_users.yaml"))?;
OsuLastBeatmap::insert_into(&mut *data, &path.join("last_beatmaps.yaml"))?;
// API client
let http_client = data.get_cloned::<HTTPClient>();
data.insert::<OsuClient>(OsuHttpClient::new(
http_client,
std::env::var("OSU_API_KEY").expect("Please set OSU_API_KEY as osu! api key."),
));
// Announcer
OsuAnnouncer::scan(&client, std::time::Duration::from_secs(300));
Ok(())
}
#[group] #[group]
#[prefix = "osu"] #[prefix = "osu"]
#[description = "osu! related commands."] #[description = "osu! related commands."]
@ -91,18 +131,13 @@ impl AsRef<Beatmap> for BeatmapWithMode {
#[usage = "[username or user_id]"] #[usage = "[username or user_id]"]
#[num_args(1)] #[num_args(1)]
pub fn save(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { pub fn save(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write(); let osu = ctx.data.get_cloned::<OsuClient>();
let reqwest = data.get::<http::HTTP>().unwrap();
let osu = data.get::<http::Osu>().unwrap();
let user = args.single::<String>()?; let user = args.single::<String>()?;
let user: Option<User> = osu.user(reqwest, UserID::Auto(user), |f| f)?; let user: Option<User> = osu.user(UserID::Auto(user), |f| f)?;
match user { match user {
Some(u) => { Some(u) => {
let mut db: DBWriteGuard<_> = data let db = OsuSavedUsers::open(&*ctx.data.read());
.get_mut::<OsuSavedUsers>()
.ok_or(Error::from("DB uninitialized"))?
.into();
let mut db = db.borrow_mut()?; let mut db = db.borrow_mut()?;
db.insert( db.insert(
@ -148,20 +183,14 @@ enum UsernameArg {
} }
impl UsernameArg { impl UsernameArg {
fn to_user_id_query( fn to_user_id_query(s: Option<Self>, data: &ShareMap, msg: &Message) -> Result<UserID, Error> {
s: Option<Self>,
data: &mut ShareMap,
msg: &Message,
) -> Result<UserID, Error> {
let id = match s { let id = match s {
Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)), Some(UsernameArg::Raw(s)) => return Ok(UserID::Auto(s)),
Some(UsernameArg::Tagged(r)) => r, Some(UsernameArg::Tagged(r)) => r,
None => msg.author.id, None => msg.author.id,
}; };
let db: DBWriteGuard<_> = data
.get_mut::<OsuSavedUsers>() let db = OsuSavedUsers::open(data);
.ok_or(Error::from("DB uninitialized"))?
.into();
let db = db.borrow()?; let db = db.borrow()?;
db.get(&id) db.get(&id)
.cloned() .cloned()
@ -201,28 +230,24 @@ impl FromStr for Nth {
#[example = "#1 / taiko / natsukagami"] #[example = "#1 / taiko / natsukagami"]
#[max_args(3)] #[max_args(3)]
pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write();
let nth = args.single::<Nth>().unwrap_or(Nth(1)).0.min(50).max(1); 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 mode = args.single::<ModeArg>().unwrap_or(ModeArg(Mode::Std)).0;
let user = UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &mut *data, msg)?; let user =
UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let reqwest = data.get::<http::HTTP>().unwrap(); let osu = ctx.data.get_cloned::<OsuClient>();
let osu: &OsuClient = data.get::<http::Osu>().unwrap();
let user = osu let user = osu
.user(reqwest, user, |f| f.mode(mode))? .user(user, |f| f.mode(mode))?
.ok_or(Error::from("User not found"))?; .ok_or(Error::from("User not found"))?;
let recent_play = osu let recent_play = osu
.user_recent(reqwest, UserID::ID(user.id), |f| f.mode(mode).limit(nth))? .user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(nth))?
.into_iter() .into_iter()
.last() .last()
.ok_or(Error::from("No such play"))?; .ok_or(Error::from("No such play"))?;
let beatmap = osu let beatmap = osu
.beatmaps( .beatmaps(BeatmapRequestKind::Beatmap(recent_play.beatmap_id), |f| {
reqwest, f.mode(mode, true)
BeatmapRequestKind::Beatmap(recent_play.beatmap_id), })?
|f| f.mode(mode, true),
)?
.into_iter() .into_iter()
.next() .next()
.map(|v| BeatmapWithMode(v, mode)) .map(|v| BeatmapWithMode(v, mode))
@ -237,7 +262,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
})?; })?;
// Save the beatmap... // Save the beatmap...
cache::save_beatmap(&mut *data, msg.channel_id, &beatmap)?; cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?;
Ok(()) Ok(())
} }
@ -246,9 +271,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
#[description = "Show information from the last queried beatmap."] #[description = "Show information from the last queried beatmap."]
#[num_args(0)] #[num_args(0)]
pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult { pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult {
let mut data = ctx.data.write(); let b = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?;
let b = cache::get_beatmap(&mut *data, msg.channel_id)?;
match b { match b {
Some(BeatmapWithMode(b, m)) => { Some(BeatmapWithMode(b, m)) => {
@ -273,9 +296,7 @@ pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult {
#[description = "Check your own or someone else's best record on the last beatmap."] #[description = "Check your own or someone else's best record on the last beatmap."]
#[max_args(1)] #[max_args(1)]
pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write(); let bm = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?;
let bm = cache::get_beatmap(&mut *data, msg.channel_id)?;
match bm { match bm {
None => { None => {
@ -284,18 +305,18 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
Some(bm) => { Some(bm) => {
let b = &bm.0; let b = &bm.0;
let m = bm.1; let m = bm.1;
let user = let user = UsernameArg::to_user_id_query(
UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &mut *data, msg)?; args.single::<UsernameArg>().ok(),
&*ctx.data.read(),
msg,
)?;
let reqwest = data.get::<http::HTTP>().unwrap(); let osu = ctx.data.get_cloned::<OsuClient>();
let osu = data.get::<http::Osu>().unwrap();
let user = osu let user = osu
.user(reqwest, user, |f| f)? .user(user, |f| f)?
.ok_or(Error::from("User not found"))?; .ok_or(Error::from("User not found"))?;
let scores = osu.scores(reqwest, b.beatmap_id, |f| { let scores = osu.scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m))?;
f.user(UserID::ID(user.id)).mode(m)
})?;
if scores.is_empty() { if scores.is_empty() {
msg.reply(&ctx, "No scores found")?; msg.reply(&ctx, "No scores found")?;
@ -324,15 +345,14 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
.map(|ModeArg(t)| t) .map(|ModeArg(t)| t)
.unwrap_or(Mode::Std); .unwrap_or(Mode::Std);
let mut data = ctx.data.write(); let user =
let user = UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &mut *data, msg)?; UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let reqwest = data.get::<http::HTTP>().unwrap(); let osu = ctx.data.get_cloned::<OsuClient>();
let osu: &OsuClient = data.get::<http::Osu>().unwrap();
let user = osu let user = osu
.user(reqwest, user, |f| f.mode(mode))? .user(user, |f| f.mode(mode))?
.ok_or(Error::from("User not found"))?; .ok_or(Error::from("User not found"))?;
let top_play = osu.user_best(reqwest, UserID::ID(user.id), |f| f.mode(mode).limit(nth))?; let top_play = osu.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(nth))?;
let rank = top_play.len() as u8; let rank = top_play.len() as u8;
@ -341,11 +361,9 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
.last() .last()
.ok_or(Error::from("No such play"))?; .ok_or(Error::from("No such play"))?;
let beatmap = osu let beatmap = osu
.beatmaps( .beatmaps(BeatmapRequestKind::Beatmap(top_play.beatmap_id), |f| {
reqwest, f.mode(mode, true)
BeatmapRequestKind::Beatmap(top_play.beatmap_id), })?
|f| f.mode(mode, true),
)?
.into_iter() .into_iter()
.next() .next()
.map(|v| BeatmapWithMode(v, mode)) .map(|v| BeatmapWithMode(v, mode))
@ -360,25 +378,24 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
})?; })?;
// Save the beatmap... // Save the beatmap...
cache::save_beatmap(&mut *data, msg.channel_id, &beatmap)?; cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?;
Ok(()) Ok(())
} }
fn get_user(ctx: &mut Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult { fn get_user(ctx: &mut Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult {
let mut data = ctx.data.write(); let user =
let user = UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &mut *data, msg)?; UsernameArg::to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let reqwest = data.get::<http::HTTP>().unwrap(); let osu = ctx.data.get_cloned::<OsuClient>();
let osu = data.get::<http::Osu>().unwrap(); let user = osu.user(user, |f| f.mode(mode))?;
let user = osu.user(reqwest, user, |f| f.mode(mode))?;
match user { match user {
Some(u) => { Some(u) => {
let best = osu let best = osu
.user_best(reqwest, UserID::ID(u.id), |f| f.limit(1).mode(mode))? .user_best(UserID::ID(u.id), |f| f.limit(1).mode(mode))?
.into_iter() .into_iter()
.next() .next()
.map(|m| { .map(|m| {
osu.beatmaps(reqwest, BeatmapRequestKind::Beatmap(m.beatmap_id), |f| { osu.beatmaps(BeatmapRequestKind::Beatmap(m.beatmap_id), |f| {
f.mode(mode, true) f.mode(mode, true)
}) })
.map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode))) .map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode)))

View file

@ -1,5 +1,5 @@
pub mod discord;
pub mod models; pub mod models;
pub mod request; pub mod request;
#[cfg(test)] #[cfg(test)]
@ -10,11 +10,14 @@ use request::builders::*;
use request::*; use request::*;
use reqwest::blocking::{Client as HTTPClient, RequestBuilder, Response}; use reqwest::blocking::{Client as HTTPClient, RequestBuilder, Response};
use serenity::framework::standard::CommandError as Error; use serenity::framework::standard::CommandError as Error;
use std::convert::TryInto; use std::{convert::TryInto, sync::Arc};
/// Client is the client that will perform calls to the osu! api server. /// Client is the client that will perform calls to the osu! api server.
/// It's cheap to clone, so do it.
#[derive(Clone)]
pub struct Client { pub struct Client {
key: String, key: Arc<String>,
client: HTTPClient,
} }
fn vec_try_into<U, T: std::convert::TryFrom<U>>(v: Vec<U>) -> Result<Vec<T>, T::Error> { fn vec_try_into<U, T: std::convert::TryFrom<U>>(v: Vec<U>) -> Result<Vec<T>, T::Error> {
@ -29,52 +32,50 @@ fn vec_try_into<U, T: std::convert::TryFrom<U>>(v: Vec<U>) -> Result<Vec<T>, T::
impl Client { impl Client {
/// Create a new client from the given API key. /// Create a new client from the given API key.
pub fn new(key: impl AsRef<str>) -> Client { pub fn new(http_client: HTTPClient, key: String) -> Client {
Client { Client {
key: key.as_ref().to_string(), key: Arc::new(key),
client: http_client,
} }
} }
fn build_request(&self, c: &HTTPClient, r: RequestBuilder) -> Result<Response, Error> { fn build_request(&self, r: RequestBuilder) -> Result<Response, Error> {
let v = r.query(&[("k", &self.key)]).build()?; let v = r.query(&[("k", &*self.key)]).build()?;
dbg!(v.url()); dbg!(v.url());
Ok(c.execute(v)?) Ok(self.client.execute(v)?)
} }
pub fn beatmaps( pub fn beatmaps(
&self, &self,
client: &HTTPClient,
kind: BeatmapRequestKind, kind: BeatmapRequestKind,
f: impl FnOnce(&mut BeatmapRequestBuilder) -> &mut BeatmapRequestBuilder, f: impl FnOnce(&mut BeatmapRequestBuilder) -> &mut BeatmapRequestBuilder,
) -> Result<Vec<Beatmap>, Error> { ) -> Result<Vec<Beatmap>, Error> {
let mut r = BeatmapRequestBuilder::new(kind); let mut r = BeatmapRequestBuilder::new(kind);
f(&mut r); f(&mut r);
let res: Vec<raw::Beatmap> = self.build_request(client, r.build(client))?.json()?; let res: Vec<raw::Beatmap> = self.build_request(r.build(&self.client))?.json()?;
Ok(vec_try_into(res)?) Ok(vec_try_into(res)?)
} }
pub fn user( pub fn user(
&self, &self,
client: &HTTPClient,
user: UserID, user: UserID,
f: impl FnOnce(&mut UserRequestBuilder) -> &mut UserRequestBuilder, f: impl FnOnce(&mut UserRequestBuilder) -> &mut UserRequestBuilder,
) -> Result<Option<User>, Error> { ) -> Result<Option<User>, Error> {
let mut r = UserRequestBuilder::new(user); let mut r = UserRequestBuilder::new(user);
f(&mut r); f(&mut r);
let res: Vec<raw::User> = self.build_request(client, r.build(client))?.json()?; let res: Vec<raw::User> = self.build_request(r.build(&self.client))?.json()?;
let res = vec_try_into(res)?; let res = vec_try_into(res)?;
Ok(res.into_iter().next()) Ok(res.into_iter().next())
} }
pub fn scores( pub fn scores(
&self, &self,
client: &HTTPClient,
beatmap_id: u64, beatmap_id: u64,
f: impl FnOnce(&mut ScoreRequestBuilder) -> &mut ScoreRequestBuilder, f: impl FnOnce(&mut ScoreRequestBuilder) -> &mut ScoreRequestBuilder,
) -> Result<Vec<Score>, Error> { ) -> Result<Vec<Score>, Error> {
let mut r = ScoreRequestBuilder::new(beatmap_id); let mut r = ScoreRequestBuilder::new(beatmap_id);
f(&mut r); f(&mut r);
let res: Vec<raw::Score> = self.build_request(client, r.build(client))?.json()?; let res: Vec<raw::Score> = self.build_request(r.build(&self.client))?.json()?;
let mut res: Vec<Score> = vec_try_into(res)?; let mut res: Vec<Score> = vec_try_into(res)?;
// with a scores request you need to fill the beatmap ids yourself // with a scores request you need to fill the beatmap ids yourself
@ -86,32 +87,29 @@ impl Client {
pub fn user_best( pub fn user_best(
&self, &self,
client: &HTTPClient,
user: UserID, user: UserID,
f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder,
) -> Result<Vec<Score>, Error> { ) -> Result<Vec<Score>, Error> {
self.user_scores(UserScoreType::Best, client, user, f) self.user_scores(UserScoreType::Best, user, f)
} }
pub fn user_recent( pub fn user_recent(
&self, &self,
client: &HTTPClient,
user: UserID, user: UserID,
f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder,
) -> Result<Vec<Score>, Error> { ) -> Result<Vec<Score>, Error> {
self.user_scores(UserScoreType::Recent, client, user, f) self.user_scores(UserScoreType::Recent, user, f)
} }
fn user_scores( fn user_scores(
&self, &self,
u: UserScoreType, u: UserScoreType,
client: &HTTPClient,
user: UserID, user: UserID,
f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder,
) -> Result<Vec<Score>, Error> { ) -> Result<Vec<Score>, Error> {
let mut r = UserScoreRequestBuilder::new(u, user); let mut r = UserScoreRequestBuilder::new(u, user);
f(&mut r); f(&mut r);
let res: Vec<raw::Score> = self.build_request(client, r.build(client))?.json()?; let res: Vec<raw::Score> = self.build_request(r.build(&self.client))?.json()?;
let res = vec_try_into(res)?; let res = vec_try_into(res)?;
Ok(res) Ok(res)
} }

View file

@ -0,0 +1,12 @@
[package]
name = "youmubot-prelude"
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]
serenity = "0.8"
youmubot-db = { path = "../youmubot-db" }
reqwest = "0.10"

View file

@ -1,49 +1,45 @@
use crate::db::{AnnouncerChannels, DBWriteGuard}; use crate::AppData;
use serenity::{ use serenity::{
framework::standard::{CommandError as Error, CommandResult}, framework::standard::{CommandError as Error, CommandResult},
http::{CacheHttp, Http}, http::{CacheHttp, Http},
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
prelude::ShareMap,
}; };
use std::{ use std::{
collections::HashSet, collections::{HashMap, HashSet},
thread::{spawn, JoinHandle}, thread::{spawn, JoinHandle},
}; };
use youmubot_db::DB;
pub(crate) type AnnouncerChannels = DB<HashMap<String, HashMap<GuildId, ChannelId>>>;
pub trait Announcer { pub trait Announcer {
fn announcer_key() -> &'static str; fn announcer_key() -> &'static str;
fn send_messages( fn send_messages(
c: &Http, c: &Http,
d: &mut ShareMap, d: AppData,
channels: impl Fn(UserId) -> Vec<ChannelId> + Sync, channels: impl Fn(UserId) -> Vec<ChannelId> + Sync,
) -> CommandResult; ) -> CommandResult;
fn set_channel(d: &mut ShareMap, guild: GuildId, channel: ChannelId) -> CommandResult { fn set_channel(d: AppData, guild: GuildId, channel: ChannelId) -> CommandResult {
let mut data: DBWriteGuard<_> = d AnnouncerChannels::open(&*d.read())
.get_mut::<AnnouncerChannels>() .borrow_mut()?
.expect("DB initialized") .entry(Self::announcer_key().to_owned())
.into();
let mut data = data.borrow_mut()?;
data.entry(Self::announcer_key().to_owned())
.or_default() .or_default()
.insert(guild, channel); .insert(guild, channel);
Ok(()) Ok(())
} }
fn get_guilds(d: &mut ShareMap) -> Result<Vec<(GuildId, ChannelId)>, Error> { fn get_guilds(d: AppData) -> Result<Vec<(GuildId, ChannelId)>, Error> {
let data = d let data = AnnouncerChannels::open(&*d.read())
.get::<AnnouncerChannels>() .borrow()?
.expect("DB initialized") .get(Self::announcer_key())
.read(|v| {
v.get(Self::announcer_key())
.map(|m| m.iter().map(|(a, b)| (*a, *b)).collect()) .map(|m| m.iter().map(|(a, b)| (*a, *b)).collect())
.unwrap_or_else(|| vec![]) .unwrap_or_else(|| vec![]);
})?;
Ok(data) Ok(data)
} }
fn announce(c: &Http, d: &mut ShareMap) -> CommandResult { fn announce(c: impl AsRef<Http>, d: AppData) -> CommandResult {
let guilds: Vec<_> = Self::get_guilds(d)?; let guilds: Vec<_> = Self::get_guilds(d.clone())?;
let member_sets = { let member_sets = {
let mut v = Vec::with_capacity(guilds.len()); let mut v = Vec::with_capacity(guilds.len());
for (guild, channel) in guilds.into_iter() { for (guild, channel) in guilds.into_iter() {
@ -75,7 +71,7 @@ pub trait Announcer {
let c = client.cache_and_http.clone(); let c = client.cache_and_http.clone();
let data = client.data.clone(); let data = client.data.clone();
spawn(move || loop { spawn(move || loop {
if let Err(e) = Self::announce(c.http(), &mut *data.write()) { if let Err(e) = Self::announce(c.http(), data.clone()) {
dbg!(e); dbg!(e);
} }
std::thread::sleep(cooldown); std::thread::sleep(cooldown);

View file

@ -0,0 +1,48 @@
pub use serenity::prelude::*;
use std::sync::Arc;
pub mod announcer;
pub mod args;
pub mod setup;
pub use announcer::Announcer;
pub use args::Duration;
/// The global app data.
pub type AppData = Arc<RwLock<ShareMap>>;
/// The HTTP client.
pub struct HTTPClient;
impl TypeMapKey for HTTPClient {
type Value = reqwest::blocking::Client;
}
/// The TypeMap trait that allows TypeMaps to quickly get a clonable item.
pub trait GetCloned {
/// Gets an item from the store, cloned.
fn get_cloned<T>(&self) -> T::Value
where
T: TypeMapKey,
T::Value: Clone + Send + Sync;
}
impl GetCloned for ShareMap {
fn get_cloned<T>(&self) -> T::Value
where
T: TypeMapKey,
T::Value: Clone + Send + Sync,
{
self.get::<T>().cloned().expect("Should be there")
}
}
impl GetCloned for AppData {
fn get_cloned<T>(&self) -> T::Value
where
T: TypeMapKey,
T::Value: Clone + Send + Sync,
{
self.read().get::<T>().cloned().expect("Should be there")
}
}

View file

@ -0,0 +1,13 @@
use serenity::{framework::standard::StandardFramework, prelude::*};
use std::path::Path;
/// Set up the prelude libraries.
///
/// Panics on failure: Youmubot should *NOT* attempt to continue when this function fails.
pub fn setup_prelude(db_path: &Path, data: &mut ShareMap, _: &mut StandardFramework) {
// Setup the announcer DB.
crate::announcer::AnnouncerChannels::insert_into(data, db_path.join("announcers.yaml"))
.expect("Announcers DB set up");
data.insert::<crate::HTTPClient>(reqwest::blocking::Client::new());
}

View file

@ -9,16 +9,8 @@ edition = "2018"
[dependencies] [dependencies]
serenity = "0.8" serenity = "0.8"
dotenv = "0.15" dotenv = "0.15"
serde = { version = "1.0", features = ["derive"] }
chrono = "0.4.9"
rand = "0.7.2"
static_assertions = "1.1.0"
reqwest = "0.10.1"
regex = "1"
lazy_static = "1"
youmubot-osu = { path = "../youmubot-osu" } youmubot-osu = { path = "../youmubot-osu" }
rayon = "1.1" youmubot-db = { path = "../youmubot-db" }
youmubot-prelude = { path = "../youmubot-prelude" }
youmubot-core = { path = "../youmubot-core" }
[dependencies.rustbreak]
version = "2.0.0-rc3"
features = ["yaml_enc"]

View file

@ -1,134 +0,0 @@
use chrono::{DateTime, Utc};
use dotenv::var;
use rustbreak::{deser::Yaml as Ron, FileDatabase};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serenity::{
client::Client,
framework::standard::CommandError as Error,
model::id::{ChannelId, GuildId, RoleId, UserId},
prelude::*,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use youmubot_osu::models::{Beatmap, Mode};
/// GuildMap defines the guild-map type.
/// It is basically a HashMap from a GuildId to a data structure.
pub type GuildMap<V> = HashMap<GuildId, V>;
/// The generic DB type we will be using.
pub struct DB<T>(std::marker::PhantomData<T>);
impl<T: std::any::Any> serenity::prelude::TypeMapKey for DB<T> {
type Value = FileDatabase<T, Ron>;
}
impl<T: std::any::Any + Default + Send + Sync + Clone + Serialize + std::fmt::Debug> DB<T>
where
for<'de> T: Deserialize<'de>,
{
fn insert_into(data: &mut ShareMap, path: impl AsRef<Path>) -> Result<(), Error> {
let db = FileDatabase::<T, Ron>::from_path(path, T::default())?;
db.load().or_else(|e| {
dbg!(e);
db.save()
})?;
data.insert::<DB<T>>(db);
Ok(())
}
}
/// A map from announcer keys to guild IDs and to channels.
pub type AnnouncerChannels = DB<HashMap<String, GuildMap<ChannelId>>>;
/// A list of SoftBans for all servers.
pub type SoftBans = DB<GuildMap<ServerSoftBans>>;
/// Save the user IDs.
pub type OsuSavedUsers = DB<HashMap<UserId, OsuUser>>;
/// Save each channel's last requested beatmap.
pub type OsuLastBeatmap = DB<HashMap<ChannelId, (Beatmap, Mode)>>;
/// Sets up all databases in the client.
pub fn setup_db(client: &mut Client) -> Result<(), Error> {
let path: PathBuf = var("DBPATH").map(|v| PathBuf::from(v)).unwrap_or_else(|e| {
println!("No DBPATH set up ({:?}), using `/data`", e);
PathBuf::from("data")
});
let mut data = client.data.write();
SoftBans::insert_into(&mut *data, &path.join("soft_bans.yaml"))?;
OsuSavedUsers::insert_into(&mut *data, &path.join("osu_saved_users.yaml"))?;
OsuLastBeatmap::insert_into(&mut *data, &path.join("last_beatmaps.yaml"))?;
AnnouncerChannels::insert_into(&mut *data, &path.join("announcers.yaml"))?;
Ok(())
}
pub struct DBWriteGuard<'a, T>(&'a mut FileDatabase<T, Ron>)
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned;
impl<'a, T> From<&'a mut FileDatabase<T, Ron>> for DBWriteGuard<'a, T>
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned,
{
fn from(v: &'a mut FileDatabase<T, Ron>) -> Self {
DBWriteGuard(v)
}
}
impl<'a, T> DBWriteGuard<'a, T>
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned,
{
pub fn borrow(&self) -> Result<std::sync::RwLockReadGuard<T>, rustbreak::RustbreakError> {
(*self).0.borrow_data()
}
pub fn borrow_mut(
&mut self,
) -> Result<std::sync::RwLockWriteGuard<T>, rustbreak::RustbreakError> {
(*self).0.borrow_data_mut()
}
}
impl<'a, T> Drop for DBWriteGuard<'a, T>
where
T: Send + Sync + Clone + std::fmt::Debug + Serialize + DeserializeOwned,
{
fn drop(&mut self) {
self.0.save().expect("Save succeed")
}
}
/// For the admin commands:
/// - Each server might have a `soft ban` role implemented.
/// - We allow periodical `soft ban` applications.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ServerSoftBans {
Implemented(ImplementedSoftBans),
Unimplemented,
}
impl ServerSoftBans {
// Create a new, implemented role.
pub fn new_implemented(role: RoleId) -> ServerSoftBans {
ServerSoftBans::Implemented(ImplementedSoftBans {
role: role,
periodical_bans: HashMap::new(),
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImplementedSoftBans {
/// The soft-ban role.
pub role: RoleId,
/// List of all to-unban people.
pub periodical_bans: HashMap<UserId, DateTime<Utc>>,
}
/// An osu! saved user.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OsuUser {
pub id: u64,
pub last_update: DateTime<Utc>,
}

View file

@ -1,14 +0,0 @@
use serenity::prelude::TypeMapKey;
use youmubot_osu::Client as OsuClient;
pub(crate) struct HTTP;
impl TypeMapKey for HTTP {
type Value = reqwest::blocking::Client;
}
pub(crate) struct Osu;
impl TypeMapKey for Osu {
type Value = OsuClient;
}

View file

@ -1,21 +1,13 @@
use dotenv; use dotenv;
use dotenv::var; use dotenv::var;
use reqwest;
use serenity::{ use serenity::{
framework::standard::{DispatchError, StandardFramework}, framework::standard::{DispatchError, StandardFramework},
model::{channel::Message, gateway}, model::{channel::Message, gateway},
prelude::*,
}; };
use youmubot_osu::Client as OsuClient; use youmubot_osu::discord::{setup as setup_osu, OSU_GROUP};
use youmubot_prelude::*;
mod commands; const MESSAGE_HOOKS: [fn(&mut Context, &Message) -> (); 1] = [youmubot_osu::discord::hook];
mod db;
mod http;
use commands::osu::OsuAnnouncer;
use commands::Announcer;
const MESSAGE_HOOKS: [fn(&mut Context, &Message) -> (); 1] = [commands::osu::hook];
struct Handler; struct Handler;
@ -40,26 +32,28 @@ fn main() {
// Collect the token // Collect the token
let token = var("TOKEN").expect("Please set TOKEN as the Discord Bot's token to be used."); let token = var("TOKEN").expect("Please set TOKEN as the Discord Bot's token to be used.");
// Attempt to connect and set up a framework // Attempt to connect and set up a framework
setup_framework(Client::new(token, Handler).expect("Cannot connect...")) Client::new(token, Handler).expect("Cannot connect")
}; };
// Setup initial data // Set up base framework
db::setup_db(&mut client).expect("Setup db should succeed"); let mut fw = setup_framework(&client);
// Setup shared instances of things
// Setup each package starting from the prelude.
{ {
let mut data = client.data.write(); let mut data = client.data.write();
data.insert::<http::HTTP>(reqwest::blocking::Client::new()); let db_path = var("DBPATH")
data.insert::<http::Osu>(OsuClient::new( .map(|v| std::path::PathBuf::from(v))
var("OSU_API_KEY").expect("Please set OSU_API_KEY as osu! api key."), .unwrap_or_else(|e| {
)); println!("No DBPATH set up ({:?}), using `/data`", e);
std::path::PathBuf::from("data")
});
youmubot_prelude::setup::setup_prelude(&db_path, &mut data, &mut fw);
// Setup core
youmubot_core::setup(&db_path, &client, &mut data).expect("Setup db should succeed");
// osu!
setup_osu(&db_path, &client, &mut data).expect("osu! is initialized");
} }
// Create handler threads
std::thread::spawn(commands::admin::watch_soft_bans(&mut client));
// Announcers
OsuAnnouncer::scan(&client, std::time::Duration::from_secs(300));
println!("Starting..."); println!("Starting...");
if let Err(v) = client.start() { if let Err(v) = client.start() {
panic!(v) panic!(v)
@ -69,7 +63,7 @@ fn main() {
} }
// Sets up a framework for a client // Sets up a framework for a client
fn setup_framework(mut client: Client) -> Client { fn setup_framework(client: &Client) -> StandardFramework {
// Collect owners // Collect owners
let owner = client let owner = client
.cache_and_http .cache_and_http
@ -78,7 +72,6 @@ fn setup_framework(mut client: Client) -> Client {
.expect("Should be able to get app info") .expect("Should be able to get app info")
.owner; .owner;
client.with_framework(
StandardFramework::new() StandardFramework::new()
.configure(|c| { .configure(|c| {
c.with_whitespace(false) c.with_whitespace(false)
@ -86,7 +79,7 @@ fn setup_framework(mut client: Client) -> Client {
.delimiters(vec![" / ", "/ ", " /", "/"]) .delimiters(vec![" / ", "/ ", " /", "/"])
.owners([owner.id].iter().cloned().collect()) .owners([owner.id].iter().cloned().collect())
}) })
.help(&commands::HELP) .help(&youmubot_core::HELP)
.before(|_, msg, command_name| { .before(|_, msg, command_name| {
println!( println!(
"Got command '{}' by user '{}'", "Got command '{}' by user '{}'",
@ -135,10 +128,8 @@ fn setup_framework(mut client: Client) -> Client {
c.delay(30).time_span(30).limit(1) c.delay(30).time_span(30).limit(1)
}) })
// groups here // groups here
.group(&commands::ADMIN_GROUP) .group(&youmubot_core::ADMIN_GROUP)
.group(&commands::FUN_GROUP) .group(&youmubot_core::FUN_GROUP)
.group(&commands::COMMUNITY_GROUP) .group(&youmubot_core::COMMUNITY_GROUP)
.group(&commands::OSU_GROUP) .group(&OSU_GROUP)
);
client
} }