use std::cmp::Ordering; use super::*; use cache::save_beatmap; use display::display_beatmapset; use embeds::ScoreEmbedBuilder; use link_parser::EmbedType; use poise::{ChoiceParameter, CreateReply}; use serenity::all::User; use server_rank::get_leaderboard_from_embed; /// osu!-related command group. #[poise::command( slash_command, subcommands( "profile", "top", "recent", "pinned", "save", "forcesave", "beatmap", "check", "ranks", "leaderboard", "clear_cache" ), install_context = "Guild|User", interaction_context = "Guild|BotDm|PrivateChannel" )] pub async fn osu(_ctx: CmdContext<'_, U>) -> Result<()> { Ok(()) } /// Returns top plays for a given player. /// /// If no osu! username is given, defaults to the currently registered user. #[poise::command(slash_command)] async fn top( ctx: CmdContext<'_, U>, #[description = "Index of the score"] #[min = 1] #[max = 100] index: Option, #[description = "Score listing style"] style: Option, #[description = "Game mode"] mode: Option, #[description = "osu! username"] username: Option, #[description = "Discord username"] discord_name: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let username_arg = arg_from_username_or_discord(username, discord_name); let args = ListingArgs::from_params( env, index, style.unwrap_or(ScoreListStyle::Table), mode, username_arg, ctx.author().id, ) .await?; ctx.defer().await?; let osu_client = &env.client; let mut plays = osu_client .user_best(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(100)) .await?; plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap()); handle_listing(ctx, plays, args, |nth, b| b.top_record(nth), "top").await } /// Get an user's profile. #[poise::command(slash_command)] async fn profile( ctx: CmdContext<'_, U>, #[description = "Game mode"] #[rename = "mode"] mode_override: Option, #[description = "osu! username"] username: Option, #[description = "Discord username"] discord_name: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let username_arg = arg_from_username_or_discord(username, discord_name); let (mode, user) = user_header_or_default_id(username_arg, env, ctx.author().id).await?; let mode = mode_override.unwrap_or(mode); ctx.defer().await?; let user = env .client .user(&UserID::ID(user.id), |f| f.mode(mode)) .await?; match user { Some(u) => { let ex = UserExtras::from_user(env, &u, mode).await?; ctx.send( CreateReply::default() .content(format!("Here is {}'s **{}** profile!", u.mention(), mode)) .embed(user_embed(u, ex)), ) .await?; } None => { ctx.reply("🔍 user not found!").await?; } }; Ok(()) } /// Returns recent plays from a given player. /// /// If no osu! username is given, defaults to the currently registered user. #[poise::command(slash_command)] async fn recent( ctx: CmdContext<'_, U>, #[description = "Index of the score"] #[min = 1] #[max = 50] index: Option, #[description = "Score listing style"] style: Option, #[description = "Game mode"] mode: Option, #[description = "osu! username"] username: Option, #[description = "Discord username"] discord_name: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let args = arg_from_username_or_discord(username, discord_name); let style = style.unwrap_or(ScoreListStyle::Table); let args = ListingArgs::from_params(env, index, style, mode, args, ctx.author().id).await?; ctx.defer().await?; let osu_client = &env.client; let plays = osu_client .user_recent(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(50)) .await?; handle_listing(ctx, plays, args, |_, b| b, "recent").await } /// Returns pinned plays from a given player. /// /// If no osu! username is given, defaults to the currently registered user. #[poise::command(slash_command)] async fn pinned( ctx: CmdContext<'_, U>, #[description = "Index of the score"] #[min = 1] #[max = 50] index: Option, #[description = "Score listing style"] style: Option, #[description = "Game mode"] mode: Option, #[description = "osu! username"] username: Option, #[description = "Discord username"] discord_name: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let args = arg_from_username_or_discord(username, discord_name); let style = style.unwrap_or(ScoreListStyle::Table); let args = ListingArgs::from_params(env, index, style, mode, args, ctx.author().id).await?; ctx.defer().await?; let osu_client = &env.client; let plays = osu_client .user_pins(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(50)) .await?; handle_listing(ctx, plays, args, |_, b| b, "pinned").await } /// Save your osu! profile into Youmu's database for tracking and quick commands. #[poise::command(slash_command)] pub async fn save( ctx: CmdContext<'_, U>, #[description = "The osu! username to set to"] username: String, ) -> Result<()> { let env = ctx.data().osu_env(); ctx.defer().await?; let (u, mode, score, beatmap, info) = find_save_requirements(env, username).await?; let reply = ctx .clone() .send( CreateReply::default() .content(save_request_message(&u.username, score.beatmap_id, mode)) .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info)) .components(vec![beatmap_components(mode, ctx.guild_id())]), ) .await? .into_message() .await?; handle_save_respond( ctx.serenity_context(), &env, ctx.author().id, reply, &beatmap, u, mode, ) .await?; Ok(()) } /// Force-save an osu! profile into Youmu's database for tracking and quick commands. #[poise::command(slash_command, owners_only)] pub async fn forcesave( ctx: CmdContext<'_, U>, #[description = "The osu! username to set to"] username: String, #[description = "The discord user to assign to"] discord_name: User, ) -> Result<()> { let env = ctx.data().osu_env(); let osu_client = &env.client; ctx.defer().await?; let Some(u) = osu_client .user(&UserID::from_string(username.clone()), |f| f) .await? else { return Err(Error::msg("osu! user not found")); }; add_user(discord_name.id, &u, &env).await?; let ex = UserExtras::from_user(&env, &u, u.preferred_mode).await?; ctx.send( CreateReply::default() .content( MessageBuilder::new() .push("Youmu is now tracking user ") .push(discord_name.mention().to_string()) .push(" with osu! account ") .push_bold_safe(username) .build(), ) .embed(user_embed(u, ex)), ) .await?; Ok(()) } async fn handle_listing( ctx: CmdContext<'_, U>, plays: Vec, listing_args: ListingArgs, transform: impl for<'a> Fn(u8, ScoreEmbedBuilder<'a>) -> ScoreEmbedBuilder<'a>, listing_kind: &'static str, ) -> Result<()> { let env = ctx.data().osu_env(); let ListingArgs { nth, style, mode, user, } = listing_args; match nth { Nth::Nth(nth) => { let Some(play) = plays.get(nth as usize) else { Err(Error::msg("no such play"))? }; let beatmap = env.beatmaps.get_beatmap(play.beatmap_id, mode).await?; let content = env.oppai.get_beatmap(beatmap.beatmap_id).await?; let beatmap = BeatmapWithMode(beatmap, Some(mode)); ctx.send({ CreateReply::default() .content(format!( "Here is the #{} {} play by {}!", nth + 1, listing_kind, user.mention() )) .embed({ let mut b = transform(nth + 1, score_embed(&play, &beatmap, &content, user)); if let Some(rank) = play.global_rank { b = b.world_record(rank as u16); } b.build() }) .components(vec![score_components(ctx.guild_id())]) }) .await?; // Save the beatmap... cache::save_beatmap(&env, ctx.channel_id(), &beatmap).await?; } Nth::All => { let reply = ctx .clone() .reply(format!( "Here are the {} plays by {}!", listing_kind, user.mention() )) .await? .into_message() .await?; style .display_scores(plays, ctx.serenity_context(), ctx.guild_id(), reply) .await?; } } Ok(()) } /// Get information about a beatmap, or the last beatmap mentioned in the channel. #[poise::command(slash_command)] async fn beatmap( ctx: CmdContext<'_, U>, #[description = "A link or shortlink to the beatmap or beatmapset"] map: Option, #[description = "Override the mods on the map"] mods: Option, #[description = "Override the mode of the map"] mode: Option, #[description = "Load the beatmapset instead"] beatmapset: Option, ) -> Result<()> { let env = ctx.data().osu_env(); ctx.defer().await?; let beatmap = parse_map_input(ctx.channel_id(), env, map, mode, beatmapset).await?; // override mods and mode if needed match beatmap { EmbedType::Beatmap(beatmap, bmode, info, bmmods) => { let (beatmap, info, mods) = if mods.is_none() && mode.is_none_or(|v| v == bmode.unwrap_or(beatmap.mode)) { (*beatmap, info, bmmods) } else { let mode = bmode.unwrap_or(beatmap.mode); let mods = match mods { None => bmmods, Some(mods) => mods.to_mods(mode)?, }; let beatmap = env.beatmaps.get_beatmap(beatmap.beatmap_id, mode).await?; let info = env .oppai .get_beatmap(beatmap.beatmap_id) .await? .get_possible_pp_with(mode, &mods); (beatmap, info, mods) }; ctx.send( CreateReply::default() .content(format!("Information for {}", beatmap.mention(mode, &mods))) .embed(beatmap_embed( &beatmap, mode.unwrap_or(beatmap.mode), &mods, &info, )) .components(vec![beatmap_components( mode.unwrap_or(beatmap.mode), ctx.guild_id(), )]), ) .await?; let bmode = beatmap.mode.with_override(mode); save_beatmap( env, ctx.channel_id(), &BeatmapWithMode(beatmap, Some(bmode)), ) .await?; } EmbedType::Beatmapset(vec, _) => { let b0 = &vec[0]; let msg = ctx .clone() .reply(format!("Information for {}", b0.beatmapset_mention())) .await? .into_message() .await?; display_beatmapset( ctx.serenity_context().clone(), vec, mode, mods, ctx.guild_id(), msg, ) .await?; } }; Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq, ChoiceParameter, Default)] enum SortScoreBy { #[default] PP, Score, #[name = "Maximum Combo"] Combo, #[name = "Miss Count"] Miss, Accuracy, } impl SortScoreBy { fn compare(self, a: &Score, b: &Score) -> Ordering { match self { SortScoreBy::PP => { b.pp.unwrap_or(0.0) .partial_cmp(&a.pp.unwrap_or(0.0)) .unwrap() } SortScoreBy::Score => b.normalized_score.cmp(&a.normalized_score), SortScoreBy::Combo => b.max_combo.cmp(&a.max_combo), SortScoreBy::Miss => b.count_miss.cmp(&a.count_miss), SortScoreBy::Accuracy => b.server_accuracy.partial_cmp(&a.server_accuracy).unwrap(), } } } /// Display your or a player's scores for a certain beatmap/beatmapset. #[poise::command(slash_command)] async fn check( ctx: CmdContext<'_, U>, #[description = "A link or shortlink to the beatmap or beatmapset"] map: Option, #[description = "osu! username"] username: Option, #[description = "Discord username"] discord_name: Option, #[description = "Sort scores by"] sort: Option, #[description = "Reverse the sorting order"] reverse: Option, #[description = "Filter the mods on the scores"] mods: Option, #[description = "Filter the gamemode of the scores"] mode: Option, #[description = "Find all scores in the beatmapset instead"] beatmapset: Option, #[description = "Score listing style"] style: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let user = arg_from_username_or_discord(username, discord_name); let args = ListingArgs::from_params( env, None, style.unwrap_or(ScoreListStyle::Grid), mode, user, ctx.author().id, ) .await?; ctx.defer().await?; let embed = parse_map_input(ctx.channel_id(), env, map, mode, beatmapset).await?; let display = embed.mention(); let ordering = sort.unwrap_or_default(); let mut scores = do_check(env, &embed, mods, &args.user).await?; if scores.is_empty() { ctx.reply(format!( "No plays found for {} on {} with the required criteria.", args.user.mention(), display )) .await?; return Ok(()); } scores.sort_unstable_by(|a, b| ordering.compare(a, b)); if reverse == Some(true) { scores.reverse(); } let msg = ctx .clone() .reply(format!( "Here are the plays by {} on {}!", args.user.mention(), display )) .await? .into_message() .await?; let style = style.unwrap_or(if scores.len() <= 5 { ScoreListStyle::Grid } else { ScoreListStyle::Table }); style .display_scores(scores, ctx.serenity_context(), ctx.guild_id(), msg) .await?; Ok(()) } /// Display the rankings of members in the server. #[poise::command(slash_command, guild_only)] async fn ranks( ctx: CmdContext<'_, U>, #[description = "Sort users by"] sort: Option, #[description = "Reverse the ordering"] reverse: Option, #[description = "The gamemode for the rankings"] mode: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let guild = ctx.partial_guild().await.unwrap(); ctx.defer().await?; server_rank::do_server_ranks( ctx.clone().serenity_context(), env, &guild, mode, sort, reverse.unwrap_or(false), |s| async move { let m = ctx.reply(s).await?; Ok(m.into_message().await?) }, ) .await?; Ok(()) } /// Display the leaderboard on a single map of members in the server. #[poise::command(slash_command, guild_only)] async fn leaderboard( ctx: CmdContext<'_, U>, #[description = "The link or shortlink of the map"] map: Option, #[description = "Load the scoreboard for the entire beatmapset"] beatmapset: Option, #[description = "Sort scores by"] sort: Option, #[description = "Reverse the ordering"] reverse: Option, #[description = "Include unranked scores"] unranked: Option, #[description = "Filter the gamemode of the scores"] mode: Option, #[description = "Score listing style"] style: Option, ) -> Result<()> { let env = ctx.data().osu_env(); let guild = ctx.partial_guild().await.unwrap(); let style = style.unwrap_or_default(); let order = sort.unwrap_or_default(); let embed = parse_map_input(ctx.channel_id(), env, map, mode, beatmapset).await?; ctx.defer().await?; let scoreboard_msg = embed.mention(); let (mut scores, show_diff) = get_leaderboard_from_embed( ctx.serenity_context(), &env, embed, None, unranked.unwrap_or(true), order, guild.id, ) .await?; if reverse == Some(true) { scores.reverse(); } if scores.is_empty() { ctx.reply(format!( "No scores have been recorded in **{}** on {}.", guild.name, scoreboard_msg, )) .await?; return Ok(()); } let header = format!( "Here are the top scores of **{}** on {}", guild.name, scoreboard_msg, ); match style { ScoreListStyle::Table => { let reply = ctx.reply(header).await?.into_message().await?; server_rank::display_rankings_table( ctx.serenity_context(), reply, scores, show_diff, sort.unwrap_or_default(), ) .await?; } ScoreListStyle::Grid => { let reply = ctx.reply(header).await?.into_message().await?; style .display_scores( scores.into_iter().map(|s| s.score).collect(), ctx.serenity_context(), Some(guild.id), reply, ) .await?; } } Ok(()) } /// Clear youmu's cache. #[poise::command(slash_command, owners_only)] pub async fn clear_cache( ctx: CmdContext<'_, U>, #[description = "Also clear oppai cache"] clear_oppai: bool, ) -> Result<()> { let env = ctx.data().osu_env(); ctx.defer_ephemeral().await?; env.beatmaps.clear().await?; if clear_oppai { env.oppai.clear().await?; } ctx.reply("Beatmap cache cleared!").await?; Ok(()) } fn arg_from_username_or_discord( username: Option, discord_name: Option, ) -> Option { match (username, discord_name) { (Some(v), _) => Some(UsernameArg::Raw(v)), (_, Some(u)) => Some(UsernameArg::Tagged(u.id)), (None, None) => None, } } async fn parse_map_input( channel_id: serenity::all::ChannelId, env: &OsuEnv, input: Option, mode: Option, beatmapset: Option, ) -> Result { let output = match input { None => { let Some(v) = load_beatmap_from_channel(env, channel_id).await else { return Err(Error::msg("no beatmap mentioned in this channel")); }; v } Some(map) => { if let Ok(id) = map.parse::() { let beatmap = match mode { None => env.beatmaps.get_beatmap_default(id).await, Some(mode) => env.beatmaps.get_beatmap(id, mode).await, }?; let info = env .oppai .get_beatmap(beatmap.beatmap_id) .await? .get_possible_pp_with(beatmap.mode, Mods::NOMOD); return Ok(EmbedType::Beatmap( Box::new(beatmap), None, info, Mods::NOMOD.clone(), )); } let Some(results) = stream::select( link_parser::parse_new_links(env, &map), stream::select( link_parser::parse_old_links(env, &map), link_parser::parse_short_links(env, &map), ), ) .next() .await else { return Err(Error::msg("no beatmap detected in the argument")); }; results.embed } }; // override into beatmapset if needed let output = if beatmapset == Some(true) { match output { EmbedType::Beatmap(beatmap, _, _, _) => { let beatmaps = env .beatmaps .get_beatmapset(beatmap.beatmapset_id, mode) .await?; EmbedType::Beatmapset(beatmaps, mode) } bm @ EmbedType::Beatmapset(_, _) => bm, } } else { output }; Ok(output) }