diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce0797e --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# MuteCounter — Discord Bot + +Ein Discord-Bot der Self-Mutes in Voice-Channels zählt, ein Leaderboard führt und Meilensteine feiert. + +--- + +## Features + +| Feature | Beschreibung | +|---|---| +| **Mute-Tracking** | Zählt Mikrofon-Mutes (🎙️) und Deaf-Mutes (🔇) getrennt | +| **Leaderboard** | All-Time, wöchentlich und monatlich per `/mutescore` | +| **Nutzer-Fokus** | Rang eines bestimmten Nutzers mit Kontext anzeigen | +| **Persönliche Stats** | Rekordtag, Mute-Ratio, Tages-/Wochen-/Monatswerte | +| **Meilensteine** | Automatische Channel-Nachricht bei 10 / 25 / 50 / 100 / 250 / 500 / 1000 Mutes | +| **Rang-Rollen** | Automatisch zugewiesene Rollen je nach Mute-Anzahl | +| **Overtake-Alert** | Benachrichtigung wenn jemand auf dem Leaderboard überholt wird | +| **Weekly-Posting** | Automatische Wochenzusammenfassung jeden Montag | +| **Mute-Ratio** | Mutes pro Stunde Voice-Zeit (AFK-Channel ausgeschlossen) | +| **Persistenz** | Alle Daten werden in `data.json` gespeichert | + +--- + +## Voraussetzungen + +- Python 3.10 oder neuer +- Ein Discord-Bot-Token ([Discord Developer Portal](https://discord.com/developers/applications)) + +--- + +## Installation + +**1. Abhängigkeiten installieren** +```bash +pip install -r requirements.txt +``` + +**2. Umgebungsvariablen konfigurieren** +```bash +cp .env.example .env +``` +Dann `.env` öffnen und die Werte eintragen: +```env +DISCORD_TOKEN=dein_bot_token_hier +MILESTONE_CHANNEL_ID=1234567890123456789 # optional +``` + +**3. Bot starten** +```bash +python bot.py +``` + +> Slash-Commands werden beim ersten Start synchronisiert. Es kann bis zu einer Stunde dauern, bis sie Discord-weit erscheinen. + +--- + +## Konfiguration + +| Variable | Pflicht | Beschreibung | +|---|---|---| +| `DISCORD_TOKEN` | ✅ | Bot-Token aus dem Developer Portal | +| `MILESTONE_CHANNEL_ID` | ❌ | Channel-ID für Meilensteine, Overtakes & Weekly-Posting. Fallback: Server-Systemchannel | + +**Channel-ID kopieren:** Rechtsklick auf Channel → *ID kopieren* (Entwicklermodus muss aktiviert sein: Einstellungen → Erweitert → Entwicklermodus) + +--- + +## Befehle + +### `/mutescore` +Zeigt das Mute-Leaderboard. + +| Parameter | Typ | Standard | Beschreibung | +|---|---|---|---| +| `period` | Auswahl | All-Time | `Gesamt`, `Diese Woche` oder `Diesen Monat` | +| `limit` | Zahl | 10 | Anzahl der angezeigten Plätze (1–25) | +| `user` | @Mention | — | Fokus-Ansicht für einen bestimmten Nutzer | + +**Beispiele:** +``` +/mutescore +/mutescore period:Diese Woche limit:5 +/mutescore user:@Max +/mutescore user:@Max period:Diesen Monat +``` + +--- + +### `/mymutes` +Zeigt die eigenen Mute-Stats (nur für den Nutzer selbst sichtbar). + +``` +📊 All-Time 🎙️ 12x Mikro | 🔇 5x Deaf | 17x gesamt +📅 Diese Woche 4x +🗓️ Diesen Monat 11x +☀️ Heute 2x +🏆 Rekordtag 15.01.2026 mit 6x +⚡ Mute-Ratio 2.4 Mutes/Stunde (Voice-Zeit: 7h 5min) +🏷️ Rang 💯 Mute-Veteran +``` + +--- + +## Rang-Rollen + +Rollen werden automatisch erstellt und zugewiesen. Bei einem Aufstieg wird die alte Rolle entfernt. + +| Mutes | Rolle | +|---|---| +| 10 | 🔇 Stummer Gast | +| 25 | 😶 Gelegentlicher Muter | +| 50 | 🤫 Fortgeschrittener Schweiger | +| 100 | 💯 Mute-Veteran | +| 250 | 🏅 Schweige-Meister | +| 500 | 🎖️ Elite-Muter | +| 1000 | 👑 Mute-König | + +--- + +## Bot-Berechtigungen + +Folgende Berechtigungen werden benötigt: + +| Berechtigung | Wozu | +|---|---| +| `View Channels` | Channels sehen | +| `Send Messages` | Nachrichten & Embeds senden | +| `Embed Links` | Embeds posten | +| `Manage Roles` | Rang-Rollen erstellen & vergeben | +| `Connect` | Voice-Channels sehen (für AFK-Erkennung) | + +**Privileged Gateway Intents** (im Developer Portal aktivieren): +- `Server Members Intent` +- `Voice State Intent` (standardmäßig aktiv) + +**Einlade-Scopes:** `bot` + `applications.commands` + +--- + +## Datenspeicherung + +Alle Daten werden lokal in `data.json` gespeichert. + +``` +MuteCounter/ +├── bot.py +├── data.json # automatisch erstellt +├── .env # nicht in Git einchecken! +├── .env.example +├── requirements.txt +└── README.md +``` + +> `data.json` und `.env` sollten **nicht** in ein öffentliches Repository eingecheckt werden. + +--- + +## Mute-Typen + +| Aktion in Discord | Typ | Symbol | +|---|---|---| +| Mikrofon-Button drücken | `mic` | 🎙️ | +| Deaf-Button drücken (taubstellen) | `deaf` | 🔇 | + +Wenn sich jemand taubstellt, wird nur der Deaf-Counter erhöht (nicht doppelt gezählt). diff --git a/bot.py b/bot.py index 644e580..34e878e 100644 --- a/bot.py +++ b/bot.py @@ -1,41 +1,23 @@ import discord from discord import app_commands -from discord.ext import commands +from discord.ext import commands, tasks import json import os import time from datetime import date, datetime, timedelta +from typing import Optional from dotenv import load_dotenv load_dotenv() -TOKEN = os.getenv("DISCORD_TOKEN") +TOKEN = os.getenv("DISCORD_TOKEN") MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0)) -DATA_FILE = "data.json" +DATA_FILE = "data.json" # --------------------------------------------------------------------------- -# Datenformat pro Nutzer: -# { -# "mic": int, # All-Time Mikrofon-Mutes -# "deaf": int, # All-Time Deaf-Mutes -# "w_mic": int, # Wöchentlich Mikrofon-Mutes -# "w_deaf": int, # Wöchentlich Deaf-Mutes -# "m_mic": int, # Monatlich Mikrofon-Mutes -# "m_deaf": int, # Monatlich Deaf-Mutes -# "best_day": str, # Datum des Rekordtages "YYYY-MM-DD" -# "best_day_count": int, -# "today": str, # Heutiges Datum "YYYY-MM-DD" -# "today_count": int, -# "voice_sec": float, # Gesamte Voice-Zeit in Sekunden (ohne AFK) -# } -# -# Pro Guild zusätzlich ein "_meta"-Eintrag: -# { "weekly_start": "YYYY-MM-DD", "monthly_key": "YYYY-MM" } +# Konfiguration # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Meilenstein-Konfiguration -# --------------------------------------------------------------------------- -MILESTONES = { +MILESTONES: dict[int, tuple[str, str]] = { 10: ("🔇", "Erste Schritte in die Stille — **{name}** hat sich **10x** gemutet!"), 25: ("😶", "**{name}** hält langsam die Klappe... **25 Mutes** erreicht!"), 50: ("🤫", "**{name}** ist auf dem Weg zur Legende — **50 Mutes**!"), @@ -45,10 +27,8 @@ MILESTONES = { 1000: ("👑", "**{name}** ist der unbestrittene **MUTE-KÖNIG** mit **1000 Mutes**!"), } -# --------------------------------------------------------------------------- -# Rang-Rollen-Konfiguration (absteigend nach Schwellenwert) -# --------------------------------------------------------------------------- -RANK_ROLES = [ +# Absteigend nach Schwellenwert sortiert +RANK_ROLES: list[tuple[int, str]] = [ (1000, "👑 Mute-König"), (500, "🎖️ Elite-Muter"), (250, "🏅 Schweige-Meister"), @@ -57,33 +37,89 @@ RANK_ROLES = [ (25, "😶 Gelegentlicher Muter"), (10, "🔇 Stummer Gast"), ] -RANK_ROLE_NAMES = {name for _, name in RANK_ROLES} +RANK_ROLE_NAMES: set[str] = {name for _, name in RANK_ROLES} + +PERIOD_LABELS: dict[str, str] = { + "alltime": "All-Time", + "weekly": "Diese Woche", + "monthly": "Diesen Monat", +} + +MEDALS: dict[int, str] = {1: "🥇", 2: "🥈", 3: "🥉"} -def get_target_role_name(total: int): +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + +def get_target_role_name(total: int) -> Optional[str]: for threshold, name in RANK_ROLES: if total >= threshold: return name return None -# --------------------------------------------------------------------------- -# Zeitraum-Hilfsfunktionen -# --------------------------------------------------------------------------- def get_week_start() -> str: - """Gibt den Montag der aktuellen Woche als 'YYYY-MM-DD' zurück.""" today = date.today() return (today - timedelta(days=today.weekday())).isoformat() def get_month_key() -> str: - """Gibt den aktuellen Monat als 'YYYY-MM' zurück.""" return date.today().strftime("%Y-%m") +def format_date(date_str: str) -> str: + return datetime.fromisoformat(date_str).strftime("%d.%m.%Y") + + +def format_voice_time(seconds: float) -> str: + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + return f"{h}h {m}min" if h > 0 else f"{m}min" + + +def is_regular_voice(channel, afk_channel) -> bool: + if channel is None: + return False + if afk_channel is not None and channel.id == afk_channel.id: + return False + return True + + +def find_announce_channel(guild: discord.Guild) -> Optional[discord.TextChannel]: + if MILESTONE_CHANNEL_ID: + ch = guild.get_channel(MILESTONE_CHANNEL_ID) + if ch: + return ch + if guild.system_channel: + return guild.system_channel + return next( + (c for c in guild.text_channels if c.permissions_for(guild.me).send_messages), + None, + ) + + +def build_lb_line(guild: discord.Guild, rank: int, uid: str, counts: dict, + period: str, highlight: bool = False) -> str: + member = guild.get_member(int(uid)) + name = member.display_name if member else f"Unbekannt ({uid})" + badge = MEDALS.get(rank, f"#{rank}") + + if period == "weekly": + mic, deaf = counts.get("w_mic", 0), counts.get("w_deaf", 0) + elif period == "monthly": + mic, deaf = counts.get("m_mic", 0), counts.get("m_deaf", 0) + else: + mic, deaf = counts.get("mic", 0), counts.get("deaf", 0) + + line = f"{badge} {name} — **{mic + deaf}x** *(🎙️ {mic}x / 🔇 {deaf}x)*" + return f"**▶ {line} ◀**" if highlight else line + + # --------------------------------------------------------------------------- # Datenverwaltung # --------------------------------------------------------------------------- + def load_data() -> dict: if os.path.exists(DATA_FILE): with open(DATA_FILE, "r", encoding="utf-8") as f: @@ -91,7 +127,7 @@ def load_data() -> dict: return {} -def save_data(data: dict): +def save_data(data: dict) -> None: with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) @@ -108,26 +144,47 @@ def ensure_meta(data: dict, guild_id: int) -> dict: return data[gid]["_meta"] -def reset_periods_if_needed(data: dict, guild_id: int): - """Setzt Wochen-/Monatszähler zurück falls der Zeitraum gewechselt hat.""" - meta = ensure_meta(data, guild_id) - gid = str(guild_id) +def reset_periods_if_needed(data: dict, guild_id: int) -> bool: + """ + Setzt Perioden-Zähler zurück wenn Woche oder Monat gewechselt hat. + Bei einem Wochen-Reset wird ein Snapshot für den weekly_post_task gespeichert. + Gibt True zurück wenn Daten verändert wurden (Save empfohlen). + """ + meta = ensure_meta(data, guild_id) + gid = str(guild_id) + changed = False current_week = get_week_start() if meta["weekly_start"] != current_week: - for uid, entry in data[gid].items(): - if uid != "_meta" and isinstance(entry, dict): - entry["w_mic"] = 0 - entry["w_deaf"] = 0 + snapshot = [ + {"uid": uid, "w_mic": e.get("w_mic", 0), "w_deaf": e.get("w_deaf", 0)} + for uid, e in data[gid].items() + if uid != "_meta" and isinstance(e, dict) + and e.get("w_mic", 0) + e.get("w_deaf", 0) > 0 + ] + if snapshot: + snapshot.sort(key=lambda x: x["w_mic"] + x["w_deaf"], reverse=True) + meta["pending_weekly_post"] = { + "week_start": meta["weekly_start"], + "entries": snapshot[:10], + } + for uid, e in data[gid].items(): + if uid != "_meta" and isinstance(e, dict): + e["w_mic"] = 0 + e["w_deaf"] = 0 meta["weekly_start"] = current_week + changed = True current_month = get_month_key() if meta["monthly_key"] != current_month: - for uid, entry in data[gid].items(): - if uid != "_meta" and isinstance(entry, dict): - entry["m_mic"] = 0 - entry["m_deaf"] = 0 + for uid, e in data[gid].items(): + if uid != "_meta" and isinstance(e, dict): + e["m_mic"] = 0 + e["m_deaf"] = 0 meta["monthly_key"] = current_month + changed = True + + return changed def default_user_entry() -> dict: @@ -149,87 +206,46 @@ def get_user_entry(data: dict, guild_id: int, user_id: int) -> dict: return data[gid][uid] -def update_daily(entry: dict, delta: int = 1): - """Aktualisiert den heutigen Tageszähler und ggf. den Rekordtag.""" +def update_daily(entry: dict) -> None: today_str = date.today().isoformat() if entry.get("today") != today_str: - entry["today"] = today_str + entry["today"] = today_str entry["today_count"] = 0 - entry["today_count"] += delta + entry["today_count"] += 1 if entry["today_count"] > entry.get("best_day_count", 0): - entry["best_day"] = today_str + entry["best_day"] = today_str entry["best_day_count"] = entry["today_count"] -def entry_total(entry, period: str = "alltime") -> int: - if not isinstance(entry, dict): - return int(entry) - if period == "weekly": - return entry.get("w_mic", 0) + entry.get("w_deaf", 0) - if period == "monthly": - return entry.get("m_mic", 0) + entry.get("m_deaf", 0) +def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int: + """Erhöht alle Zähler und gibt den neuen All-Time-Gesamtwert zurück.""" + reset_periods_if_needed(data, guild_id) + entry = get_user_entry(data, guild_id, user_id) + entry[kind] += 1 + entry[f"w_{kind}"] += 1 + entry[f"m_{kind}"] += 1 + update_daily(entry) return entry.get("mic", 0) + entry.get("deaf", 0) -def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int: - """Erhöht alle relevanten Zähler und gibt den neuen All-Time-Gesamtwert zurück.""" - reset_periods_if_needed(data, guild_id) - entry = get_user_entry(data, guild_id, user_id) - entry[kind] += 1 - entry[f"w_{kind}"] += 1 - entry[f"m_{kind}"] += 1 - update_daily(entry, 1) - return entry_total(entry) - - def get_sorted_lb(data: dict, guild_id: int, period: str = "alltime") -> list: + def total(e) -> int: + if period == "weekly": return e.get("w_mic", 0) + e.get("w_deaf", 0) + if period == "monthly": return e.get("m_mic", 0) + e.get("m_deaf", 0) + return e.get("mic", 0) + e.get("deaf", 0) + guild_data = {k: v for k, v in data.get(str(guild_id), {}).items() if k != "_meta"} - return sorted(guild_data.items(), key=lambda x: entry_total(x[1], period), reverse=True) + return sorted(guild_data.items(), key=lambda x: total(x[1]), reverse=True) # --------------------------------------------------------------------------- -# Hilfsfunktionen +# Bot-Events # --------------------------------------------------------------------------- -def is_regular_voice(channel, afk_channel) -> bool: - if channel is None: - return False - if afk_channel is not None and channel.id == afk_channel.id: - return False - return True - -def find_announce_channel(guild: discord.Guild): - if MILESTONE_CHANNEL_ID: - ch = guild.get_channel(MILESTONE_CHANNEL_ID) - if ch: - return ch - if guild.system_channel: - return guild.system_channel - return next( - (c for c in guild.text_channels if c.permissions_for(guild.me).send_messages), - None, - ) - - -def format_date(date_str: str) -> str: - return datetime.fromisoformat(date_str).strftime("%d.%m.%Y") - - -def format_voice_time(seconds: float) -> str: - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - if h > 0: - return f"{h}h {m}min" - return f"{m}min" - - -# --------------------------------------------------------------------------- -# Feature: Meilenstein-Nachrichten -# --------------------------------------------------------------------------- -async def check_milestone(guild: discord.Guild, member: discord.Member, new_total: int): - if new_total not in MILESTONES: +async def check_milestone(guild: discord.Guild, member: discord.Member, total: int) -> None: + if total not in MILESTONES: return - emoji, template = MILESTONES[new_total] + emoji, template = MILESTONES[total] channel = find_announce_channel(guild) if not channel: return @@ -239,16 +255,13 @@ async def check_milestone(guild: discord.Guild, member: discord.Member, new_tota color=discord.Color.gold(), ) embed.set_thumbnail(url=member.display_avatar.url) - embed.set_footer(text=f"Gesamt: {new_total} Mutes") + embed.set_footer(text=f"Gesamt: {total} Mutes") await channel.send(embed=embed) -# --------------------------------------------------------------------------- -# Feature: Rang-Rollen -# --------------------------------------------------------------------------- -async def update_rank_role(guild: discord.Guild, member: discord.Member, new_total: int): - target_name = get_target_role_name(new_total) - to_remove = [r for r in member.roles if r.name in RANK_ROLE_NAMES and r.name != target_name] +async def update_rank_role(guild: discord.Guild, member: discord.Member, total: int) -> None: + target_name = get_target_role_name(total) + to_remove = [r for r in member.roles if r.name in RANK_ROLE_NAMES and r.name != target_name] if to_remove: try: await member.remove_roles(*to_remove, reason="MuteCounter Rang-Update") @@ -263,36 +276,28 @@ async def update_rank_role(guild: discord.Guild, member: discord.Member, new_tot try: role = await guild.create_role(name=target_name, reason="MuteCounter Rang-System") except discord.Forbidden: - print(f"[RANG] Fehlende Berechtigung zum Erstellen der Rolle '{target_name}'.") + print(f"[RANG] Fehlende Berechtigung beim Erstellen von '{target_name}'.") return try: await member.add_roles(role, reason="MuteCounter Rang-Update") except discord.Forbidden: - print(f"[RANG] Fehlende Berechtigung zum Zuweisen der Rolle '{target_name}'.") + print(f"[RANG] Fehlende Berechtigung beim Zuweisen von '{target_name}'.") -# --------------------------------------------------------------------------- -# Feature: Overtake-Alert -# --------------------------------------------------------------------------- -async def check_overtake( - guild: discord.Guild, - member: discord.Member, - old_lb: list, - new_lb: list, -): - uid = str(member.id) +async def check_overtake(guild: discord.Guild, member: discord.Member, + old_lb: list, new_lb: list) -> None: + uid = str(member.id) old_rank = next((i + 1 for i, (u, _) in enumerate(old_lb) if u == uid), None) new_rank = next((i + 1 for i, (u, _) in enumerate(new_lb) if u == uid), None) if old_rank is None or new_rank is None or new_rank >= old_rank: return - if new_rank - 1 >= len(old_lb): return - overtaken_uid, _ = old_lb[new_rank - 1] - overtaken = guild.get_member(int(overtaken_uid)) - overtaken_name = overtaken.display_name if overtaken else f"User {overtaken_uid}" + overtaken_uid, _ = old_lb[new_rank - 1] + overtaken = guild.get_member(int(overtaken_uid)) + overtaken_name = overtaken.display_name if overtaken else f"User {overtaken_uid}" channel = find_announce_channel(guild) if not channel: @@ -310,23 +315,70 @@ async def check_overtake( await channel.send(embed=embed) +async def post_weekly_summary(guild: discord.Guild, post_data: dict) -> None: + channel = find_announce_channel(guild) + if not channel: + return + + week_start_dt = datetime.fromisoformat(post_data["week_start"]) + week_end_dt = week_start_dt + timedelta(days=6) + entries = post_data.get("entries", []) + + lines = [] + for rank, e in enumerate(entries, start=1): + member = guild.get_member(int(e["uid"])) + name = member.display_name if member else f"Unbekannt ({e['uid']})" + mic, deaf = e["w_mic"], e["w_deaf"] + lines.append(f"{MEDALS.get(rank, f'**#{rank}**')} {name} — **{mic + deaf}x** *(🎙️ {mic}x / 🔇 {deaf}x)*") + + embed = discord.Embed( + title="📋 Wöchentliches Leaderboard", + description=( + f"*Woche vom {week_start_dt.strftime('%d.%m.')} " + f"bis {week_end_dt.strftime('%d.%m.%Y')}*\n\n" + + ("\n".join(lines) if lines else "Keine Mutes diese Woche.") + ), + color=discord.Color.green(), + ) + embed.set_footer(text=f"Top {len(entries)} Nutzer dieser Woche") + await channel.send(embed=embed) + + # --------------------------------------------------------------------------- # Bot-Setup # --------------------------------------------------------------------------- -intents = discord.Intents.default() -intents.voice_states = True -intents.members = True -bot = commands.Bot(command_prefix="!", intents=intents) -mute_data = load_data() -voice_sessions: dict = {} # (guild_id, user_id) -> Unix-Timestamp des Channel-Beitritts +intents = discord.Intents.default() +intents.voice_states = True +intents.members = True + +bot = commands.Bot(command_prefix="!", intents=intents) +mute_data = load_data() +voice_sessions: dict[tuple[int, int], float] = {} + + +@tasks.loop(hours=1) +async def weekly_post_task() -> None: + for guild in bot.guilds: + gid = str(guild.id) + meta = mute_data.get(gid, {}).get("_meta", {}) + if "pending_weekly_post" not in meta: + continue + post_data = meta.pop("pending_weekly_post") + save_data(mute_data) + await post_weekly_summary(guild, post_data) + print(f"[WEEKLY] Zusammenfassung für '{guild.name}' gepostet.") + + +@weekly_post_task.before_loop +async def before_weekly_task() -> None: + await bot.wait_until_ready() @bot.event -async def on_ready(): +async def on_ready() -> None: print(f"Bot gestartet als {bot.user} (ID: {bot.user.id})") - # Bereits verbundene Mitglieder in voice_sessions eintragen (Bot-Neustart) for guild in bot.guilds: afk = guild.afk_channel for vc in guild.voice_channels: @@ -335,56 +387,48 @@ async def on_ready(): for member in vc.members: if not member.bot: voice_sessions[(guild.id, member.id)] = time.time() - print(f"{sum(1 for _ in voice_sessions)} Voice-Sessions wiederhergestellt.") + print(f"{len(voice_sessions)} Voice-Session(s) wiederhergestellt.") + + if not weekly_post_task.is_running(): + weekly_post_task.start() try: synced = await bot.tree.sync() - print(f"{len(synced)} Slash-Commands synchronisiert.") + print(f"{len(synced)} Slash-Command(s) synchronisiert.") except Exception as e: print(f"Fehler beim Sync: {e}") -# --------------------------------------------------------------------------- -# Voice-State-Tracking -# --------------------------------------------------------------------------- @bot.event -async def on_voice_state_update( - member: discord.Member, - before: discord.VoiceState, - after: discord.VoiceState, -): +async def on_voice_state_update(member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState) -> None: if member.bot: return - guild = member.guild + guild = member.guild afk_channel = guild.afk_channel - was_regular = is_regular_voice(before.channel, afk_channel) now_regular = is_regular_voice(after.channel, afk_channel) - # --- Voice-Zeit tracken --- if not was_regular and now_regular: voice_sessions[(guild.id, member.id)] = time.time() elif was_regular and not now_regular: join_time = voice_sessions.pop((guild.id, member.id), None) if join_time is not None: - duration = time.time() - join_time entry = get_user_entry(mute_data, guild.id, member.id) - entry["voice_sec"] = entry.get("voice_sec", 0.0) + duration + entry["voice_sec"] = entry.get("voice_sec", 0.0) + (time.time() - join_time) save_data(mute_data) - # --- Mute-Erkennung (nur in regulären Channels) --- if not now_regular: return just_deafened = (not before.self_deaf) and after.self_deaf just_mic_muted = (not before.self_mute) and after.self_mute and (not after.self_deaf) - if not just_deafened and not just_mic_muted: return - kind = "deaf" if just_deafened else "mic" - label = "DEAF" if just_deafened else "MIC" + kind = "deaf" if just_deafened else "mic" old_lb = get_sorted_lb(mute_data, guild.id) new_total = increment(mute_data, guild.id, member.id, kind) @@ -392,7 +436,8 @@ async def on_voice_state_update( new_lb = get_sorted_lb(mute_data, guild.id) entry = get_user_entry(mute_data, guild.id, member.id) - print(f"[{label}] {member.display_name}: mic={entry['mic']}x deaf={entry['deaf']}x [{guild.name}]") + print(f"[{'DEAF' if kind == 'deaf' else 'MIC '}] {member.display_name}: " + f"mic={entry['mic']}x deaf={entry['deaf']}x [{guild.name}]") await check_milestone(guild, member, new_total) await update_rank_role(guild, member, new_total) @@ -402,60 +447,75 @@ async def on_voice_state_update( # --------------------------------------------------------------------------- # Slash-Commands # --------------------------------------------------------------------------- -PERIOD_LABELS = { - "alltime": "All-Time", - "weekly": "Diese Woche", - "monthly": "Diesen Monat", -} - @bot.tree.command(name="mutescore", description="Zeigt das Mute-Leaderboard des Servers.") @app_commands.describe( - limit="Wie viele Plätze anzeigen? (Standard: 10)", + limit="Wie viele Plätze anzeigen? (Standard: 10, Max: 25)", period="Zeitraum (Standard: Gesamt)", + user="Fokus-Ansicht für einen bestimmten Nutzer.", ) @app_commands.choices(period=[ app_commands.Choice(name="Gesamt (All-Time)", value="alltime"), app_commands.Choice(name="Diese Woche", value="weekly"), app_commands.Choice(name="Diesen Monat", value="monthly"), ]) -async def mutescore(interaction: discord.Interaction, limit: int = 10, period: str = "alltime"): - limit = max(1, min(limit, 25)) +async def mutescore( + interaction: discord.Interaction, + limit: int = 10, + period: str = "alltime", + user: Optional[discord.Member] = None, +) -> None: guild = interaction.guild + if reset_periods_if_needed(mute_data, guild.id): + save_data(mute_data) - # Perioden-Reset prüfen bevor angezeigt wird - reset_periods_if_needed(mute_data, guild.id) + lb = get_sorted_lb(mute_data, guild.id, period) + lb_filtered = [(uid, e) for uid, e in lb if + (e.get("w_mic", 0) + e.get("w_deaf", 0) if period == "weekly" else + e.get("m_mic", 0) + e.get("m_deaf", 0) if period == "monthly" else + e.get("mic", 0) + e.get("deaf", 0)) > 0] - lb = get_sorted_lb(mute_data, guild.id, period) - lb_filtered = [(uid, e) for uid, e in lb if entry_total(e, period) > 0] + if user is not None: + uid = str(user.id) + user_rank = next((i + 1 for i, (u, _) in enumerate(lb_filtered) if u == uid), None) + + if user_rank is None: + await interaction.response.send_message( + f"**{user.display_name}** hat noch keine Mutes im Zeitraum " + f"**{PERIOD_LABELS[period]}**.", + ephemeral=True, + ) + return + + ctx_start = max(0, user_rank - 3) + ctx_end = min(len(lb_filtered), user_rank + 2) + lines = [ + build_lb_line(guild, ctx_start + i + 1, u, counts, period, highlight=(u == uid)) + for i, (u, counts) in enumerate(lb_filtered[ctx_start:ctx_end]) + ] + + embed = discord.Embed( + title=f"🔍 Mute-Leaderboard — Fokus: {user.display_name}", + color=discord.Color.blurple(), + ) + embed.description = "\n".join(lines) + embed.set_thumbnail(url=user.display_avatar.url) + embed.set_footer( + text=f"Platz #{user_rank} von {len(lb_filtered)} Nutzern | {PERIOD_LABELS[period]}" + ) + await interaction.response.send_message(embed=embed) + return if not lb_filtered: await interaction.response.send_message( - f"Für den Zeitraum **{PERIOD_LABELS[period]}** gibt es noch keine Mutes.", + f"Für **{PERIOD_LABELS[period]}** gibt es noch keine Mutes.", ephemeral=True, ) return - top = lb_filtered[:limit] - medals = {1: "🥇", 2: "🥈", 3: "🥉"} - lines = [] - - for rank, (uid, counts) in enumerate(top, start=1): - m = guild.get_member(int(uid)) - name = m.display_name if m else f"Unbekannt ({uid})" - medal = medals.get(rank, f"**#{rank}**") - - if period == "weekly": - mic, deaf = counts.get("w_mic", 0), counts.get("w_deaf", 0) - elif period == "monthly": - mic, deaf = counts.get("m_mic", 0), counts.get("m_deaf", 0) - else: - mic, deaf = counts.get("mic", 0), counts.get("deaf", 0) - - lines.append( - f"{medal} {name} — **{mic + deaf}x** gesamt " - f"*(🎙️ {mic}x Mikro / 🔇 {deaf}x Deaf)*" - ) + top = lb_filtered[:max(1, min(limit, 25))] + lines = [build_lb_line(guild, rank, uid, counts, period) + for rank, (uid, counts) in enumerate(top, start=1)] embed = discord.Embed( title=f"Mute-Leaderboard — {PERIOD_LABELS[period]}", @@ -469,51 +529,46 @@ async def mutescore(interaction: discord.Interaction, limit: int = 10, period: s @bot.tree.command(name="mymutes", description="Zeigt deine persönlichen Mute-Stats.") -async def mymutes(interaction: discord.Interaction): - reset_periods_if_needed(mute_data, interaction.guild.id) - entry = get_user_entry(mute_data, interaction.guild.id, interaction.user.id) +async def mymutes(interaction: discord.Interaction) -> None: + if reset_periods_if_needed(mute_data, interaction.guild.id): + save_data(mute_data) + entry = get_user_entry(mute_data, interaction.guild.id, interaction.user.id) mic, deaf = entry.get("mic", 0), entry.get("deaf", 0) w_mic, w_deaf = entry.get("w_mic", 0), entry.get("w_deaf", 0) m_mic, m_deaf = entry.get("m_mic", 0), entry.get("m_deaf", 0) - total = mic + deaf + total = mic + deaf - # Mute-Ratio voice_sec = entry.get("voice_sec", 0.0) voice_h = voice_sec / 3600 - if voice_h >= 0.5: - ratio_str = f"**{total / voice_h:.1f}** Mutes/Stunde *(Voice-Zeit: {format_voice_time(voice_sec)})*" - else: - ratio_str = f"Noch nicht genug Voice-Zeit *(bisher: {format_voice_time(voice_sec)})*" + ratio_str = ( + f"**{total / voice_h:.1f}** Mutes/Stunde *(Voice-Zeit: {format_voice_time(voice_sec)})*" + if voice_h >= 0.5 else + f"Noch nicht genug Voice-Zeit *(bisher: {format_voice_time(voice_sec)})*" + ) - # Rekordtag best_day = entry.get("best_day") best_count = entry.get("best_day_count", 0) best_str = f"{format_date(best_day)} mit **{best_count}x**" if best_day else "Noch kein Rekord" - # Heutiger Tag - today_str = date.today().isoformat() + today_str = date.today().isoformat() today_count = entry.get("today_count", 0) if entry.get("today") == today_str else 0 - rank_name = get_target_role_name(total) or "Noch kein Rang" - embed = discord.Embed( title=f"Mute-Stats von {interaction.user.display_name}", color=discord.Color.blurple(), ) embed.set_thumbnail(url=interaction.user.display_avatar.url) - - embed.add_field( - name="📊 All-Time", - value=f"🎙️ {mic}x Mikro | 🔇 {deaf}x Deaf | **{total}x gesamt**", - inline=False, - ) - embed.add_field(name="📅 Diese Woche", value=f"**{w_mic + w_deaf}x**", inline=True) - embed.add_field(name="🗓️ Diesen Monat", value=f"**{m_mic + m_deaf}x**", inline=True) - embed.add_field(name="☀️ Heute", value=f"**{today_count}x**", inline=True) - embed.add_field(name="🏆 Rekordtag", value=best_str, inline=False) - embed.add_field(name="⚡ Mute-Ratio", value=ratio_str, inline=False) - embed.add_field(name="🏷️ Rang", value=rank_name, inline=False) + embed.add_field(name="📊 All-Time", + value=f"🎙️ {mic}x Mikro | 🔇 {deaf}x Deaf | **{total}x gesamt**", + inline=False) + embed.add_field(name="📅 Diese Woche", value=f"**{w_mic + w_deaf}x**", inline=True) + embed.add_field(name="🗓️ Diesen Monat", value=f"**{m_mic + m_deaf}x**", inline=True) + embed.add_field(name="☀️ Heute", value=f"**{today_count}x**", inline=True) + embed.add_field(name="🏆 Rekordtag", value=best_str, inline=False) + embed.add_field(name="⚡ Mute-Ratio", value=ratio_str, inline=False) + embed.add_field(name="🏷️ Rang", value=get_target_role_name(total) or "Noch kein Rang", + inline=False) await interaction.response.send_message(embed=embed, ephemeral=True)