use poise::serenity_prelude as serenity; use crate::Data; use crate::database; use crate::error::{Error, Result}; type Context<'a> = poise::Context<'a, Data, Error>; const PAGE_SIZE: i64 = 10; /// Format seconds to human-readable duration fn format_duration(seconds: f64) -> String { let total = seconds as u64; let hours = total / 3600; let minutes = (total % 3600) / 60; if hours > 0 { format!("{}h {}m", hours, minutes) } else { format!("{}m", minutes) } } /// Format number with thousands separator fn format_number(n: i64) -> String { let s = n.to_string(); let mut result = String::new(); for (i, c) in s.chars().rev().enumerate() { if i > 0 && i % 3 == 0 { result.push(','); } result.push(c); } result.chars().rev().collect() } /// Get rank suffix (1st, 2nd, 3rd, etc.) fn rank_suffix(rank: i64) -> String { let suffix = if rank % 100 >= 11 && rank % 100 <= 13 { "th" } else { match rank % 10 { 1 => "st", 2 => "nd", 3 => "rd", _ => "th", } }; format!("{}{}", rank, suffix) } /// Resolve user ID to display name async fn resolve_display_name(ctx: Context<'_>, user_id: &str) -> String { let id = user_id.parse::().unwrap_or(0); let user_id_obj = serenity::UserId::new(id); // Try to get from cache first if let Some(user) = ctx.cache().user(user_id_obj) { return user.name.clone(); } // Fallback to API if let Ok(user) = user_id_obj.to_user(ctx.http()).await { return user.name; } user_id.to_string() } /// Create the stats embed for a user async fn create_stats_embed( ctx: Context<'_>, user_id: &str, stats: &database::UserStats, ) -> serenity::CreateEmbed { let display_name = resolve_display_name(ctx, user_id).await; // Get ranks for each metric let msg_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "messages").await.unwrap_or(0); let active_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "active").await.unwrap_or(0); let afk_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "afk").await.unwrap_or(0); let total_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "total").await.unwrap_or(0); let rank_str = |rank: i64| if rank > 0 { format!(" (#{})", rank_suffix(rank)) } else { " (unranked)".to_string() }; let total_voice = stats.total_voice_active + stats.total_voice_afk; serenity::CreateEmbed::default() .title(format!("Stats for {}", display_name)) .color(serenity::Color::from_rgb(59, 130, 246)) .field( "Messages", format!("{}{}", format_number(stats.message_count), rank_str(msg_rank)), true, ) .field( "Voice Active", format!("{}{}", format_duration(stats.total_voice_active), rank_str(active_rank)), true, ) .field( "Voice AFK", format!("{}{}", format_duration(stats.total_voice_afk), rank_str(afk_rank)), true, ) .field( "Total Voice", format!("{}{}", format_duration(total_voice), rank_str(total_rank)), true, ) .footer(serenity::CreateEmbedFooter::new("Use /leaderboard messages|active|afk|total to see full leaderboard")) } /// Create the leaderboard embed async fn create_leaderboard_embed( ctx: Context<'_>, metric: &str, ascending: bool, page: i64, ) -> Result<(serenity::CreateEmbed, i64)> { let offset = (page - 1) * PAGE_SIZE; let (entries, total_count) = database::get_leaderboard( &ctx.data().db_pool, metric, ascending, PAGE_SIZE, offset, ) .await?; let total_pages = (total_count as f64 / PAGE_SIZE as f64).ceil() as i64; let total_pages = total_pages.max(1); let title = match metric { "messages" => "Messages Leaderboard", "active" => "Voice Active Leaderboard", "afk" => "Voice AFK Leaderboard", "total" => "Total Voice Leaderboard", _ => "Leaderboard", }; let direction = if ascending { "↑" } else { "↓" }; let mut description = String::new(); for (i, entry) in entries.iter().enumerate() { let rank = offset + i as i64 + 1; let display_name = resolve_display_name(ctx, &entry.user_id).await; let value = match metric { "messages" => format_number(entry.message_count), "active" => format_duration(entry.total_voice_active), "afk" => format_duration(entry.total_voice_afk), "total" => format_duration(entry.total_voice_active + entry.total_voice_afk), _ => format_number(entry.message_count), }; description.push_str(&format!("**#{}** — {} — {}\n", rank_suffix(rank), display_name, value)); } if description.is_empty() { description = "No entries yet.".to_string(); } // Get current user's rank and stats for footer let user_id = ctx.author().id.to_string(); let user_stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; let user_rank = database::get_user_rank(&ctx.data().db_pool, &user_id, metric).await.unwrap_or(0); let footer_text = if let Some(stats) = &user_stats { let user_value = match metric { "messages" => format_number(stats.message_count), "active" => format_duration(stats.total_voice_active), "afk" => format_duration(stats.total_voice_afk), "total" => format_duration(stats.total_voice_active + stats.total_voice_afk), _ => format_number(stats.message_count), }; if user_rank > 0 { format!("Your rank: #{} ({})", rank_suffix(user_rank), user_value) } else { "You are not ranked yet".to_string() } } else { "You are not ranked yet".to_string() }; let embed = serenity::CreateEmbed::default() .title(format!("{} {}", title, direction)) .description(description) .color(serenity::Color::from_rgb(59, 130, 246)) .footer(serenity::CreateEmbedFooter::new(footer_text)) .field("Page", format!("{}/{}", page, total_pages), true); Ok((embed, total_pages)) } /// Show your personal stats #[poise::command(slash_command)] pub async fn me(ctx: Context<'_>) -> Result<()> { let user_id = ctx.author().id.to_string(); let stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; let embed = if let Some(stats) = &stats { create_stats_embed(ctx, &user_id, stats).await } else { // User has no stats yet - show zeros let empty_stats = database::UserStats { user_id: user_id.clone(), message_count: 0, total_voice_active: 0.0, total_voice_afk: 0.0, }; create_stats_embed(ctx, &user_id, &empty_stats).await }; ctx.send( poise::CreateReply::default() .embed(embed), ) .await?; Ok(()) } /// Show messages leaderboard #[poise::command(slash_command)] pub async fn messages( ctx: Context<'_>, #[description = "Sort order (default: descending)"] #[autocomplete = "sort_order_autocomplete"] order: Option, ) -> Result<()> { let ascending = order.as_deref() == Some("ascending"); let page = 1; let (embed, _total_pages) = create_leaderboard_embed(ctx, "messages", ascending, page).await?; ctx.send( poise::CreateReply::default() .embed(embed), ) .await?; Ok(()) } /// Show voice active leaderboard #[poise::command(slash_command)] pub async fn active( ctx: Context<'_>, #[description = "Sort order (default: descending)"] #[autocomplete = "sort_order_autocomplete"] order: Option, ) -> Result<()> { let ascending = order.as_deref() == Some("ascending"); let page = 1; let (embed, _total_pages) = create_leaderboard_embed(ctx, "active", ascending, page).await?; ctx.send( poise::CreateReply::default() .embed(embed), ) .await?; Ok(()) } /// Show voice AFK leaderboard #[poise::command(slash_command)] pub async fn afk( ctx: Context<'_>, #[description = "Sort order (default: descending)"] #[autocomplete = "sort_order_autocomplete"] order: Option, ) -> Result<()> { let ascending = order.as_deref() == Some("ascending"); let page = 1; let (embed, _total_pages) = create_leaderboard_embed(ctx, "afk", ascending, page).await?; ctx.send( poise::CreateReply::default() .embed(embed), ) .await?; Ok(()) } /// Show total voice leaderboard #[poise::command(slash_command)] pub async fn total( ctx: Context<'_>, #[description = "Sort order (default: descending)"] #[autocomplete = "sort_order_autocomplete"] order: Option, ) -> Result<()> { let ascending = order.as_deref() == Some("ascending"); let page = 1; let (embed, _total_pages) = create_leaderboard_embed(ctx, "total", ascending, page).await?; ctx.send( poise::CreateReply::default() .embed(embed), ) .await?; Ok(()) } /// Autocomplete for sort order async fn sort_order_autocomplete( _ctx: Context<'_>, _partial: &str, ) -> Vec { vec![ serenity::AutocompleteChoice::new("Descending (highest first)", "descending"), serenity::AutocompleteChoice::new("Ascending (lowest first)", "ascending"), ] } /// Leaderboard commands #[poise::command( slash_command, subcommands("me", "messages", "active", "afk", "total"), subcommand_required )] pub async fn leaderboard(_ctx: Context<'_>) -> Result<()> { Ok(()) } pub fn commands() -> Vec> { vec![leaderboard()] }