From 1d653ec216a67c7363967449d2797e1b66c445f1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 16:08:39 -0500 Subject: [PATCH] feat: add /drama-leaderboard command with historical composite scoring Queries Messages, AnalysisResults, and Actions tables to rank users by a composite drama score (weighted avg toxicity, peak toxicity, and action rate). Public command with configurable time period (7d/30d/90d/all-time). Co-Authored-By: Claude Opus 4.6 --- cogs/commands.py | 72 +++++++++++++++ .../2026-02-27-drama-leaderboard-design.md | 57 ++++++++++++ utils/database.py | 88 +++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 docs/plans/2026-02-27-drama-leaderboard-design.md diff --git a/cogs/commands.py b/cogs/commands.py index 46b9046..70e0fca 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -669,6 +669,78 @@ class CommandsCog(commands.Cog): old_mode, mode, interaction.user.display_name, ) + @app_commands.command( + name="drama-leaderboard", + description="Show the all-time drama leaderboard for the server.", + ) + @app_commands.describe(period="Time period to rank (default: 30d)") + @app_commands.choices(period=[ + app_commands.Choice(name="Last 7 days", value="7d"), + app_commands.Choice(name="Last 30 days", value="30d"), + app_commands.Choice(name="Last 90 days", value="90d"), + app_commands.Choice(name="All time", value="all"), + ]) + async def drama_leaderboard( + self, interaction: discord.Interaction, period: app_commands.Choice[str] | None = None, + ): + await interaction.response.defer() + + period_val = period.value if period else "30d" + if period_val == "all": + days = None + period_label = "All Time" + else: + days = int(period_val.rstrip("d")) + period_label = f"Last {days} Days" + + rows = await self.bot.db.get_drama_leaderboard(interaction.guild.id, days) + if not rows: + await interaction.followup.send( + f"No drama data for **{period_label}**. Everyone's been suspiciously well-behaved." + ) + return + + # Compute composite score for each user + scored = [] + for r in rows: + avg_tox = r["avg_toxicity"] + max_tox = r["max_toxicity"] + msg_count = r["messages_analyzed"] + action_weight = r["warnings"] + r["mutes"] * 2 + r["off_topic"] * 0.5 + action_rate = min(1.0, action_weight / msg_count * 10) if msg_count > 0 else 0.0 + composite = avg_tox * 0.4 + max_tox * 0.2 + action_rate * 0.4 + scored.append({**r, "composite": composite, "action_rate": action_rate}) + + scored.sort(key=lambda x: x["composite"], reverse=True) + top = scored[:10] + + medals = ["🥇", "🥈", "🥉"] + lines = [] + for i, entry in enumerate(top): + rank = medals[i] if i < 3 else f"`{i + 1}.`" + + # Resolve display name from guild if possible + member = interaction.guild.get_member(entry["user_id"]) + name = member.display_name if member else entry["username"] + + lines.append( + f"{rank} **{entry['composite']:.2f}** — {name}\n" + f" Avg: {entry['avg_toxicity']:.2f} | " + f"Peak: {entry['max_toxicity']:.2f} | " + f"⚠️ {entry['warnings']} | " + f"🔇 {entry['mutes']} | " + f"📢 {entry['off_topic']}" + ) + + embed = discord.Embed( + title=f"Drama Leaderboard — {period_label}", + description="\n".join(lines), + color=discord.Color.orange(), + ) + embed.set_footer(text=f"{len(rows)} users tracked | {sum(r['messages_analyzed'] for r in rows)} messages analyzed") + + await interaction.followup.send(embed=embed) + @bcs_mode.autocomplete("mode") async def _mode_autocomplete( self, interaction: discord.Interaction, current: str, diff --git a/docs/plans/2026-02-27-drama-leaderboard-design.md b/docs/plans/2026-02-27-drama-leaderboard-design.md new file mode 100644 index 0000000..4d83719 --- /dev/null +++ b/docs/plans/2026-02-27-drama-leaderboard-design.md @@ -0,0 +1,57 @@ +# Drama Leaderboard Design + +## Overview + +Public `/drama-leaderboard` slash command that ranks server members by historical drama levels using a composite score derived from DB data. Configurable time period (7d, 30d, 90d, all-time; default 30d). + +## Data Sources + +All from existing tables — no schema changes needed: + +- **Messages + AnalysisResults** (JOIN on MessageId): per-user avg/peak toxicity, message count +- **Actions**: warning, mute, topic_remind, topic_nudge counts per user + +## Composite Score Formula + +``` +score = (avg_toxicity * 0.4) + (peak_toxicity * 0.2) + (action_rate * 0.4) +``` + +Where `action_rate = min(1.0, (warnings + mutes*2 + off_topic*0.5) / messages_analyzed * 10)` + +Normalizes actions relative to message volume so low-volume high-drama users rank appropriately. + +## Embed Format + +Top 10 users, ranked by composite score: + +``` +🥇 0.47 — Username + Avg: 0.32 | Peak: 0.81 | ⚠️ 3 | 🔇 1 | 📢 5 +``` + +## Files to Modify + +- `utils/database.py` — add `get_drama_leaderboard(guild_id, days)` query method +- `cogs/commands.py` — add `/drama-leaderboard` slash command with `period` choice parameter + +## Implementation Plan + +### Step 1: Database query method + +Add `get_drama_leaderboard(guild_id, days=None)` to `Database`: +- Single SQL query joining Messages, AnalysisResults, Actions +- Returns list of dicts with: user_id, username, avg_toxicity, max_toxicity, warnings, mutes, off_topic, messages_analyzed +- `days=None` means all-time (no date filter) +- Filter by GuildId to scope to the server + +### Step 2: Slash command + +Add `/drama-leaderboard` to `CommandsCog`: +- Public command (no admin restriction) +- `period` parameter with choices: 7d, 30d, 90d, all-time +- Defer response (DB query may take a moment) +- Compute composite score in Python from query results +- Sort by composite score descending, take top 10 +- Build embed with ranked list and per-user stat breakdown +- Handle empty results gracefully diff --git a/utils/database.py b/utils/database.py index dc8c312..1aeb454 100644 --- a/utils/database.py +++ b/utils/database.py @@ -713,6 +713,94 @@ class Database: finally: conn.close() + # ------------------------------------------------------------------ + # Drama Leaderboard (historical stats from Messages + AnalysisResults + Actions) + # ------------------------------------------------------------------ + async def get_drama_leaderboard(self, guild_id: int, days: int | None = None) -> list[dict]: + """Get per-user drama stats for the leaderboard. + days=None means all-time. Returns list of dicts sorted by user_id.""" + if not self._available: + return [] + try: + return await asyncio.to_thread(self._get_drama_leaderboard_sync, guild_id, days) + except Exception: + logger.exception("Failed to get drama leaderboard") + return [] + + def _get_drama_leaderboard_sync(self, guild_id: int, days: int | None) -> list[dict]: + conn = self._connect() + try: + cursor = conn.cursor() + + date_filter = "" + params: list = [guild_id] + if days is not None: + date_filter = "AND m.CreatedAt >= DATEADD(DAY, ?, SYSUTCDATETIME())" + params.append(-days) + + # Analysis stats from Messages + AnalysisResults + cursor.execute(f""" + SELECT + m.UserId, + MAX(m.Username) AS Username, + AVG(ar.ToxicityScore) AS AvgToxicity, + MAX(ar.ToxicityScore) AS MaxToxicity, + COUNT(*) AS MessagesAnalyzed + FROM Messages m + INNER JOIN AnalysisResults ar ON ar.MessageId = m.Id + WHERE m.GuildId = ? {date_filter} + GROUP BY m.UserId + """, *params) + + analysis_rows = cursor.fetchall() + + # Action counts + action_date_filter = "" + action_params: list = [guild_id] + if days is not None: + action_date_filter = "AND CreatedAt >= DATEADD(DAY, ?, SYSUTCDATETIME())" + action_params.append(-days) + + cursor.execute(f""" + SELECT + UserId, + SUM(CASE WHEN ActionType = 'warning' THEN 1 ELSE 0 END) AS Warnings, + SUM(CASE WHEN ActionType = 'mute' THEN 1 ELSE 0 END) AS Mutes, + SUM(CASE WHEN ActionType IN ('topic_remind', 'topic_nudge') THEN 1 ELSE 0 END) AS OffTopic + FROM Actions + WHERE GuildId = ? {action_date_filter} + GROUP BY UserId + """, *action_params) + + action_map = {} + for row in cursor.fetchall(): + action_map[row[0]] = { + "warnings": row[1], + "mutes": row[2], + "off_topic": row[3], + } + + cursor.close() + + results = [] + for row in analysis_rows: + user_id = row[0] + actions = action_map.get(user_id, {"warnings": 0, "mutes": 0, "off_topic": 0}) + results.append({ + "user_id": user_id, + "username": row[1], + "avg_toxicity": float(row[2]), + "max_toxicity": float(row[3]), + "messages_analyzed": row[4], + "warnings": actions["warnings"], + "mutes": actions["mutes"], + "off_topic": actions["off_topic"], + }) + + return results + finally: + conn.close() + async def close(self): """No persistent connection to close (connections are per-operation).""" pass