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 | |
| 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 | ||||
| -rw-r--r-- | src/commands/mod.rs | 2 | ||||
| -rw-r--r-- | src/database/mod.rs | 145 | ||||
| -rw-r--r-- | src/events/mod.rs | 32 |
4 files changed, 507 insertions, 1 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 | } | ||
diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9d6a1d0..12e2a3d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs | |||
| @@ -8,6 +8,7 @@ pub mod cron; | |||
| 8 | pub mod trigger; | 8 | pub mod trigger; |
| 9 | pub mod utilities; | 9 | pub mod utilities; |
| 10 | pub mod autocomplete; | 10 | pub mod autocomplete; |
| 11 | pub mod leaderboard; | ||
| 11 | 12 | ||
| 12 | use crate::Data; | 13 | use crate::Data; |
| 13 | 14 | ||
| @@ -22,5 +23,6 @@ pub fn all_commands() -> Vec<poise::Command<Data, crate::error::Error>> { | |||
| 22 | cmds.extend(cron::commands()); | 23 | cmds.extend(cron::commands()); |
| 23 | cmds.extend(trigger::commands()); | 24 | cmds.extend(trigger::commands()); |
| 24 | cmds.extend(utilities::commands()); | 25 | cmds.extend(utilities::commands()); |
| 26 | cmds.extend(leaderboard::commands()); | ||
| 25 | cmds | 27 | cmds |
| 26 | } | 28 | } |
diff --git a/src/database/mod.rs b/src/database/mod.rs index f2185fb..b68d927 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs | |||
| @@ -101,6 +101,18 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { | |||
| 101 | .execute(pool) | 101 | .execute(pool) |
| 102 | .await?; | 102 | .await?; |
| 103 | 103 | ||
| 104 | sqlx::query( | ||
| 105 | "CREATE TABLE IF NOT EXISTS user_stats ( | ||
| 106 | user_id TEXT PRIMARY KEY, | ||
| 107 | message_count INTEGER NOT NULL DEFAULT 0, | ||
| 108 | total_voice_active REAL NOT NULL DEFAULT 0, | ||
| 109 | total_voice_afk REAL NOT NULL DEFAULT 0, | ||
| 110 | last_message_at TIMESTAMP | ||
| 111 | )", | ||
| 112 | ) | ||
| 113 | .execute(pool) | ||
| 114 | .await?; | ||
| 115 | |||
| 104 | Ok(()) | 116 | Ok(()) |
| 105 | } | 117 | } |
| 106 | 118 | ||
| @@ -156,6 +168,139 @@ pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result< | |||
| 156 | Ok(()) | 168 | Ok(()) |
| 157 | } | 169 | } |
| 158 | 170 | ||
| 171 | // -- User Stats (leaderboard) -- | ||
| 172 | #[derive(Debug, Clone, FromRow)] | ||
| 173 | pub struct UserStats { | ||
| 174 | pub user_id: String, | ||
| 175 | pub message_count: i64, | ||
| 176 | pub total_voice_active: f64, | ||
| 177 | pub total_voice_afk: f64, | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Increment message count for a user. | ||
| 181 | pub async fn increment_message_count(pool: &DbPool, user_id: &str) -> Result<()> { | ||
| 182 | sqlx::query( | ||
| 183 | "INSERT INTO user_stats (user_id, message_count, last_message_at) | ||
| 184 | VALUES (?, 1, CURRENT_TIMESTAMP) | ||
| 185 | ON CONFLICT(user_id) DO UPDATE SET | ||
| 186 | message_count = message_count + 1, | ||
| 187 | last_message_at = CURRENT_TIMESTAMP", | ||
| 188 | ) | ||
| 189 | .bind(user_id) | ||
| 190 | .execute(pool) | ||
| 191 | .await?; | ||
| 192 | Ok(()) | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Add voice time to a user's cumulative stats. | ||
| 196 | pub async fn add_voice_time( | ||
| 197 | pool: &DbPool, | ||
| 198 | user_id: &str, | ||
| 199 | active_seconds: f64, | ||
| 200 | afk_seconds: f64, | ||
| 201 | ) -> Result<()> { | ||
| 202 | sqlx::query( | ||
| 203 | "INSERT INTO user_stats (user_id, total_voice_active, total_voice_afk) | ||
| 204 | VALUES (?, ?, ?) | ||
| 205 | ON CONFLICT(user_id) DO UPDATE SET | ||
| 206 | total_voice_active = total_voice_active + ?, | ||
| 207 | total_voice_afk = total_voice_afk + ?", | ||
| 208 | ) | ||
| 209 | .bind(user_id) | ||
| 210 | .bind(active_seconds) | ||
| 211 | .bind(afk_seconds) | ||
| 212 | .bind(active_seconds) | ||
| 213 | .bind(afk_seconds) | ||
| 214 | .execute(pool) | ||
| 215 | .await?; | ||
| 216 | Ok(()) | ||
| 217 | } | ||
| 218 | |||
| 219 | /// Get stats for a single user. | ||
| 220 | pub async fn get_user_stats(pool: &DbPool, user_id: &str) -> Result<Option<UserStats>> { | ||
| 221 | Ok( | ||
| 222 | sqlx::query_as("SELECT * FROM user_stats WHERE user_id = ?") | ||
| 223 | .bind(user_id) | ||
| 224 | .fetch_optional(pool) | ||
| 225 | .await?, | ||
| 226 | ) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Get leaderboard entries sorted by a metric. | ||
| 230 | /// Returns (entries, total_count) where total_count is used for pagination. | ||
| 231 | /// SAFETY: `order_clause` and `order` are derived from fixed match arms, not user input. | ||
| 232 | pub async fn get_leaderboard( | ||
| 233 | pool: &DbPool, | ||
| 234 | metric: &str, | ||
| 235 | ascending: bool, | ||
| 236 | limit: i64, | ||
| 237 | offset: i64, | ||
| 238 | ) -> Result<(Vec<UserStats>, i64)> { | ||
| 239 | let order = if ascending { "ASC" } else { "DESC" }; | ||
| 240 | // SAFETY: all arms are compile-time constant strings | ||
| 241 | #[allow(clippy::disallowed_methods)] | ||
| 242 | let order_clause = match metric { | ||
| 243 | "messages" => "message_count", | ||
| 244 | "active" => "total_voice_active", | ||
| 245 | "afk" => "total_voice_afk", | ||
| 246 | "total" => "total_voice_active + total_voice_afk", | ||
| 247 | _ => "message_count", | ||
| 248 | }; | ||
| 249 | |||
| 250 | let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_stats") | ||
| 251 | .fetch_one(pool) | ||
| 252 | .await?; | ||
| 253 | |||
| 254 | // SAFETY: order_clause and order are not user-controlled | ||
| 255 | let query = format!( | ||
| 256 | "SELECT * FROM user_stats ORDER BY {} {} LIMIT ? OFFSET ?", | ||
| 257 | order_clause, order | ||
| 258 | ); | ||
| 259 | let entries: Vec<UserStats> = sqlx::query_as(sqlx::AssertSqlSafe(query)) | ||
| 260 | .bind(limit) | ||
| 261 | .bind(offset) | ||
| 262 | .fetch_all(pool) | ||
| 263 | .await?; | ||
| 264 | |||
| 265 | Ok((entries, total.0)) | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Get a user's rank for a metric (1-indexed, 0 if not found). | ||
| 269 | /// SAFETY: `order_clause` is derived from fixed match arms, not user input. | ||
| 270 | pub async fn get_user_rank(pool: &DbPool, user_id: &str, metric: &str) -> Result<i64> { | ||
| 271 | // SAFETY: all arms are compile-time constant strings | ||
| 272 | #[allow(clippy::disallowed_methods)] | ||
| 273 | let order_clause = match metric { | ||
| 274 | "messages" => "message_count", | ||
| 275 | "active" => "total_voice_active", | ||
| 276 | "afk" => "total_voice_afk", | ||
| 277 | "total" => "total_voice_active + total_voice_afk", | ||
| 278 | _ => "message_count", | ||
| 279 | }; | ||
| 280 | |||
| 281 | // SAFETY: order_clause is not user-controlled | ||
| 282 | let query = format!( | ||
| 283 | "SELECT COUNT(*) + 1 FROM user_stats WHERE {} > ( | ||
| 284 | SELECT {} FROM user_stats WHERE user_id = ? | ||
| 285 | )", | ||
| 286 | order_clause, order_clause | ||
| 287 | ); | ||
| 288 | let result: (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(query)) | ||
| 289 | .bind(user_id) | ||
| 290 | .fetch_one(pool) | ||
| 291 | .await?; | ||
| 292 | |||
| 293 | // If user doesn't exist, return 0 | ||
| 294 | let user_exists: Option<(i64,)> = sqlx::query_as( | ||
| 295 | "SELECT 1 FROM user_stats WHERE user_id = ?" | ||
| 296 | ) | ||
| 297 | .bind(user_id) | ||
| 298 | .fetch_optional(pool) | ||
| 299 | .await?; | ||
| 300 | |||
| 301 | Ok(if user_exists.is_some() { result.0 } else { 0 }) | ||
| 302 | } | ||
| 303 | |||
| 159 | // -- Cron Jobs -- | 304 | // -- Cron Jobs -- |
| 160 | #[derive(Debug, Clone, FromRow)] | 305 | #[derive(Debug, Clone, FromRow)] |
| 161 | pub struct CronJob { | 306 | pub struct CronJob { |
diff --git a/src/events/mod.rs b/src/events/mod.rs index 22fe976..ae0e1e4 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs | |||
| @@ -172,6 +172,22 @@ pub async fn handle_voice_state_update( | |||
| 172 | let active_str = format_duration(va.total_active); | 172 | let active_str = format_duration(va.total_active); |
| 173 | let afk_str = format_duration(va.total_afk); | 173 | let afk_str = format_duration(va.total_afk); |
| 174 | 174 | ||
| 175 | // Accumulate to user_stats if in tracked guild | ||
| 176 | if let Some(status_channel_id) = data.config.status_channel_id | ||
| 177 | && let Some(gid) = guild_id { | ||
| 178 | let is_tracked = ctx.cache.guild(gid) | ||
| 179 | .map(|g| g.channels.contains_key(&serenity::ChannelId::new(status_channel_id))) | ||
| 180 | .unwrap_or(false); | ||
| 181 | if is_tracked { | ||
| 182 | database::add_voice_time( | ||
| 183 | &data.db_pool, | ||
| 184 | &user_id_str, | ||
| 185 | va.total_active, | ||
| 186 | va.total_afk, | ||
| 187 | ).await?; | ||
| 188 | } | ||
| 189 | } | ||
| 190 | |||
| 175 | if let Some(lc) = log_channel { | 191 | if let Some(lc) = log_channel { |
| 176 | let msg = format!( | 192 | let msg = format!( |
| 177 | "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\ | 193 | "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\ |
| @@ -324,10 +340,24 @@ pub async fn handle_message( | |||
| 324 | data: &Data, | 340 | data: &Data, |
| 325 | new_message: &serenity::Message, | 341 | new_message: &serenity::Message, |
| 326 | ) -> Result<()> { | 342 | ) -> Result<()> { |
| 327 | if new_message.author.id == ctx.cache.current_user().id { | 343 | // Ignore own messages and other bots |
| 344 | if new_message.author.id == ctx.cache.current_user().id || new_message.author.bot { | ||
| 328 | return Ok(()); | 345 | return Ok(()); |
| 329 | } | 346 | } |
| 330 | 347 | ||
| 348 | // Track message count in the tracked guild | ||
| 349 | if let Some(status_channel_id) = data.config.status_channel_id | ||
| 350 | && let Some(guild_id) = new_message.guild_id { | ||
| 351 | // Check if the status channel is in the same guild as the message | ||
| 352 | let is_tracked_guild = ctx.cache.guild(guild_id) | ||
| 353 | .map(|g| g.channels.contains_key(&serenity::ChannelId::new(status_channel_id))) | ||
| 354 | .unwrap_or(false); | ||
| 355 | if is_tracked_guild { | ||
| 356 | database::increment_message_count(&data.db_pool, &new_message.author.id.to_string()) | ||
| 357 | .await?; | ||
| 358 | } | ||
| 359 | } | ||
| 360 | |||
| 331 | let content_lower = new_message.content.to_lowercase(); | 361 | let content_lower = new_message.content.to_lowercase(); |
| 332 | let response_to_send = { | 362 | let response_to_send = { |
| 333 | let triggers = data.triggers.lock().await; | 363 | let triggers = data.triggers.lock().await; |
