Files
MuteCounter/bot.py

587 lines
21 KiB
Python

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"
MUTE_COOLDOWN = 5.0 # Sekunden zwischen zwei zählbaren Mutes pro Nutzer
# ---------------------------------------------------------------------------
# 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] = {}
mute_cooldowns: dict[tuple[int, int], float] = {} # (guild_id, user_id) → letzter Mute-Timestamp
@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
# Spam-Schutz: Mute innerhalb des Cooldowns wird nicht gezählt
key = (guild.id, member.id)
now = time.time()
if now - mute_cooldowns.get(key, 0.0) < MUTE_COOLDOWN:
print(f"[SKIP ] {member.display_name} — Cooldown aktiv [{guild.name}]")
return
mute_cooldowns[key] = now
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)