diff --git a/bot.py b/bot.py index 1b94277..16dfa79 100644 --- a/bot.py +++ b/bot.py @@ -13,9 +13,10 @@ DATA_FILE = "data.json" # Datenformat: # { "guild_id": { "user_id": { "mic": , "deaf": } } } -MILESTONES = [10, 25, 50, 100, 250, 500, 1000] - -MILESTONE_MESSAGES = { +# --------------------------------------------------------------------------- +# Meilenstein-Konfiguration +# --------------------------------------------------------------------------- +MILESTONES = { 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**!"), @@ -25,7 +26,31 @@ MILESTONE_MESSAGES = { 1000: ("👑", "**{name}** ist der unbestrittene **MUTE-KÖNIG** mit **1000 Mutes**!"), } +# --------------------------------------------------------------------------- +# Rang-Rollen-Konfiguration (absteigend nach Schwellenwert) +# --------------------------------------------------------------------------- +RANK_ROLES = [ + (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 = {name for _, name in RANK_ROLES} + +def get_target_role_name(total: int): + for threshold, name in RANK_ROLES: + if total >= threshold: + return name + return None + + +# --------------------------------------------------------------------------- +# Datenhilfsfunktionen +# --------------------------------------------------------------------------- def load_data() -> dict: if os.path.exists(DATA_FILE): with open(DATA_FILE, "r", encoding="utf-8") as f: @@ -42,53 +67,155 @@ 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}) +def entry_total(entry) -> int: + if isinstance(entry, dict): + return entry.get("mic", 0) + entry.get("deaf", 0) + return int(entry) + + def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int: - """Zählt hoch und gibt den neuen Gesamtwert (mic + deaf) zurück.""" - gid = str(guild_id) - uid = str(user_id) + """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 data[gid][uid]["mic"] + data[gid][uid]["deaf"] + return entry_total(data[gid][uid]) +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 + + +# --------------------------------------------------------------------------- +# Hilfsfunktion: Ziel-Channel ermitteln +# --------------------------------------------------------------------------- +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, + ) + + +# --------------------------------------------------------------------------- +# Feature: Meilenstein-Nachrichten +# --------------------------------------------------------------------------- async def check_milestone(guild: discord.Guild, member: discord.Member, new_total: int): - """Prüft ob ein Meilenstein erreicht wurde und postet ggf. eine Nachricht.""" if new_total not in MILESTONES: return - - emoji, template = MILESTONE_MESSAGES[new_total] - message = template.format(name=member.display_name) - - # Ziel-Channel: konfigurierter Channel → System-Channel → erstes Text-Channel - channel = None - if MILESTONE_CHANNEL_ID: - channel = guild.get_channel(MILESTONE_CHANNEL_ID) + emoji, template = MILESTONES[new_total] + channel = find_announce_channel(guild) if channel is None: - channel = guild.system_channel - if channel is None: - channel = next( - (c for c in guild.text_channels if c.permissions_for(guild.me).send_messages), - None, - ) - - if channel is None: - print(f"[MILESTONE] Kein Channel gefunden für: {message}") return embed = discord.Embed( title=f"{emoji} Meilenstein erreicht!", - description=message, + 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: {new_total} Mutes") - await channel.send(embed=embed) +# --------------------------------------------------------------------------- +# 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: + await member.remove_roles(*to_remove, reason="MuteCounter Rang-Update") + except discord.Forbidden: + print("[RANG] Fehlende Berechtigung zum Entfernen von Rollen.") + + if target_name is None: + return None + + # 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 + + 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 + + +# --------------------------------------------------------------------------- +# Feature: Overtake-Alert +# --------------------------------------------------------------------------- +async def check_overtake( + guild: discord.Guild, + member: discord.Member, + old_lb: list, + new_lb: list, +): + 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) + + # 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: + 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) + + +# --------------------------------------------------------------------------- +# Bot-Setup +# --------------------------------------------------------------------------- intents = discord.Intents.default() intents.voice_states = True intents.members = True @@ -107,6 +234,9 @@ async def on_ready(): print(f"Fehler beim Sync: {e}") +# --------------------------------------------------------------------------- +# Voice-State-Tracking +# --------------------------------------------------------------------------- @bot.event async def on_voice_state_update( member: discord.Member, @@ -119,39 +249,40 @@ async def on_voice_state_update( guild = member.guild afk_channel = guild.afk_channel - # AFK-Channel ausschließen 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 - # Full Mute (Deafen): Nutzer hat sich gerade taubgestellt - just_deafened = (not before.self_deaf) and after.self_deaf - - # Nur Mikrofon gemutet: self_mute wurde aktiviert, aber NICHT deafen + 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 just_deafened: - new_total = increment(mute_data, guild.id, member.id, "deaf") - save_data(mute_data) - entry = get_user_entry(mute_data, guild.id, member.id) - print( - f"[DEAF] {member.display_name} hat sich taubgestellt " - f"(deaf={entry['deaf']}x, mic={entry['mic']}x) [{guild.name}]" - ) - await check_milestone(guild, member, new_total) + if not just_deafened and not just_mic_muted: + return - elif just_mic_muted: - new_total = increment(mute_data, guild.id, member.id, "mic") - save_data(mute_data) - entry = get_user_entry(mute_data, guild.id, member.id) - print( - f"[MIC] {member.display_name} hat nur sein Mikrofon gemutet " - f"(mic={entry['mic']}x, deaf={entry['deaf']}x) [{guild.name}]" - ) - await check_milestone(guild, member, new_total) + 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) + + new_total = increment(mute_data, guild.id, member.id, kind) + save_data(mute_data) + + 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}]") + + 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) +# --------------------------------------------------------------------------- +# 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)") async def mutescore(interaction: discord.Interaction, limit: int = 10): @@ -160,58 +291,43 @@ async def mutescore(interaction: discord.Interaction, limit: int = 10): guild_data: dict = mute_data.get(str(guild.id), {}) if not guild_data: - await interaction.response.send_message( - "Noch keine Mutes aufgezeichnet.", ephemeral=True - ) + await interaction.response.send_message("Noch keine Mutes aufgezeichnet.", ephemeral=True) return - def total(entry): - if isinstance(entry, dict): - return entry.get("mic", 0) + entry.get("deaf", 0) - return entry - - sorted_entries = sorted(guild_data.items(), key=lambda x: total(x[1]), reverse=True) + sorted_entries = get_sorted_leaderboard(mute_data, guild.id) top = sorted_entries[:limit] medals = {1: "🥇", 2: "🥈", 3: "🥉"} lines = [] for rank, (uid, counts) in enumerate(top, start=1): - member = guild.get_member(int(uid)) - name = member.display_name if member else f"Unbekannt ({uid})" + 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 + lines.append( + f"{medal} {name} — **{mic + deaf}x** gesamt " + f"*(🎙️ {mic}x Mikro / 🔇 {deaf}x Deaf)*" + ) - if isinstance(counts, dict): - mic = counts.get("mic", 0) - deaf = counts.get("deaf", 0) - lines.append( - f"{medal} {name} — " - f"**{mic + deaf}x** gesamt " - f"*(🎙️ {mic}x Mikro / 🔇 {deaf}x Deaf)*" - ) - else: - lines.append(f"{medal} {name} — **{counts}x** gemutet") - - embed = discord.Embed( - title="Mute-Leaderboard", - color=discord.Color.blurple(), - ) + embed = discord.Embed(title="Mute-Leaderboard", 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") - await interaction.response.send_message(embed=embed) @bot.tree.command(name="mymutes", description="Zeigt deine eigenen Mute-Stats.") async def mymutes(interaction: discord.Interaction): entry = get_user_entry(mute_data, interaction.guild.id, interaction.user.id) - mic = entry.get("mic", 0) - deaf = entry.get("deaf", 0) + 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**", + f"📊 Gesamt: **{mic + deaf}x**\n" + f"🏷️ Aktueller Rang: **{rank_name}**", ephemeral=True, )