mirror of
https://github.com/natsukagami/youmubot.git
synced 2025-05-24 17:20:49 +00:00
Split youmubot-osu
This commit is contained in:
parent
03be1a4acc
commit
aec9cd130d
15 changed files with 108 additions and 86 deletions
95
youmubot-osu/src/discord/announcer.rs
Normal file
95
youmubot-osu/src/discord/announcer.rs
Normal 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)
|
||||
}
|
||||
}
|
35
youmubot-osu/src/discord/cache.rs
Normal file
35
youmubot-osu/src/discord/cache.rs
Normal 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)))
|
||||
}
|
22
youmubot-osu/src/discord/db.rs
Normal file
22
youmubot-osu/src/discord/db.rs
Normal 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>,
|
||||
}
|
351
youmubot-osu/src/discord/embeds.rs
Normal file
351
youmubot-osu/src/discord/embeds.rs
Normal 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,
|
||||
)
|
||||
}))
|
||||
}
|
206
youmubot-osu/src/discord/hook.rs
Normal file
206
youmubot-osu/src/discord/hook.rs
Normal 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))
|
||||
}
|
415
youmubot-osu/src/discord/mod.rs
Normal file
415
youmubot-osu/src/discord/mod.rs
Normal 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(())
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
pub mod discord;
|
||||
pub mod models;
|
||||
|
||||
pub mod request;
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue