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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
57
docs/plans/2026-02-27-drama-leaderboard-design.md
Normal file
57
docs/plans/2026-02-27-drama-leaderboard-design.md
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user