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,
|
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")
|
@bcs_mode.autocomplete("mode")
|
||||||
async def _mode_autocomplete(
|
async def _mode_autocomplete(
|
||||||
self, interaction: discord.Interaction, current: str,
|
self, interaction: discord.Interaction, current: str,
|
||||||
|
|||||||
@@ -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:
|
finally:
|
||||||
conn.close()
|
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):
|
async def close(self):
|
||||||
"""No persistent connection to close (connections are per-operation)."""
|
"""No persistent connection to close (connections are per-operation)."""
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user