From 2c59f81c285064b11ed3da19fb703ed4d7e84ba6 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Fri, 20 Feb 2026 15:42:47 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr=20Zei?= =?UTF-8?q?tperioden=20hinzu:=20Implementiere=20Reset-Logik=20f=C3=BCr=20w?= =?UTF-8?q?=C3=B6chentliche=20und=20monatliche=20Mutes=20sowie=20neue=20Fu?= =?UTF-8?q?nktionen=20zur=20Datenverwaltung=20und=20verbessere=20die=20Ben?= =?UTF-8?q?utzerstatistiken.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 348 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 267 insertions(+), 81 deletions(-) diff --git a/bot.py b/bot.py index 16dfa79..644e580 100644 --- a/bot.py +++ b/bot.py @@ -3,6 +3,8 @@ from discord import app_commands from discord.ext import commands import json import os +import time +from datetime import date, datetime, timedelta from dotenv import load_dotenv load_dotenv() @@ -10,8 +12,25 @@ TOKEN = os.getenv("DISCORD_TOKEN") MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0)) DATA_FILE = "data.json" -# Datenformat: -# { "guild_id": { "user_id": { "mic": , "deaf": } } } +# --------------------------------------------------------------------------- +# 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" } +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Meilenstein-Konfiguration @@ -49,7 +68,21 @@ def get_target_role_name(total: int): # --------------------------------------------------------------------------- -# Datenhilfsfunktionen +# 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") + + +# --------------------------------------------------------------------------- +# Datenverwaltung # --------------------------------------------------------------------------- def load_data() -> dict: if os.path.exists(DATA_FILE): @@ -63,43 +96,108 @@ def save_data(data: dict): json.dump(data, f, indent=2, ensure_ascii=False) +def ensure_meta(data: dict, guild_id: int) -> dict: + gid = str(guild_id) + if gid not in data: + data[gid] = {} + if "_meta" not in data[gid]: + data[gid]["_meta"] = { + "weekly_start": get_week_start(), + "monthly_key": get_month_key(), + } + 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) + + 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 + meta["weekly_start"] = current_week + + 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 + meta["monthly_key"] = current_month + + +def default_user_entry() -> dict: + return { + "mic": 0, "deaf": 0, + "w_mic": 0, "w_deaf": 0, + "m_mic": 0, "m_deaf": 0, + "best_day": None, "best_day_count": 0, + "today": None, "today_count": 0, + "voice_sec": 0.0, + } + + def get_user_entry(data: dict, guild_id: int, user_id: int) -> dict: - return data.get(str(guild_id), {}).get(str(user_id), {"mic": 0, "deaf": 0}) + ensure_meta(data, guild_id) + gid, uid = str(guild_id), str(user_id) + if uid not in data[gid]: + data[gid][uid] = default_user_entry() + return data[gid][uid] -def entry_total(entry) -> int: - if isinstance(entry, dict): - return entry.get("mic", 0) + entry.get("deaf", 0) - return int(entry) +def update_daily(entry: dict, delta: int = 1): + """Aktualisiert den heutigen Tageszähler und ggf. den Rekordtag.""" + today_str = date.today().isoformat() + if entry.get("today") != today_str: + entry["today"] = today_str + entry["today_count"] = 0 + entry["today_count"] += delta + if entry["today_count"] > entry.get("best_day_count", 0): + 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) + return entry.get("mic", 0) + entry.get("deaf", 0) def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int: - """Zählt hoch und gibt den neuen Gesamtwert zurück.""" - gid, uid = str(guild_id), str(user_id) - if gid not in data: - data[gid] = {} - if uid not in data[gid]: - data[gid][uid] = {"mic": 0, "deaf": 0} - data[gid][uid][kind] += 1 - return entry_total(data[gid][uid]) + """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_leaderboard(data: dict, guild_id: int) -> list: - guild_data = data.get(str(guild_id), {}) - return sorted(guild_data.items(), key=lambda x: entry_total(x[1]), reverse=True) - - -def get_user_rank(leaderboard: list, user_id: int) -> int: - uid = str(user_id) - for i, (u, _) in enumerate(leaderboard): - if u == uid: - return i + 1 - return len(leaderboard) + 1 +def get_sorted_lb(data: dict, guild_id: int, period: str = "alltime") -> list: + 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) # --------------------------------------------------------------------------- -# Hilfsfunktion: Ziel-Channel ermitteln +# Hilfsfunktionen # --------------------------------------------------------------------------- +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) @@ -113,6 +211,18 @@ def find_announce_channel(guild: discord.Guild): ) +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 # --------------------------------------------------------------------------- @@ -121,9 +231,8 @@ async def check_milestone(guild: discord.Guild, member: discord.Member, new_tota return emoji, template = MILESTONES[new_total] channel = find_announce_channel(guild) - if channel is None: + if not channel: return - embed = discord.Embed( title=f"{emoji} Meilenstein erreicht!", description=template.format(name=member.display_name), @@ -138,10 +247,7 @@ async def check_milestone(guild: discord.Guild, member: discord.Member, new_tota # Feature: Rang-Rollen # --------------------------------------------------------------------------- async def update_rank_role(guild: discord.Guild, member: discord.Member, new_total: int): - """Weist die passende Rang-Rolle zu und entfernt veraltete Rang-Rollen.""" target_name = get_target_role_name(new_total) - - # Alte Rang-Rollen entfernen (außer der Ziel-Rolle) to_remove = [r for r in member.roles if r.name in RANK_ROLE_NAMES and r.name != target_name] if to_remove: try: @@ -149,28 +255,20 @@ async def update_rank_role(guild: discord.Guild, member: discord.Member, new_tot except discord.Forbidden: print("[RANG] Fehlende Berechtigung zum Entfernen von Rollen.") - if target_name is None: - return None + if not target_name or any(r.name == target_name for r in member.roles): + return - # Prüfen ob Nutzer die Rolle schon hat - if any(r.name == target_name for r in member.roles): - return None # Bereits zugewiesen, kein Update nötig - - # Rolle holen oder erstellen role = discord.utils.get(guild.roles, name=target_name) if role is None: 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}'.") - return None - + return try: await member.add_roles(role, reason="MuteCounter Rang-Update") - return target_name # Neu zugewiesener Rang except discord.Forbidden: print(f"[RANG] Fehlende Berechtigung zum Zuweisen der Rolle '{target_name}'.") - return None # --------------------------------------------------------------------------- @@ -186,19 +284,18 @@ async def check_overtake( 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) - # Kein Aufstieg oder neuer Nutzer im Leaderboard if old_rank is None or new_rank is None or new_rank >= old_rank: return - # Wer stand vor dem Update auf dem Platz, den der Nutzer jetzt einnimmt? 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}" channel = find_announce_channel(guild) - if channel is None: + if not channel: return embed = discord.Embed( @@ -222,11 +319,24 @@ 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 @bot.event async def on_ready(): 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: + if afk and vc.id == afk.id: + continue + 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.") + try: synced = await bot.tree.sync() print(f"{len(synced)} Slash-Commands synchronisiert.") @@ -249,10 +359,23 @@ async def on_voice_state_update( guild = member.guild afk_channel = guild.afk_channel - if afk_channel is not None: - if (after.channel is not None and after.channel.id == afk_channel.id) or \ - (before.channel is not None and before.channel.id == afk_channel.id): - return + 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 + 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) @@ -260,21 +383,17 @@ async def on_voice_state_update( if not just_deafened and not just_mic_muted: return - kind = "deaf" if just_deafened else "mic" + kind = "deaf" if just_deafened else "mic" label = "DEAF" if just_deafened else "MIC" - # Leaderboard vor dem Increment für Overtake-Vergleich - old_lb = get_sorted_leaderboard(mute_data, guild.id) - + old_lb = get_sorted_lb(mute_data, guild.id) new_total = increment(mute_data, guild.id, member.id, kind) save_data(mute_data) + 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"[{label}] {member.display_name}: mic={entry['mic']}x deaf={entry['deaf']}x [{guild.name}]") - new_lb = get_sorted_leaderboard(mute_data, guild.id) - - # Features parallel ausführen await check_milestone(guild, member, new_total) await update_rank_role(guild, member, new_total) await check_overtake(guild, member, old_lb, new_lb) @@ -283,20 +402,41 @@ 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)") -async def mutescore(interaction: discord.Interaction, limit: int = 10): +@app_commands.describe( + limit="Wie viele Plätze anzeigen? (Standard: 10)", + period="Zeitraum (Standard: Gesamt)", +) +@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)) guild = interaction.guild - guild_data: dict = mute_data.get(str(guild.id), {}) - if not guild_data: - await interaction.response.send_message("Noch keine Mutes aufgezeichnet.", ephemeral=True) + # 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 entry_total(e, period) > 0] + + if not lb_filtered: + await interaction.response.send_message( + f"Für den Zeitraum **{PERIOD_LABELS[period]}** gibt es noch keine Mutes.", + ephemeral=True, + ) return - sorted_entries = get_sorted_leaderboard(mute_data, guild.id) - top = sorted_entries[:limit] - + top = lb_filtered[:limit] medals = {1: "🥇", 2: "🥈", 3: "🥉"} lines = [] @@ -304,32 +444,78 @@ async def mutescore(interaction: discord.Interaction, limit: int = 10): m = guild.get_member(int(uid)) name = m.display_name if m else f"Unbekannt ({uid})" medal = medals.get(rank, f"**#{rank}**") - mic = counts.get("mic", 0) if isinstance(counts, dict) else counts - deaf = counts.get("deaf", 0) if isinstance(counts, dict) else 0 + + 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)*" ) - embed = discord.Embed(title="Mute-Leaderboard", color=discord.Color.blurple()) + embed = discord.Embed( + title=f"Mute-Leaderboard — {PERIOD_LABELS[period]}", + color=discord.Color.blurple(), + ) embed.description = "\n".join(lines) - embed.set_footer(text=f"Top {len(top)} von {len(sorted_entries)} Nutzern | 🎙️ = nur Mikro | 🔇 = Deaf") + embed.set_footer( + text=f"Top {len(top)} von {len(lb_filtered)} Nutzern | 🎙️ = nur Mikro | 🔇 = Deaf" + ) await interaction.response.send_message(embed=embed) -@bot.tree.command(name="mymutes", description="Zeigt deine eigenen Mute-Stats.") +@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) - mic, deaf = entry.get("mic", 0), entry.get("deaf", 0) - rank_name = get_target_role_name(mic + deaf) or "Noch kein Rang" - await interaction.response.send_message( - f"Deine Mute-Stats:\n" - f"🎙️ Nur Mikrofon gemutet: **{mic}x**\n" - f"🔇 Taubgestellt (Deaf): **{deaf}x**\n" - f"📊 Gesamt: **{mic + deaf}x**\n" - f"🏷️ Aktueller Rang: **{rank_name}**", - ephemeral=True, + + 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 + + # 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)})*" + + # 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_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) + + await interaction.response.send_message(embed=embed, ephemeral=True) bot.run(TOKEN)