aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CONTEXT.md14
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--src/commands/leaderboard.rs329
-rw-r--r--src/commands/mod.rs2
-rw-r--r--src/database/mod.rs145
-rw-r--r--src/events/mod.rs32
7 files changed, 523 insertions, 3 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index f39bb31..e272a7a 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -18,3 +18,17 @@ holds no implementation decisions or specs (those live in `docs/adr/`).
18- **Flush** — the `/record` action: snapshot the current recording buffer to an 18- **Flush** — the `/record` action: snapshot the current recording buffer to an
19 mp3 and upload it to Copyparty. Non-destructive — the buffer keeps recording 19 mp3 and upload it to Copyparty. Non-destructive — the buffer keeps recording
20 after a flush, so successive calls may overlap. 20 after a flush, so successive calls may overlap.
21
22## Stats & Leaderboard
23
24- **User stats** — cumulative per-user counters (message count, total voice
25 active time, total voice AFK time) stored in the `user_stats` table. Persists
26 across restarts. Distinct from *voice activity* which is session-scoped.
27- **Voice activity** (session) — per-session timing metadata (join time, current
28 active/AFK durations, is_afk flag). Resets when user joins VC. Used for
29 real-time status, not leaderboard rankings.
30- **Leaderboard** — `/leaderboard` command group showing ranked user stats.
31 Metrics: `messages`, `active` (voice), `afk` (voice), `total` (voice active +
32 afk). Paged at 10 entries, descending by default.
33- **Tracked guild** — the guild containing `status_channel_id`. Message counts
34 are only tracked in this guild.
diff --git a/Cargo.lock b/Cargo.lock
index 10dbabe..4031efa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4391,7 +4391,7 @@ dependencies = [
4391 4391
4392[[package]] 4392[[package]]
4393name = "senator-sperma" 4393name = "senator-sperma"
4394version = "0.3.0" 4394version = "0.4.0"
4395dependencies = [ 4395dependencies = [
4396 "chrono", 4396 "chrono",
4397 "cron", 4397 "cron",
diff --git a/Cargo.toml b/Cargo.toml
index d311d17..7127862 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "senator-sperma" 2name = "senator-sperma"
3version = "0.3.0" 3version = "0.4.0"
4edition = "2024" 4edition = "2024"
5 5
6[profile.dev] 6[profile.dev]
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 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::database;
5use crate::error::{Error, Result};
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9const PAGE_SIZE: i64 = 10;
10
11/// Format seconds to human-readable duration
12fn 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
24fn 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.)
37fn 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
52async 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
70async 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
114async 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)]
197pub 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)]
224pub 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)]
245pub 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)]
266pub 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)]
287pub 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
307async 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)]
323pub async fn leaderboard(_ctx: Context<'_>) -> Result<()> {
324 Ok(())
325}
326
327pub 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;
8pub mod trigger; 8pub mod trigger;
9pub mod utilities; 9pub mod utilities;
10pub mod autocomplete; 10pub mod autocomplete;
11pub mod leaderboard;
11 12
12use crate::Data; 13use 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)]
173pub 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.
181pub 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.
196pub 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.
220pub 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.
232pub 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.
270pub 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)]
161pub struct CronJob { 306pub 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;