Split youmubot-osu

This commit is contained in:
Natsu Kagami 2020-02-05 17:25:34 -05:00
parent 03be1a4acc
commit aec9cd130d
15 changed files with 108 additions and 86 deletions

View file

@ -0,0 +1,95 @@
use super::db::{OsuSavedUsers, OsuUser};
use super::{embeds::score_embed, BeatmapWithMode, OsuClient};
use crate::{
models::{Mode, Score},
request::{BeatmapRequestKind, UserID},
Client as Osu,
};
use rayon::prelude::*;
use serenity::{
framework::standard::{CommandError as Error, CommandResult},
http::Http,
model::id::{ChannelId, UserId},
};
use youmubot_prelude::*;
/// 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();
for (user_id, osu_user) in data.iter_mut() {
let mut user = None;
for mode in &[Mode::Std, Mode::Taiko, Mode::Mania, Mode::Catch] {
let scores = OsuAnnouncer::scan_user(&osu, osu_user, *mode)?;
if scores.is_empty() {
continue;
}
let user = {
let user = &mut user;
if let None = user {
match osu.user(UserID::ID(osu_user.id), |f| f.mode(*mode)) {
Ok(u) => {
*user = u;
}
Err(_) => continue,
}
};
user.as_ref().unwrap()
};
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);
}
}
});
}
osu_user.last_update = chrono::Utc::now();
}
// 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)
}
}

View file

@ -0,0 +1,35 @@
use super::db::OsuLastBeatmap;
use super::BeatmapWithMode;
use serenity::{
framework::standard::{CommandError as Error, CommandResult},
model::id::ChannelId,
prelude::*,
};
/// Save the beatmap into the server data storage.
pub(crate) fn save_beatmap(
data: &ShareMap,
channel_id: ChannelId,
bm: &BeatmapWithMode,
) -> CommandResult {
let db = OsuLastBeatmap::open(data);
let mut db = db.borrow_mut()?;
db.insert(channel_id, (bm.0.clone(), bm.mode()));
Ok(())
}
/// Get the last beatmap requested from this channel.
pub(crate) fn get_beatmap(
data: &ShareMap,
channel_id: ChannelId,
) -> Result<Option<BeatmapWithMode>, Error> {
let db = OsuLastBeatmap::open(data);
let db = db.borrow()?;
Ok(db
.get(&channel_id)
.cloned()
.map(|(a, b)| BeatmapWithMode(a, b)))
}

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

