diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-19 19:28:39 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-19 19:28:39 +0200 |
| commit | 11507d00fb774e59afe2c304a6922f875865caee (patch) | |
| tree | edb7350e9cb89bc37501bf1248770d9384c787b1 /src/commands/leaderboard.rs | |
| parent | fix: clear voice_activity upon starting (diff) | |
| download | senator-sperma-11507d00fb774e59afe2c304a6922f875865caee.tar.gz senator-sperma-11507d00fb774e59afe2c304a6922f875865caee.zip | |
feat: leaderboads
Diffstat (limited to '')
| -rw-r--r-- | src/commands/leaderboard.rs | 329 |
1 files changed, 329 insertions, 0 deletions
diff --git a/src/commands/leaderboard.rs b/src/commands/leaderboard.rs new file mode 100644 index 0000000..94d67d1 --- /dev/null +++ b/src/commands/leaderboard.rs | |||
| @@ -0,0 +1,329 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | |||
| 3 | use crate::Data; | ||
| 4 | use crate::database; | ||
| 5 | use crate::error::{Error, Result}; | ||
| 6 | |||
| 7 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 8 | |||
| 9 | const PAGE_SIZE: i64 = 10; | ||
| 10 | |||
| 11 | /// Format seconds to human-readable duration | ||
| 12 | fn format_duration(seconds: f64) -> String { | ||
| 13 | let total = seconds as u64; | ||
| 14 | let hours = total / 3600; | ||
| 15 | let minutes = (total % 3600) / 60; | ||
| 16 | if hours > 0 { | ||
| 17 | format!("{}h {}m", hours, minutes) | ||
| 18 | } else { | ||
| 19 | format!("{}m", minutes) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Format number with thousands separator | ||
| 24 | fn format_number(n: i64) -> String { | ||
| 25 | let s = n.to_string(); | ||
| 26 | let mut result = String::new(); | ||
| 27 | for (i, c) in s.chars().rev().enumerate() { | ||
| 28 | if i > 0 && i % 3 == 0 { | ||
| 29 | result.push(','); | ||
| 30 | } | ||
| 31 | result.push(c); | ||
| 32 | } | ||
| 33 | result.chars().rev().collect() | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Get rank suffix (1st, 2nd, 3rd, etc.) | ||
| 37 | fn rank_suffix(rank: i64) -> String { | ||
| 38 | let suffix = if rank % 100 >= 11 && rank % 100 <= 13 { | ||
| 39 | "th" | ||
| 40 | } else { | ||
| 41 | match rank % 10 { | ||
| 42 | 1 => "st", | ||
| 43 | 2 => "nd", | ||
| 44 | 3 => "rd", | ||
| 45 | _ => "th", | ||
| 46 | } | ||
| 47 | }; | ||
| 48 | format!("{}{}", rank, suffix) | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Resolve user ID to display name | ||
| 52 | async fn resolve_display_name(ctx: Context<'_>, user_id: &str) -> String { | ||
| 53 | let id = user_id.parse::<u64>().unwrap_or(0); | ||
| 54 | let user_id_obj = serenity::UserId::new(id); | ||
| 55 | |||
| 56 | // Try to get from cache first | ||
| 57 | if let Some(user) = ctx.cache().user(user_id_obj) { | ||
| 58 | return user.name.clone(); | ||
| 59 | } | ||
| 60 | |||
| 61 | // Fallback to API | ||
| 62 | if let Ok(user) = user_id_obj.to_user(ctx.http()).await { | ||
| 63 | return user.name; | ||
| 64 | } | ||
| 65 | |||
| 66 | user_id.to_string() | ||
| 67 | } | ||
| 68 | |||
| 69 | /// Create the stats embed for a user | ||
| 70 | async fn create_stats_embed( | ||
| 71 | ctx: Context<'_>, | ||
| 72 | user_id: &str, | ||
| 73 | stats: &database::UserStats, | ||
| 74 | ) -> serenity::CreateEmbed { | ||
| 75 | let display_name = resolve_display_name(ctx, user_id).await; | ||
| 76 | |||
| 77 | // Get ranks for each metric | ||
| 78 | let msg_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "messages").await.unwrap_or(0); | ||
| 79 | let active_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "active").await.unwrap_or(0); | ||
| 80 | let afk_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "afk").await.unwrap_or(0); | ||
| 81 | let total_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "total").await.unwrap_or(0); | ||
| 82 | |||
| 83 | let rank_str = |rank: i64| if rank > 0 { format!(" (#{})", rank_suffix(rank)) } else { " (unranked)".to_string() }; | ||
| 84 | |||
| 85 | let total_voice = stats.total_voice_active + stats.total_voice_afk; | ||
| 86 | |||
| 87 | serenity::CreateEmbed::default() | ||
| 88 | .title(format!("Stats for {}", display_name)) | ||
| 89 | .color(serenity::Color::from_rgb(59, 130, 246)) | ||
| 90 | .field( | ||
| 91 | "Messages", | ||
| 92 | format!("{}{}", format_number(stats.message_count), rank_str(msg_rank)), | ||
| 93 | true, | ||
| 94 | ) | ||
| 95 | .field( | ||
| 96 | "Voice Active", | ||
| 97 | format!("{}{}", format_duration(stats.total_voice_active), rank_str(active_rank)), | ||
| 98 | true, | ||
| 99 | ) | ||
| 100 | .field( | ||
| 101 | "Voice AFK", | ||
| 102 | format!("{}{}", format_duration(stats.total_voice_afk), rank_str(afk_rank)), | ||
| 103 | true, | ||
| 104 | ) | ||
| 105 | .field( | ||
| 106 | "Total Voice", | ||
| 107 | format!("{}{}", format_duration(total_voice), rank_str(total_rank)), | ||
| 108 | true, | ||
| 109 | ) | ||
| 110 | .footer(serenity::CreateEmbedFooter::new("Use /leaderboard messages|active|afk|total to see full leaderboard")) | ||
| 111 | } | ||
| 112 | |||
| 113 | /// Create the leaderboard embed | ||
| 114 | async fn create_leaderboard_embed( | ||
| 115 | ctx: Context<'_>, | ||
| 116 | metric: &str, | ||
| 117 | ascending: bool, | ||
| 118 | page: i64, | ||
| 119 | ) -> Result<(serenity::CreateEmbed, i64)> { | ||
| 120 | let offset = (page - 1) * PAGE_SIZE; | ||
| 121 | let (entries, total_count) = database::get_leaderboard( | ||
| 122 | &ctx.data().db_pool, | ||
| 123 | metric, | ||
| 124 | ascending, | ||
| 125 | PAGE_SIZE, | ||
| 126 | offset, | ||
| 127 | ) | ||
| 128 | .await?; | ||
| 129 | |||
| 130 | let total_pages = (total_count as f64 / PAGE_SIZE as f64).ceil() as i64; | ||
| 131 | let total_pages = total_pages.max(1); | ||
| 132 | |||
| 133 | let title = match metric { | ||
| 134 | "messages" => "Messages Leaderboard", | ||
| 135 | "active" => "Voice Active Leaderboard", | ||
| 136 | "afk" => "Voice AFK Leaderboard", | ||
| 137 | "total" => "Total Voice Leaderboard", | ||
| 138 | _ => "Leaderboard", | ||
| 139 | }; | ||
| 140 | |||
| 141 | let direction = if ascending { "↑" } else { "↓" }; | ||
| 142 | |||
| 143 | let mut description = String::new(); | ||
| 144 | for (i, entry) in entries.iter().enumerate() { | ||
| 145 | let rank = offset + i as i64 + 1; | ||
| 146 | let display_name = resolve_display_name(ctx, &entry.user_id).await; | ||
| 147 | |||
| 148 | let value = match metric { | ||
| 149 | "messages" => format_number(entry.message_count), | ||
| 150 | "active" => format_duration(entry.total_voice_active), | ||
| 151 | "afk" => format_duration(entry.total_voice_afk), | ||
| 152 | "total" => format_duration(entry.total_voice_active + entry.total_voice_afk), | ||
| 153 | _ => format_number(entry.message_count), | ||
| 154 | }; | ||
| 155 | |||
| 156 | description.push_str(&format!("**#{}** — {} — {}\n", rank_suffix(rank), display_name, value)); | ||
| 157 | } | ||
| 158 | |||
| 159 | if description.is_empty() { | ||
| 160 | description = "No entries yet.".to_string(); | ||
| 161 | } | ||
| 162 | |||
| 163 | // Get current user's rank and stats for footer | ||
| 164 | let user_id = ctx.author().id.to_string(); | ||
| 165 | let user_stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; | ||
| 166 | let user_rank = database::get_user_rank(&ctx.data().db_pool, &user_id, metric).await.unwrap_or(0); | ||
| 167 | |||
| 168 | let footer_text = if let Some(stats) = &user_stats { | ||
| 169 | let user_value = match metric { | ||
| 170 | "messages" => format_number(stats.message_count), | ||
| 171 | "active" => format_duration(stats.total_voice_active), | ||
| 172 | "afk" => format_duration(stats.total_voice_afk), | ||
| 173 | "total" => format_duration(stats.total_voice_active + stats.total_voice_afk), | ||
| 174 | _ => format_number(stats.message_count), | ||
| 175 | }; | ||
| 176 | if user_rank > 0 { | ||
| 177 | format!("Your rank: #{} ({})", rank_suffix(user_rank), user_value) | ||
| 178 | } else { | ||
| 179 | "You are not ranked yet".to_string() | ||
| 180 | } | ||
| 181 | } else { | ||
| 182 | "You are not ranked yet".to_string() | ||
| 183 | }; | ||
| 184 | |||
| 185 | let embed = serenity::CreateEmbed::default() | ||
| 186 | .title(format!("{} {}", title, direction)) | ||
| 187 | .description(description) | ||
| 188 | .color(serenity::Color::from_rgb(59, 130, 246)) | ||
| 189 | .footer(serenity::CreateEmbedFooter::new(footer_text)) | ||
| 190 | .field("Page", format!("{}/{}", page, total_pages), true); | ||
| 191 | |||
| 192 | Ok((embed, total_pages)) | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Show your personal stats | ||
| 196 | #[poise::command(slash_command)] | ||
| 197 | pub async fn me(ctx: Context<'_>) -> Result<()> { | ||
| 198 | let user_id = ctx.author().id.to_string(); | ||
| 199 | let stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; | ||
| 200 | |||
| 201 | let embed = if let Some(stats) = &stats { | ||
| 202 | create_stats_embed(ctx, &user_id, stats).await | ||
| 203 | } else { | ||
| 204 | // User has no stats yet - show zeros | ||
| 205 | let empty_stats = database::UserStats { | ||
| 206 | user_id: user_id.clone(), | ||
| 207 | message_count: 0, | ||
| 208 | total_voice_active: 0.0, | ||
| 209 | total_voice_afk: 0.0, | ||
| 210 | }; | ||
| 211 | create_stats_embed(ctx, &user_id, &empty_stats).await | ||
| 212 | }; | ||
| 213 | |||
| 214 | ctx.send( | ||
| 215 | poise::CreateReply::default() | ||
| 216 | .embed(embed), | ||
| 217 | ) | ||
| 218 | .await?; | ||
| 219 | Ok(()) | ||
| 220 | } | ||
| 221 | |||
| 222 | /// Show messages leaderboard | ||
| 223 | #[poise::command(slash_command)] | ||
| 224 | pub async fn messages( | ||
| 225 | ctx: Context<'_>, | ||
| 226 | #[description = "Sort order (default: descending)"] | ||
| 227 | #[autocomplete = "sort_order_autocomplete"] | ||
| 228 | order: Option<String>, | ||
| 229 | ) -> Result<()> { | ||
| 230 | let ascending = order.as_deref() == Some("ascending"); | ||
| 231 | let page = 1; | ||
| 232 | |||
| 233 | let (embed, _total_pages) = create_leaderboard_embed(ctx, "messages", ascending, page).await?; | ||
| 234 | |||
| 235 | ctx.send( | ||
| 236 | poise::CreateReply::default() | ||
| 237 | .embed(embed), | ||
| 238 | ) | ||
| 239 | .await?; | ||
| 240 | Ok(()) | ||
| 241 | } | ||
| 242 | |||
| 243 | /// Show voice active leaderboard | ||
| 244 | #[poise::command(slash_command)] | ||
| 245 | pub async fn active( | ||
| 246 | ctx: Context<'_>, | ||
| 247 | #[description = "Sort order (default: descending)"] | ||
| 248 | #[autocomplete = "sort_order_autocomplete"] | ||
| 249 | order: Option<String>, | ||
| 250 | ) -> Result<()> { | ||
| 251 | let ascending = order.as_deref() == Some("ascending"); | ||
| 252 | let page = 1; | ||
| 253 | |||
| 254 | let (embed, _total_pages) = create_leaderboard_embed(ctx, "active", ascending, page).await?; | ||
| 255 | |||
| 256 | ctx.send( | ||
| 257 | poise::CreateReply::default() | ||
| 258 | .embed(embed), | ||
| 259 | ) | ||
| 260 | .await?; | ||
| 261 | Ok(()) | ||
| 262 | } | ||
| 263 | |||
| 264 | /// Show voice AFK leaderboard | ||
| 265 | #[poise::command(slash_command)] | ||
| 266 | pub async fn afk( | ||
| 267 | ctx: Context<'_>, | ||
| 268 | #[description = "Sort order (default: descending)"] | ||
| 269 | #[autocomplete = "sort_order_autocomplete"] | ||
| 270 | order: Option<String>, | ||
| 271 | ) -> Result<()> { | ||
| 272 | let ascending = order.as_deref() == Some("ascending"); | ||
| 273 | let page = 1; | ||
| 274 | |||
| 275 | let (embed, _total_pages) = create_leaderboard_embed(ctx, "afk", ascending, page).await?; | ||
| 276 | |||
| 277 | ctx.send( | ||
| 278 | poise::CreateReply::default() | ||
| 279 | .embed(embed), | ||
| 280 | ) | ||
| 281 | .await?; | ||
| 282 | Ok(()) | ||
| 283 | } | ||
| 284 | |||
| 285 | /// Show total voice leaderboard | ||
| 286 | #[poise::command(slash_command)] | ||
| 287 | pub async fn total( | ||
| 288 | ctx: Context<'_>, | ||
| 289 | #[description = "Sort order (default: descending)"] | ||
| 290 | #[autocomplete = "sort_order_autocomplete"] | ||
| 291 | order: Option<String>, | ||
| 292 | ) -> Result<()> { | ||
| 293 | let ascending = order.as_deref() == Some("ascending"); | ||
| 294 | let page = 1; | ||
| 295 | |||
| 296 | let (embed, _total_pages) = create_leaderboard_embed(ctx, "total", ascending, page).await?; | ||
| 297 | |||
| 298 | ctx.send( | ||
| 299 | poise::CreateReply::default() | ||
| 300 | .embed(embed), | ||
| 301 | ) | ||
| 302 | .await?; | ||
| 303 | Ok(()) | ||
| 304 | } | ||
| 305 | |||
| 306 | /// Autocomplete for sort order | ||
| 307 | async fn sort_order_autocomplete( | ||
| 308 | _ctx: Context<'_>, | ||
| 309 | _partial: &str, | ||
| 310 | ) -> Vec<serenity::AutocompleteChoice> { | ||
| 311 | vec![ | ||
| 312 | serenity::AutocompleteChoice::new("Descending (highest first)", "descending"), | ||
| 313 | serenity::AutocompleteChoice::new("Ascending (lowest first)", "ascending"), | ||
| 314 | ] | ||
| 315 | } | ||
| 316 | |||
| 317 | /// Leaderboard commands | ||
| 318 | #[poise::command( | ||
| 319 | slash_command, | ||
| 320 | subcommands("me", "messages", "active", "afk", "total"), | ||
| 321 | subcommand_required | ||
| 322 | )] | ||
| 323 | pub async fn leaderboard(_ctx: Context<'_>) -> Result<()> { | ||
| 324 | Ok(()) | ||
| 325 | } | ||
| 326 | |||
| 327 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 328 | vec![leaderboard()] | ||
| 329 | } | ||
