Füge Unterstützung für Zeitperioden hinzu: Implementiere Reset-Logik für wöchentliche und monatliche Mutes sowie neue Funktionen zur Datenverwaltung und verbessere die Benutzerstatistiken.

This commit is contained in:
2026-02-20 15:42:47 +01:00
parent 46af33b815
commit 2c59f81c28

340
bot.py
View File

@@ -3,6 +3,8 @@ from discord import app_commands
from discord.ext import commands from discord.ext import commands
import json import json
import os import os
import time
from datetime import date, datetime, timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -10,8 +12,25 @@ TOKEN = os.getenv("DISCORD_TOKEN")
MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0)) MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0))
DATA_FILE = "data.json" DATA_FILE = "data.json"
# Datenformat: # ---------------------------------------------------------------------------
# { "guild_id": { "user_id": { "mic": <int>, "deaf": <int> } } } # 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 # 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: def load_data() -> dict:
if os.path.exists(DATA_FILE): if os.path.exists(DATA_FILE):
@@ -63,43 +96,108 @@ def save_data(data: dict):
json.dump(data, f, indent=2, ensure_ascii=False) 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: 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: def update_daily(entry: dict, delta: int = 1):
if isinstance(entry, dict): """Aktualisiert den heutigen Tageszähler und ggf. den Rekordtag."""
return entry.get("mic", 0) + entry.get("deaf", 0) 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) 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: def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int:
"""Zählt hoch und gibt den neuen Gesamtwert zurück.""" """Erhöht alle relevanten Zähler und gibt den neuen All-Time-Gesamtwert zurück."""
gid, uid = str(guild_id), str(user_id) reset_periods_if_needed(data, guild_id)
if gid not in data: entry = get_user_entry(data, guild_id, user_id)
data[gid] = {} entry[kind] += 1
if uid not in data[gid]: entry[f"w_{kind}"] += 1
data[gid][uid] = {"mic": 0, "deaf": 0} entry[f"m_{kind}"] += 1
data[gid][uid][kind] += 1 update_daily(entry, 1)
return entry_total(data[gid][uid]) return entry_total(entry)
def get_sorted_leaderboard(data: dict, guild_id: int) -> list: def get_sorted_lb(data: dict, guild_id: int, period: str = "alltime") -> list:
guild_data = data.get(str(guild_id), {}) 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]), reverse=True) return sorted(guild_data.items(), key=lambda x: entry_total(x[1], period), 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 # 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): def find_announce_channel(guild: discord.Guild):
if MILESTONE_CHANNEL_ID: if MILESTONE_CHANNEL_ID:
ch = guild.get_channel(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 # Feature: Meilenstein-Nachrichten
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -121,9 +231,8 @@ async def check_milestone(guild: discord.Guild, member: discord.Member, new_tota
return return
emoji, template = MILESTONES[new_total] emoji, template = MILESTONES[new_total]
channel = find_announce_channel(guild) channel = find_announce_channel(guild)
if channel is None: if not channel:
return return
embed = discord.Embed( embed = discord.Embed(
title=f"{emoji} Meilenstein erreicht!", title=f"{emoji} Meilenstein erreicht!",
description=template.format(name=member.display_name), 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 # Feature: Rang-Rollen
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def update_rank_role(guild: discord.Guild, member: discord.Member, new_total: int): 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) 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] to_remove = [r for r in member.roles if r.name in RANK_ROLE_NAMES and r.name != target_name]
if to_remove: if to_remove:
try: try:
@@ -149,28 +255,20 @@ async def update_rank_role(guild: discord.Guild, member: discord.Member, new_tot
except discord.Forbidden: except discord.Forbidden:
print("[RANG] Fehlende Berechtigung zum Entfernen von Rollen.") print("[RANG] Fehlende Berechtigung zum Entfernen von Rollen.")
if target_name is None: if not target_name or any(r.name == target_name for r in member.roles):
return None 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) role = discord.utils.get(guild.roles, name=target_name)
if role is None: if role is None:
try: try:
role = await guild.create_role(name=target_name, reason="MuteCounter Rang-System") role = await guild.create_role(name=target_name, reason="MuteCounter Rang-System")
except discord.Forbidden: except discord.Forbidden:
print(f"[RANG] Fehlende Berechtigung zum Erstellen der Rolle '{target_name}'.") print(f"[RANG] Fehlende Berechtigung zum Erstellen der Rolle '{target_name}'.")
return None return
try: try:
await member.add_roles(role, reason="MuteCounter Rang-Update") await member.add_roles(role, reason="MuteCounter Rang-Update")
return target_name # Neu zugewiesener Rang
except discord.Forbidden: except discord.Forbidden:
print(f"[RANG] Fehlende Berechtigung zum Zuweisen der Rolle '{target_name}'.") 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) 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) 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: if old_rank is None or new_rank is None or new_rank >= old_rank:
return return
# Wer stand vor dem Update auf dem Platz, den der Nutzer jetzt einnimmt?
if new_rank - 1 >= len(old_lb): if new_rank - 1 >= len(old_lb):
return return
overtaken_uid, _ = old_lb[new_rank - 1] overtaken_uid, _ = old_lb[new_rank - 1]
overtaken = guild.get_member(int(overtaken_uid)) overtaken = guild.get_member(int(overtaken_uid))
overtaken_name = overtaken.display_name if overtaken else f"User {overtaken_uid}" overtaken_name = overtaken.display_name if overtaken else f"User {overtaken_uid}"
channel = find_announce_channel(guild) channel = find_announce_channel(guild)
if channel is None: if not channel:
return return
embed = discord.Embed( embed = discord.Embed(
@@ -222,11 +319,24 @@ intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
mute_data = load_data() mute_data = load_data()
voice_sessions: dict = {} # (guild_id, user_id) -> Unix-Timestamp des Channel-Beitritts
@bot.event @bot.event
async def on_ready(): async def on_ready():
print(f"Bot gestartet als {bot.user} (ID: {bot.user.id})") 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: try:
synced = await bot.tree.sync() synced = await bot.tree.sync()
print(f"{len(synced)} Slash-Commands synchronisiert.") print(f"{len(synced)} Slash-Commands synchronisiert.")
@@ -249,9 +359,22 @@ async def on_voice_state_update(
guild = member.guild guild = member.guild
afk_channel = guild.afk_channel afk_channel = guild.afk_channel
if afk_channel is not None: was_regular = is_regular_voice(before.channel, afk_channel)
if (after.channel is not None and after.channel.id == afk_channel.id) or \ now_regular = is_regular_voice(after.channel, afk_channel)
(before.channel is not None and before.channel.id == afk_channel.id):
# --- 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 return
just_deafened = (not before.self_deaf) and after.self_deaf just_deafened = (not before.self_deaf) and after.self_deaf
@@ -263,18 +386,14 @@ async def on_voice_state_update(
kind = "deaf" if just_deafened else "mic" kind = "deaf" if just_deafened else "mic"
label = "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_lb(mute_data, guild.id)
old_lb = get_sorted_leaderboard(mute_data, guild.id)
new_total = increment(mute_data, guild.id, member.id, kind) new_total = increment(mute_data, guild.id, member.id, kind)
save_data(mute_data) save_data(mute_data)
new_lb = get_sorted_lb(mute_data, guild.id)
entry = get_user_entry(mute_data, guild.id, member.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 check_milestone(guild, member, new_total)
await update_rank_role(guild, member, new_total) await update_rank_role(guild, member, new_total)
await check_overtake(guild, member, old_lb, new_lb) await check_overtake(guild, member, old_lb, new_lb)
@@ -283,20 +402,41 @@ async def on_voice_state_update(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Slash-Commands # 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.") @bot.tree.command(name="mutescore", description="Zeigt das Mute-Leaderboard des Servers.")
@app_commands.describe(limit="Wie viele Plätze anzeigen? (Standard: 10)") @app_commands.describe(
async def mutescore(interaction: discord.Interaction, limit: int = 10): 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)) limit = max(1, min(limit, 25))
guild = interaction.guild guild = interaction.guild
guild_data: dict = mute_data.get(str(guild.id), {})
if not guild_data: # Perioden-Reset prüfen bevor angezeigt wird
await interaction.response.send_message("Noch keine Mutes aufgezeichnet.", ephemeral=True) 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 return
sorted_entries = get_sorted_leaderboard(mute_data, guild.id) top = lb_filtered[:limit]
top = sorted_entries[:limit]
medals = {1: "🥇", 2: "🥈", 3: "🥉"} medals = {1: "🥇", 2: "🥈", 3: "🥉"}
lines = [] lines = []
@@ -304,32 +444,78 @@ async def mutescore(interaction: discord.Interaction, limit: int = 10):
m = guild.get_member(int(uid)) m = guild.get_member(int(uid))
name = m.display_name if m else f"Unbekannt ({uid})" name = m.display_name if m else f"Unbekannt ({uid})"
medal = medals.get(rank, f"**#{rank}**") 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( lines.append(
f"{medal} {name} — **{mic + deaf}x** gesamt " f"{medal} {name} — **{mic + deaf}x** gesamt "
f"*(🎙️ {mic}x Mikro / 🔇 {deaf}x Deaf)*" 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.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) 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): 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) entry = get_user_entry(mute_data, interaction.guild.id, interaction.user.id)
mic, deaf = entry.get("mic", 0), 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" w_mic, w_deaf = entry.get("w_mic", 0), entry.get("w_deaf", 0)
await interaction.response.send_message( m_mic, m_deaf = entry.get("m_mic", 0), entry.get("m_deaf", 0)
f"Deine Mute-Stats:\n" total = mic + deaf
f"🎙️ Nur Mikrofon gemutet: **{mic}x**\n"
f"🔇 Taubgestellt (Deaf): **{deaf}x**\n" # Mute-Ratio
f"📊 Gesamt: **{mic + deaf}x**\n" voice_sec = entry.get("voice_sec", 0.0)
f"🏷️ Aktueller Rang: **{rank_name}**", voice_h = voice_sec / 3600
ephemeral=True, 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) bot.run(TOKEN)