Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
7 changes: 7 additions & 0 deletions src/commands/reputation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
160 changes: 160 additions & 0 deletions src/commands/reputation/cmd_leaderboard.rs
Original file line number Diff line number Diff line change
@@ -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 { &[][..] };
Comment on lines +127 to +129
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude bottom summary rows from detail pages

Detail pagination currently slices from SUMMARY_TOP through entries.len() (start..end), so once there are more than SUMMARY_TOP + SUMMARY_BOTTOM users, the final detail page(s) include the same bottom 5 users already shown on the summary page. For example, with 25 users, page 2 repeats ranks 21–25 instead of only showing the “in-between” users. This makes the paginated output inconsistent and duplicates leaderboard rows; cap detailed slices (and page count) at entries.len() - SUMMARY_BOTTOM when summary mode is active.

Useful? React with 👍 / 👎.


ReputationEmbed::LeaderboardPage {
entries: slice,
start_rank: start,
total_entries: entries.len(),
}
.to_embed()
}
}

fn get_nav_components(
page: usize,
total_pages: usize,
) -> Vec<CreateActionRow> {
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])]
}
103 changes: 102 additions & 1 deletion src/embeds/reputation/rep_embed.rs
Original file line number Diff line number Diff line change
@@ -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> {
Expand All @@ -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,
}

Expand Down Expand Up @@ -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<String> = 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")
Expand All @@ -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::<Vec<_>>()
.join("\n")
}
Loading