@ -0,0 +1,351 @@
use super::BeatmapWithMode;
use crate::models::{Beatmap, Mode, Rank, Score, User};
use chrono::Utc;
use serenity::{builder::CreateEmbed, utils::MessageBuilder};
use youmubot_prelude::*;
fn format_mode(actual: Mode, original: Mode) -> String {
if actual == original {
format!("{}", actual)
} else {
format!("{} (converted)", actual)
}
}
pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a mut CreateEmbed {
c.title(
MessageBuilder::new()
.push_bold_safe(&b.artist)
.push(" - ")
.push_bold_safe(&b.title)
.push(" [")
.push_bold_safe(&b.difficulty_name)
.push("]")
.build(),
)
.author(|a| {
a.name(&b.creator)
.url(format!("https://osu.ppy.sh/users/{}", b.creator_id))
.icon_url(format!("https://a.ppy.sh/{}", b.creator_id))
})
.url(b.link())
.thumbnail(format!("https://b.ppy.sh/thumb/{}l.jpg", b.beatmapset_id))
.image(b.cover_url())
.color(0xffb6c1)
.field(
"Star Difficulty",
format!("{:.2}", b.difficulty.stars),
false,
)
.field(
"Length",
MessageBuilder::new()
.push_bold_safe(Duration(b.total_length))
.push(" (")
.push_bold_safe(Duration(b.drain_length))
.push(" drain)")
.build(),
false,
)
.field("Circle Size", format!("{:.1}", b.difficulty.cs), true)
.field("Approach Rate", format!("{:.1}", b.difficulty.ar), true)
.field(
"Overall Difficulty",
format!("{:.1}", b.difficulty.od),
true,
)
.field("HP Drain", format!("{:.1}", b.difficulty.hp), true)
.field("BPM", b.bpm.round(), true)
.fields(b.difficulty.max_combo.map(|v| ("Max combo", v, true)))
.field("Mode", format_mode(m, b.mode), true)
.fields(b.source.as_ref().map(|v| ("Source", v, true)))
.field(
"Tags",
b.tags
.iter()
.map(|v| MessageBuilder::new().push_mono_safe(v).build())
.take(10)
.chain(std::iter::once("...".to_owned()))
.collect::<Vec<_>>()
.join(" "),
false,
)
.description(
MessageBuilder::new()
.push({
let link = format!("https://osu.ppy.sh/beatmapsets/{}/download", b.beatmap_id);
format!(
"Download: [[Link]]({}) [[No Video]]({}?noVideo=1)",
link, link
)
})
.push_line(b.beatmapset_link())
.push_line(&b.approval)
.push("Language: ")
.push_bold(&b.language)
.push(" | Genre: ")
.push_bold(&b.genre)
.build(),
)
}
const MAX_DIFFS: usize = 25 - 4;
pub fn beatmapset_embed<'a>(
bs: &'_ [Beatmap],
m: Option<Mode>,
c: &'a mut CreateEmbed,
) -> &'a mut CreateEmbed {
let too_many_diffs = bs.len() > MAX_DIFFS;
let b: &Beatmap = &bs[0];
c.title(
MessageBuilder::new()
.push_bold_safe(&b.artist)
.push(" - ")
.push_bold_safe(&b.title)
.build(),
)
.author(|a| {
a.name(&b.creator)
.url(format!("https://osu.ppy.sh/users/{}", b.creator_id))
.icon_url(format!("https://a.ppy.sh/{}", b.creator_id))
})
.url(format!(
"https://osu.ppy.sh/beatmapsets/{}",
b.beatmapset_id,
))
// .thumbnail(format!("https://b.ppy.sh/thumb/{}l.jpg", b.beatmapset_id))
.image(format!(
"https://assets.ppy.sh/beatmaps/{}/covers/cover.jpg",
b.beatmapset_id
))
.color(0xffb6c1)
.description(
MessageBuilder::new()
.push_line({
let link = format!("https://osu.ppy.sh/beatmapsets/{}/download", b.beatmap_id);
format!(
"Download: [[Link]]({}) [[No Video]]({}?noVideo=1)",
link, link
)
})
.push_line(&b.approval)
.push("Language: ")
.push_bold(&b.language)
.push(" | Genre: ")
.push_bold(&b.genre)
.build(),
)
.field(
"Length",
MessageBuilder::new()
.push_bold_safe(Duration(b.total_length))
.build(),
true,
)
.field("BPM", b.bpm.round(), true)
.fields(b.source.as_ref().map(|v| ("Source", v, false)))
.field(
"Tags",
b.tags
.iter()
.map(|v| MessageBuilder::new().push_mono_safe(v).build())
.take(10)
.chain(std::iter::once("...".to_owned()))
.collect::<Vec<_>>()
.join(" "),
false,
)
.footer(|f| {
if too_many_diffs {
f.text(format!(
"This map has {} diffs, we are showing the last {}.",
bs.len(),
MAX_DIFFS
))
} else {
f
}
})
.fields(bs.iter().rev().take(MAX_DIFFS).rev().map(|b: &Beatmap| {
(
format!("[{}]", b.difficulty_name),
MessageBuilder::new()
.push(format!("[[Link]]({})", b.link()))
.push(", ")
.push_bold(format!("{:.2}", b.difficulty.stars))
.push(", ")
.push_bold_line(format_mode(m.unwrap_or(b.mode), b.mode))
.push("CS")
.push_bold(format!("{:.1}", b.difficulty.cs))
.push(", AR")
.push_bold(format!("{:.1}", b.difficulty.ar))
.push(", OD")
.push_bold(format!("{:.1}", b.difficulty.od))
.push(", HP")
.push_bold(format!("{:.1}", b.difficulty.hp))
.push(", ⌛ ")
.push_bold(format!("{}", Duration(b.drain_length)))
.build(),
false,
)
}))
}
pub(crate) fn score_embed<'a>(
s: &Score,
bm: &BeatmapWithMode,
u: &User,
top_record: Option<u8>,
m: &'a mut CreateEmbed,
) -> &'a mut CreateEmbed {
let mode = bm.mode();
let b = &bm.0;
let accuracy = s.accuracy(mode);
let score_line = match &s.rank {
Rank::SS | Rank::SSH => format!("SS"),
_ if s.perfect => format!("{:.2}% FC", accuracy),
Rank::F => format!("{:.2}% {} combo [FAILED]", accuracy, s.max_combo),
v => format!(
"{:.2}% {}x {} miss {} rank",
accuracy, s.max_combo, s.count_miss, v
),
};
let score_line =
s.pp.map(|pp| format!("{} | {:.2}pp", &score_line, pp))
.unwrap_or(score_line);
let top_record = top_record
.map(|v| format!("| #{} top record!", v))
.unwrap_or("".to_owned());
m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url()))
.color(0xffb6c1)
.title(format!(
"{} | {} - {} [{}] {} ({:.2}\\*) by {} | {} {}",
u.username,
b.artist,
b.title,
b.difficulty_name,
s.mods,
b.difficulty.stars,
b.creator,
score_line,
top_record
))
.description(format!("[[Beatmap]]({})", b.link()))
.image(b.cover_url())
.field(
"Beatmap",
format!("{} - {} [{}]", b.artist, b.title, b.difficulty_name),
false,
)
.field("Rank", &score_line, false)
.fields(s.pp.map(|pp| ("pp gained", format!("{:.2}pp", pp), true)))
.field("Creator", &b.creator, true)
.field("Mode", mode.to_string(), true)
.field(
"Map stats",
MessageBuilder::new()
.push(format!("[[Link]]({})", b.link()))
.push(", ")
.push_bold(format!("{:.2}", b.difficulty.stars))
.push(", ")
.push_bold_line(
b.mode.to_string()
+ if bm.is_converted() {
" (Converted)"
} else {
""
},
)
.push("CS")
.push_bold(format!("{:.1}", b.difficulty.cs))
.push(", AR")
.push_bold(format!("{:.1}", b.difficulty.ar))
.push(", OD")
.push_bold(format!("{:.1}", b.difficulty.od))
.push(", HP")
.push_bold(format!("{:.1}", b.difficulty.hp))
.push(", ⌛ ")
.push_bold(format!("{}", Duration(b.drain_length)))
.build(),
false,
)
.field("Played on", s.date.format("%F %T"), false)
}
pub(crate) fn user_embed<'a>(
u: User,
best: Option<(Score, BeatmapWithMode)>,
m: &'a mut CreateEmbed,
) -> &'a mut CreateEmbed {
m.title(u.username)
.url(format!("https://osu.ppy.sh/users/{}", u.id))
.color(0xffb6c1)
.thumbnail(format!("https://a.ppy.sh/{}", u.id))
.description(format!("Member since **{}**", u.joined.format("%F %T")))
.field(
"Performance Points",
u.pp.map(|v| format!("{:.2}pp", v))
.unwrap_or("Inactive".to_owned()),
false,
)
.field("World Rank", format!("#{}", u.rank), true)
.field(
"Country Rank",
format!(":flag_{}: #{}", u.country.to_lowercase(), u.country_rank),
true,
)
.field("Accuracy", format!("{:.2}%", u.accuracy), true)
.field(
"Play count",
format!("{} (play time: {})", u.play_count, Duration(u.played_time)),
false,
)
.field(
"Ranks",
format!(
"{} SSH | {} SS | {} SH | {} S | {} A",
u.count_ssh, u.count_ss, u.count_sh, u.count_s, u.count_a
),
false,
)
.field(
"Level",
format!(
"Level **{:.0}**: {} total score, {} ranked score",
u.level, u.total_score, u.ranked_score
),
false,
)
.fields(best.map(|(v, map)| {
let map = map.0;
(
"Best Record",
MessageBuilder::new()
.push_bold(format!(
"{:.2}pp",
v.pp.unwrap() /*Top record should have pp*/
))
.push(" - ")
.push_line(format!(
"{:.1} ago",
Duration(
(Utc::now() - v.date)
.to_std()
.unwrap_or(std::time::Duration::from_secs(1))
)
))
.push("on ")
.push(format!(
"[{} - {}]({})",
MessageBuilder::new().push_bold_safe(&map.artist).build(),
MessageBuilder::new().push_bold_safe(&map.title).build(),
map.link()
))
.push(format!(" [{}]", map.difficulty_name))
.push(format!(" ({:.1}⭐)", map.difficulty.stars))
.build(),
false,
)
}))
}

