import discord from discord import app_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") MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0)) DATA_FILE = "data.json" # --------------------------------------------------------------------------- # Konfiguration # --------------------------------------------------------------------------- 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**!"), 100: ("💯", "**{name}** hat die **100 Mutes** geknackt! Ein wahrer Schweiger."), 250: ("🏅", "**{name}** erreicht **250 Mutes** — Professionelles Schweigen!"), 500: ("🎖️", "**{name}** mit satten **500 Mutes**. Respekt."), 1000: ("👑", "**{name}** ist der unbestrittene **MUTE-KÖNIG** mit **1000 Mutes**!"), } # Absteigend nach Schwellenwert sortiert RANK_ROLES: list[tuple[int, str]] = [ (1000, "👑 Mute-König"), (500, "🎖️ Elite-Muter"), (250, "🏅 Schweige-Meister"), (100, "💯 Mute-Veteran"), (50, "🤫 Fortgeschrittener Schweiger"), (25, "😶 Gelegentlicher Muter"), (10, "🔇 Stummer Gast"), ] 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: "🥉"} # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- def get_target_role_name(total: int) -> Optional[str]: for threshold, name in RANK_ROLES: if total >= threshold: return name return None def get_week_start() -> str: today = date.today() return (today - timedelta(days=today.weekday())).isoformat() def get_month_key() -> str: 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: return json.load(f) return {} 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) 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) -> 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: 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, 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: 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: 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 update_daily(entry: dict) -> None: today_str = date.today().isoformat() if entry.get("today") != today_str: entry["today"] = today_str entry["today_count"] = 0 entry["today_count"] += 1 if entry["today_count"] > entry.get("best_day_count", 0): entry["best_day"] = today_str entry["best_day_count"] = entry["today_count"] 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 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: total(x[1]), reverse=True) # --------------------------------------------------------------------------- # Bot-Events # --------------------------------------------------------------------------- async def check_milestone(guild: discord.Guild, member: discord.Member, total: int) -> None: if total not in MILESTONES: return emoji, template = MILESTONES[total] channel = find_announce_channel(guild) if not channel: return embed = discord.Embed( title=f"{emoji} Meilenstein erreicht!", description=template.format(name=member.display_name), color=discord.Color.gold(), ) embed.set_thumbnail(url=member.display_avatar.url) embed.set_footer(text=f"Gesamt: {total} Mutes") await channel.send(embed=embed) 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") except discord.Forbidden: print("[RANG] Fehlende Berechtigung zum Entfernen von Rollen.") if not target_name or any(r.name == target_name for r in member.roles): return 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 beim Erstellen von '{target_name}'.") return try: await member.add_roles(role, reason="MuteCounter Rang-Update") except discord.Forbidden: print(f"[RANG] Fehlende Berechtigung beim Zuweisen von '{target_name}'.") 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}" channel = find_announce_channel(guild) if not channel: return embed = discord.Embed( title="⚡ Überholung!", description=( f"**{member.display_name}** hat **{overtaken_name}** " f"überholt und ist jetzt auf Platz **#{new_rank}**!" ), color=discord.Color.orange(), ) embed.set_footer(text=f"{member.display_name}: #{old_rank} → #{new_rank}") 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[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() -> None: print(f"Bot gestartet als {bot.user} (ID: {bot.user.id})") 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"{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-Command(s) synchronisiert.") except Exception as e: print(f"Fehler beim Sync: {e}") @bot.event async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None: if member.bot: return 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) 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: entry = get_user_entry(mute_data, guild.id, member.id) entry["voice_sec"] = entry.get("voice_sec", 0.0) + (time.time() - join_time) save_data(mute_data) 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" 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"[{'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) await check_overtake(guild, member, old_lb, new_lb) # --------------------------------------------------------------------------- # Slash-Commands # --------------------------------------------------------------------------- @bot.tree.command(name="mutescore", description="Zeigt das Mute-Leaderboard des Servers.") @app_commands.describe( 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", user: Optional[discord.Member] = None, ) -> None: guild = interaction.guild if reset_periods_if_needed(mute_data, guild.id): save_data(mute_data) 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] 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 **{PERIOD_LABELS[period]}** gibt es noch keine Mutes.", ephemeral=True, ) return 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]}", color=discord.Color.blurple(), ) embed.description = "\n".join(lines) 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 persönlichen Mute-Stats.") 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 voice_sec = entry.get("voice_sec", 0.0) voice_h = voice_sec / 3600 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)})*" ) 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" today_str = date.today().isoformat() today_count = entry.get("today_count", 0) if entry.get("today") == today_str else 0 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=get_target_role_name(total) or "Noch kein Rang", inline=False) await interaction.response.send_message(embed=embed, ephemeral=True) bot.run(TOKEN)