diff --git a/src/bot.rs b/src/bot.rs index c7fc3a3..5340519 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -134,6 +134,7 @@ impl MusicBotClient { reputation::cmd_plus::add_rep(), reputation::cmd_minus::remove_rep(), reputation::cmd_list::list_rep(), + reputation::cmd_leaderboard::rep_leaderboard(), utility::cmd_rename::rename_context(), ], pre_command: |ctx| { diff --git a/src/commands/reputation.rs b/src/commands/reputation.rs index 2bda348..f88a7d0 100644 --- a/src/commands/reputation.rs +++ b/src/commands/reputation.rs @@ -5,6 +5,7 @@ use crate::embeds::reputation::rep_embed::ReputationEmbed; use crate::service::embed_service::SendEmbed; use time::{Duration, OffsetDateTime}; +pub mod cmd_leaderboard; pub mod cmd_list; pub mod cmd_minus; pub mod cmd_plus; @@ -19,6 +20,12 @@ pub struct Rep { pub created_at: OffsetDateTime, } +pub struct LeaderboardEntry { + pub receiver_id: String, + pub total_rep: i64, + pub log_count: i64, +} + /// Detects if giver spams /// If yes it returns true otherwise false async fn spam_protection( diff --git a/src/commands/reputation/cmd_leaderboard.rs b/src/commands/reputation/cmd_leaderboard.rs new file mode 100644 index 0000000..6176c9c --- /dev/null +++ b/src/commands/reputation/cmd_leaderboard.rs @@ -0,0 +1,160 @@ +use crate::bot::{Context, MusicBotError}; +use crate::commands::reputation::LeaderboardEntry; +use crate::embeds::reputation::rep_embed::ReputationEmbed; +use crate::service::interaction_service::DeferredInteractionStream; +use serenity::all::{ButtonStyle, CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponseFollowup, EditMessage}; +use std::time::Duration; + +const ITEMS_PER_PAGE: usize = 10; +const SUMMARY_TOP: usize = 5; +const SUMMARY_BOTTOM: usize = 5; + +/// Show the reputation leaderboard, ranked by total rep. +#[poise::command(prefix_command, slash_command, aliases("leaderboard", "reptop", "toprep"))] +pub async fn rep_leaderboard(ctx: Context<'_>) -> Result<(), MusicBotError> { + let entries = sqlx::query_as!( + LeaderboardEntry, + r#" + SELECT + receiver_id AS "receiver_id!: String", + COALESCE(SUM(rep_value), 0) AS "total_rep!: i64", + COUNT(*) AS "log_count!: i64" + FROM reputation_logs + GROUP BY receiver_id + ORDER BY SUM(rep_value) DESC, COUNT(*) DESC, receiver_id ASC + "# + ) + .fetch_all(&*ctx.data().database_pool) + .await + .map_err(|e| MusicBotError::InternalError(e.to_string()))?; + + let detail_pages = if entries.len() > SUMMARY_TOP + SUMMARY_BOTTOM { + (entries.len() - SUMMARY_TOP).div_ceil(ITEMS_PER_PAGE) + } else { + 0 + }; + let total_pages = 1 + detail_pages; + let mut current_page = 0; + + let mut message = ctx + .send( + poise::CreateReply::default() + .embed(render_page(&entries, current_page)) + .components(get_nav_components(current_page, total_pages)) + .reply(true), + ) + .await + .map_err(|error| MusicBotError::InternalError(error.to_string()))? + .into_message() + .await + .map_err(|error| MusicBotError::InternalError(error.to_string()))?; + + let mut stream = DeferredInteractionStream::new(ctx.serenity_context(), message.id); + + while let Some(interaction) = stream.next_within(Duration::from_mins(2)).await { + if interaction.user.id != ctx.author().id { + interaction + .create_followup( + ctx.http(), + CreateInteractionResponseFollowup::new() + .content("Only the person who ran this command can navigate the leaderboard.") + .ephemeral(true), + ) + .await + .ok(); + continue; + } + + match interaction.data.custom_id.as_str() { + "page_next" => { + if current_page + 1 < total_pages { + current_page += 1; + } + } + "page_prev" => { + current_page = current_page.saturating_sub(1); + } + _ => continue, + } + + message + .edit( + ctx.serenity_context(), + EditMessage::new() + .embed(render_page(&entries, current_page)) + .components(get_nav_components(current_page, total_pages)), + ) + .await + .map_err(|e| MusicBotError::InternalError(e.to_string()))?; + } + + message + .edit( + ctx.serenity_context(), + EditMessage::new().components(Vec::new()), + ) + .await + .map_err(|e| MusicBotError::InternalError(e.to_string()))?; + + Ok(()) +} + +fn render_page( + entries: &[LeaderboardEntry], + page: usize, +) -> CreateEmbed { + if page == 0 { + let total = entries.len(); + let top_len = SUMMARY_TOP.min(total); + let top = &entries[..top_len]; + + let remaining = total - top_len; + let bottom_len = SUMMARY_BOTTOM.min(remaining); + let bottom_start = total - bottom_len; + let bottom = &entries[bottom_start..]; + let middle_count = remaining - bottom_len; + + ReputationEmbed::LeaderboardSummary { + top, + middle_count, + bottom, + bottom_start_rank: bottom_start, + total_entries: total, + } + .to_embed() + } else { + let detail_page = page - 1; + let start = SUMMARY_TOP + detail_page * ITEMS_PER_PAGE; + let end = (start + ITEMS_PER_PAGE).min(entries.len()); + let slice = if start < entries.len() { &entries[start..end] } else { &[][..] }; + + ReputationEmbed::LeaderboardPage { + entries: slice, + start_rank: start, + total_entries: entries.len(), + } + .to_embed() + } +} + +fn get_nav_components( + page: usize, + total_pages: usize, +) -> Vec { + let prev_btn = CreateButton::new("page_prev") + .label("⬅️ Previous") + .style(ButtonStyle::Primary) + .disabled(page == 0); + + let indicator = CreateButton::new("page_indicator") + .label(format!("{}/{}", page + 1, total_pages)) + .style(ButtonStyle::Secondary) + .disabled(true); + + let next_btn = CreateButton::new("page_next") + .label("Next ➡️") + .style(ButtonStyle::Primary) + .disabled(page + 1 >= total_pages); + + vec![CreateActionRow::Buttons(vec![prev_btn, indicator, next_btn])] +} diff --git a/src/embeds/reputation/rep_embed.rs b/src/embeds/reputation/rep_embed.rs index a67d890..5b29abb 100644 --- a/src/embeds/reputation/rep_embed.rs +++ b/src/embeds/reputation/rep_embed.rs @@ -1,4 +1,4 @@ -use crate::commands::reputation::Rep; +use crate::commands::reputation::{LeaderboardEntry, Rep}; use serenity::all::{CreateEmbed, CreateEmbedFooter, User}; pub enum ReputationEmbed<'a> { @@ -7,6 +7,18 @@ pub enum ReputationEmbed<'a> { PlusRep(&'a RepEmbed<'a>), MinusRep(&'a RepEmbed<'a>), List(&'a [Rep], &'a str, i64, usize), + LeaderboardSummary { + top: &'a [LeaderboardEntry], + middle_count: usize, + bottom: &'a [LeaderboardEntry], + bottom_start_rank: usize, + total_entries: usize, + }, + LeaderboardPage { + entries: &'a [LeaderboardEntry], + start_rank: usize, + total_entries: usize, + }, NotFound, } @@ -72,6 +84,64 @@ impl ReputationEmbed<'_> { } embed } + ReputationEmbed::LeaderboardSummary { + top, + middle_count, + bottom, + bottom_start_rank, + total_entries, + } => { + let mut embed = CreateEmbed::new() + .color(serenity::all::Color::DARK_GOLD) + .title("🏆 Reputation leaderboard"); + + if *total_entries == 0 { + embed = embed + .description("No reputation has been given out yet.") + .footer(CreateEmbedFooter::new("Be the first to !+rep someone.")); + } else { + let mut sections: Vec = Vec::new(); + + if !top.is_empty() { + sections.push(render_entries(top, 0)); + } + + if *middle_count > 0 { + sections.push(format!( + "⋯ *{} more {} in between* ⋯", + middle_count, + if *middle_count == 1 { "user" } else { "users" }, + )); + } + + if !bottom.is_empty() { + sections.push(render_entries(bottom, *bottom_start_rank)); + } + + embed = embed + .description(sections.join("\n\n")) + .footer(CreateEmbedFooter::new(format!("👥 Ranked users: {}", total_entries))); + } + embed + } + ReputationEmbed::LeaderboardPage { + entries, + start_rank, + total_entries, + } => { + let mut embed = CreateEmbed::new() + .color(serenity::all::Color::DARK_GOLD) + .title("🏆 Reputation leaderboard"); + + if entries.is_empty() { + embed = embed.description("No reputation has been given out yet."); + } else { + embed = embed + .description(render_entries(entries, *start_rank)) + .footer(CreateEmbedFooter::new(format!("👥 Ranked users: {}", total_entries))); + } + embed + } ReputationEmbed::NotFound => CreateEmbed::new() .color(serenity::all::Color::DARK_RED) .title("🚫 Not found") @@ -89,3 +159,34 @@ impl ReputationEmbed<'_> { } } } + +fn rank_prefix(rank: usize) -> String { + match rank { + 1 => "🥇".to_string(), + 2 => "🥈".to_string(), + 3 => "🥉".to_string(), + _ => format!("**#{}**", rank), + } +} + +fn render_entries( + entries: &[LeaderboardEntry], + start_rank_zero_based: usize, +) -> String { + entries + .iter() + .enumerate() + .map(|(idx, entry)| { + let rank = start_rank_zero_based + idx + 1; + format!( + "{} <@{}> — `{:+}` ({} {})", + rank_prefix(rank), + entry.receiver_id, + entry.total_rep, + entry.log_count, + if entry.log_count == 1 { "log" } else { "logs" }, + ) + }) + .collect::>() + .join("\n") +}