View file

@ -0,0 +1,206 @@
use super::OsuClient;
use crate::{
models::{Beatmap, Mode},
request::BeatmapRequestKind,
};
use lazy_static::lazy_static;
use regex::Regex;
use serenity::{
builder::CreateMessage,
framework::standard::{CommandError as Error, CommandResult},
model::channel::Message,
utils::MessageBuilder,
};
use youmubot_prelude::*;
use super::embeds::{beatmap_embed, beatmapset_embed};
lazy_static! {
static ref OLD_LINK_REGEX: Regex = Regex::new(
r"https?://osu\.ppy\.sh/(?P<link_type>s|b)/(?P<id>\d+)(?:[\&\?]m=(?P<mode>\d))?(?:\+(?P<mods>[A-Z]+))?"
).unwrap();
static ref NEW_LINK_REGEX: Regex = Regex::new(
r"https?://osu\.ppy\.sh/beatmapsets/(?P<set_id>\d+)/?(?:\#(?P<mode>osu|taiko|fruits|mania)(?:/(?P<beatmap_id>\d+)|/?))?(?:\+(?P<mods>[A-Z]+))?"
).unwrap();
}
pub fn hook(ctx: &mut Context, msg: &Message) -> () {
if msg.author.bot {
return;
}
let mut v = move || -> CommandResult {
let old_links = handle_old_links(ctx, &msg.content)?;
let new_links = handle_new_links(ctx, &msg.content)?;
let mut last_beatmap = None;
for l in old_links.into_iter().chain(new_links.into_iter()) {
if let Err(v) = msg.channel_id.send_message(&ctx, |m| match l.embed {
EmbedType::Beatmap(b) => {
let t = handle_beatmap(&b, l.link, l.mode, l.mods, m);
let mode = l.mode.unwrap_or(b.mode);
last_beatmap = Some(super::BeatmapWithMode(b, mode));
t
}
EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, l.mods, m),
}) {
println!("Error in osu! hook: {:?}", v)
}
}
// Save the beatmap for query later.
if let Some(t) = last_beatmap {
if let Err(v) = super::cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &t) {
dbg!(v);
}
}
Ok(())
};
if let Err(v) = v() {
println!("Error in osu! hook: {:?}", v)
}
}
enum EmbedType {
Beatmap(Beatmap),
Beatmapset(Vec<Beatmap>),
}
struct ToPrint<'a> {
embed: EmbedType,
link: &'a str,
mode: Option<Mode>,
mods: Option<&'a str>,
}
fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let osu = ctx.data.get_cloned::<OsuClient>();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
for capture in OLD_LINK_REGEX.captures_iter(content) {
let req_type = capture.name("link_type").unwrap().as_str();
let req = match req_type {
"b" => BeatmapRequestKind::Beatmap(capture["id"].parse()?),
"s" => BeatmapRequestKind::Beatmapset(capture["id"].parse()?),
_ => continue,
};
let mode = capture
.name("mode")
.map(|v| v.as_str().parse())
.transpose()?
.and_then(|v| {
Some(match v {
0 => Mode::Std,
1 => Mode::Taiko,
2 => Mode::Catch,
3 => Mode::Mania,
_ => return None,
})
});
let beatmaps = osu.beatmaps(req, |v| match mode {
Some(m) => v.mode(m, true),
None => v,
})?;
match req_type {
"b" => {
for b in beatmaps.into_iter() {
to_prints.push(ToPrint {
embed: EmbedType::Beatmap(b),
link: capture.get(0).unwrap().as_str(),
mode,
mods: capture.name("mods").map(|v| v.as_str()),
})
}
}
"s" => to_prints.push(ToPrint {
embed: EmbedType::Beatmapset(beatmaps),
link: capture.get(0).unwrap().as_str(),
mode,
mods: capture.name("mods").map(|v| v.as_str()),
}),
_ => (),
}
}
Ok(to_prints)
}
fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let osu = ctx.data.get_cloned::<OsuClient>();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
for capture in NEW_LINK_REGEX.captures_iter(content) {
let mode = capture.name("mode").and_then(|v| {
Some(match v.as_str() {
"osu" => Mode::Std,
"taiko" => Mode::Taiko,
"fruits" => Mode::Catch,
"mania" => Mode::Mania,
_ => return None,
})
});
let mods = capture.name("mods").map(|v| v.as_str());
let link = capture.get(0).unwrap().as_str();
let req = match capture.name("beatmap_id") {
Some(ref v) => BeatmapRequestKind::Beatmap(v.as_str().parse()?),
None => {
BeatmapRequestKind::Beatmapset(capture.name("set_id").unwrap().as_str().parse()?)
}
};
let beatmaps = osu.beatmaps(req, |v| match mode {
Some(m) => v.mode(m, true),
None => v,
})?;
match capture.name("beatmap_id") {
Some(_) => {
for beatmap in beatmaps.into_iter() {
to_prints.push(ToPrint {
embed: EmbedType::Beatmap(beatmap),
link,
mods,
mode,
})
}
}
None => to_prints.push(ToPrint {
embed: EmbedType::Beatmapset(beatmaps),
link,
mods,
mode,
}),
}
}
Ok(to_prints)
}
fn handle_beatmap<'a, 'b>(
beatmap: &Beatmap,
link: &'_ str,
mode: Option<Mode>,
mods: Option<&'_ str>,
m: &'a mut CreateMessage<'b>,
) -> &'a mut CreateMessage<'b> {
m.content(
MessageBuilder::new()
.push("Beatmap information for ")
.push_mono_safe(link)
.build(),
)
.embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), b))
}
fn handle_beatmapset<'a, 'b>(
beatmaps: Vec<Beatmap>,
link: &'_ str,
mode: Option<Mode>,
mods: Option<&'_ str>,
m: &'a mut CreateMessage<'b>,
) -> &'a mut CreateMessage<'b> {
let mut beatmaps = beatmaps;
beatmaps.sort_by(|a, b| {
(mode.unwrap_or(a.mode) as u8, a.difficulty.stars)
.partial_cmp(&(mode.unwrap_or(b.mode) as u8, b.difficulty.stars))
.unwrap()
});
m.content(
MessageBuilder::new()
.push("Beatmapset information for ")
.push_mono_safe(link)
.build(),
)
.embed(|b| beatmapset_embed(&beatmaps, mode, b))
}

View file

@ -0,0 +1,415 @@
use crate::{
models::{Beatmap, Mode, User},
request::{BeatmapRequestKind, UserID},
Client as OsuHttpClient,
};
use serenity::{
framework::standard::{
macros::{command, group},
Args, CommandError as Error, CommandResult,
},
model::{channel::Message, id::UserId},
utils::MessageBuilder,
};
use std::str::FromStr;
use youmubot_prelude::*;
mod announcer;
mod cache;
mod db;
pub(crate) mod embeds;
mod hook;
pub use announcer::OsuAnnouncer;
use db::OsuUser;
use db::{OsuLastBeatmap, OsuSavedUsers};
use embeds::{beatmap_embed, score_embed, user_embed};
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]
#[prefix = "osu"]
#[description = "osu! related commands."]
#[commands(std, taiko, catch, mania, save, recent, last, check, top)]
struct Osu;
#[command]
#[aliases("osu", "osu!")]
#[description = "Receive information about an user in osu!std mode."]
#[usage = "[username or user_id = your saved username]"]
#[max_args(1)]
pub fn std(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult {
get_user(ctx, msg, args, Mode::Std)
}
#[command]
#[aliases("osu!taiko")]
#[description = "Receive information about an user in osu!taiko mode."]
#[usage = "[username or user_id = your saved username]"]
#[max_args(1)]
pub fn taiko(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult {
get_user(ctx, msg, args, Mode::Taiko)
}
#[command]
#[aliases("fruits", "osu!catch", "ctb")]
#[description = "Receive information about an user in osu!catch mode."]
#[usage = "[username or user_id = your saved username]"]
#[max_args(1)]
pub fn catch(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult {
get_user(ctx, msg, args, Mode::Catch)
}
#[command]
#[aliases("osu!mania")]
#[description = "Receive information about an user in osu!mania mode."]
#[usage = "[username or user_id = your saved username]"]
#[max_args(1)]
pub fn mania(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult {
get_user(ctx, msg, args, Mode::Mania)
}
pub(crate) struct BeatmapWithMode(pub Beatmap, pub Mode);
impl BeatmapWithMode {
/// Whether this beatmap-with-mode is a converted beatmap.
fn is_converted(&self) -> bool {
self.0.mode != self.1
}
fn mode(&self) -> Mode {
self.1
}
}
impl AsRef<Beatmap> for BeatmapWithMode {
fn as_ref(&self) -> &Beatmap {
&self.0
}
}
#[command]
#[description = "Save the given username as your username."]
#[usage = "[username or user_id]"]
#[num_args(1)]
pub fn save(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let osu = ctx.data.get_cloned::<OsuClient>();
let user = args.single::<String>()?;
let user: Option<User> = osu.user(UserID::Auto(user), |f| f)?;
match user {
Some(u) => {
let db = OsuSavedUsers::open(&*ctx.data.read());
let mut db = db.borrow_mut()?;
db.insert(
msg.author.id,
OsuUser {
id: u.id,
last_update: chrono::Utc::now(),
},
);
msg.reply(
&ctx,
MessageBuilder::new()
.push("user has been set to ")
.push_mono_safe(u.username)
.build(),
)?;
}
None => {
msg.reply(&ctx, "user not found...")?;
}
}
Ok(())
}
struct ModeArg(Mode);
impl FromStr for ModeArg {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(ModeArg(match s {
"std" => Mode::Std,
"taiko" => Mode::Taiko,
"catch" => Mode::Catch,
"mania" => Mode::Mania,
_ => return Err(format!("Unknown mode {}", s)),
}))
}
}
enum UsernameArg {
Tagged(UserId),
Raw(String),
}
impl UsernameArg {
fn to_user_id_query(s: Option<Self>, 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,
None => msg.author.id,
};
let db = OsuSavedUsers::open(data);
let db = db.borrow()?;
db.get(&id)
.cloned()
.map(|u| UserID::ID(u.id))
.ok_or(Error::from("No saved account found"))
}
}
impl FromStr for UsernameArg {
type Err = String;
fn from_str(s: &str) -> Result<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 {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !s.starts_with("#") {
Err(Error::from("Not an order"))
} else {
let v = s.split_at("#".len()).1.parse()?;
Ok(Nth(v))
}
}
}
#[command]
#[description = "Gets an user's recent play"]
#[usage = "#[the nth recent play = 1] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"]
#[example = "#1 / taiko / natsukagami"]
#[max_args(3)]
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 osu = ctx.data.get_cloned::<OsuClient>();
let user = osu
.user(user, |f| f.mode(mode))?
.ok_or(Error::from("User not found"))?;
let recent_play = osu
.user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(nth))?
.into_iter()
.last()
.ok_or(Error::from("No such play"))?;
let beatmap = osu
.beatmaps(BeatmapRequestKind::Beatmap(recent_play.beatmap_id), |f| {
f.mode(mode, true)
})?
.into_iter()
.next()
.map(|v| BeatmapWithMode(v, mode))
.unwrap();
msg.channel_id.send_message(&ctx, |m| {
m.content(format!(
"{}: here is the play that you requested",
msg.author
))
.embed(|m| score_embed(&recent_play, &beatmap, &user, None, m))
})?;
// Save the beatmap...
cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?;
Ok(())
}
#[command]
#[description = "Show information from the last queried beatmap."]
#[num_args(0)]
pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult {
let b = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?;
match b {
Some(BeatmapWithMode(b, m)) => {
msg.channel_id.send_message(&ctx, |f| {
f.content(format!(
"{}: here is the beatmap you requested!",
msg.author
))
.embed(|c| beatmap_embed(&b, m, c))
})?;
}
None => {
msg.reply(&ctx, "No beatmap was queried on this channel.")?;
}
}
Ok(())
}
#[command]
#[aliases("c", "chk")]
#[description = "Check your own or someone else's best record on the last beatmap."]
#[max_args(1)]
pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let bm = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?;
match bm {
None => {
msg.reply(&ctx, "No beatmap queried on this channel.")?;
}
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 osu = ctx.data.get_cloned::<OsuClient>();
let user = osu
.user(user, |f| f)?
.ok_or(Error::from("User not found"))?;
let scores = osu.scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m))?;
if scores.is_empty() {
msg.reply(&ctx, "No scores found")?;
}
for score in scores.into_iter() {
msg.channel_id.send_message(&ctx, |c| {
c.embed(|m| score_embed(&score, &bm, &user, None, m))
})?;
}
}
}
Ok(())
}
#[command]
#[description = "Get the n-th top record of an user."]
#[usage = "#[n-th = 1] / [mode (std, taiko, catch, mania) = std / [username or user_id = your saved user id]"]
#[example = "#2 / taiko / natsukagami"]
#[max_args(3)]
pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let nth = args.single::<Nth>().unwrap_or(Nth(1)).0;
let mode = args
.single::<ModeArg>()
.map(|ModeArg(t)| t)
.unwrap_or(Mode::Std);
let user =
UsernameArg::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))?
.ok_or(Error::from("User not found"))?;
let top_play = osu.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(nth))?;
let rank = top_play.len() as u8;
let top_play = top_play
.into_iter()
.last()
.ok_or(Error::from("No such play"))?;
let beatmap = osu
.beatmaps(BeatmapRequestKind::Beatmap(top_play.beatmap_id), |f| {
f.mode(mode, true)
})?
.into_iter()
.next()
.map(|v| BeatmapWithMode(v, mode))
.unwrap();
msg.channel_id.send_message(&ctx, |m| {
m.content(format!(
"{}: here is the play that you requested",
msg.author
))
.embed(|m| score_embed(&top_play, &beatmap, &user, Some(rank), m))
})?;
// Save the beatmap...
cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?;
Ok(())
}
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 osu = ctx.data.get_cloned::<OsuClient>();
let user = osu.user(user, |f| f.mode(mode))?;
match user {
Some(u) => {
let best = osu
.user_best(UserID::ID(u.id), |f| f.limit(1).mode(mode))?
.into_iter()
.next()
.map(|m| {
osu.beatmaps(BeatmapRequestKind::Beatmap(m.beatmap_id), |f| {
f.mode(mode, true)
})
.map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode)))
})
.transpose()?;
msg.channel_id.send_message(&ctx, |m| {
m.content(format!(
"{}: here is the user that you requested",
msg.author
))
.embed(|m| user_embed(u, best, m))
})
}
None => msg.reply(&ctx, "🔍 user not found!"),
}?;
Ok(())
}

View file

@ -1,5 +1,5 @@
pub mod discord;
pub mod models;
pub mod request;
#[cfg(test)]