Füge umfassende Dokumentation hinzu: Erstelle README.md mit Funktionen, Installationsanweisungen, Befehlen und Bot-Berechtigungen.

This commit is contained in:
2026-02-20 15:50:51 +01:00
parent 2c59f81c28
commit 972d765712
2 changed files with 442 additions and 222 deletions

165
README.md Normal file
View File

@@ -0,0 +1,165 @@
# MuteCounter — Discord Bot
Ein Discord-Bot der Self-Mutes in Voice-Channels zählt, ein Leaderboard führt und Meilensteine feiert.
---
## Features
| Feature | Beschreibung |
|---|---|
| **Mute-Tracking** | Zählt Mikrofon-Mutes (🎙️) und Deaf-Mutes (🔇) getrennt |
| **Leaderboard** | All-Time, wöchentlich und monatlich per `/mutescore` |
| **Nutzer-Fokus** | Rang eines bestimmten Nutzers mit Kontext anzeigen |
| **Persönliche Stats** | Rekordtag, Mute-Ratio, Tages-/Wochen-/Monatswerte |
| **Meilensteine** | Automatische Channel-Nachricht bei 10 / 25 / 50 / 100 / 250 / 500 / 1000 Mutes |
| **Rang-Rollen** | Automatisch zugewiesene Rollen je nach Mute-Anzahl |
| **Overtake-Alert** | Benachrichtigung wenn jemand auf dem Leaderboard überholt wird |
| **Weekly-Posting** | Automatische Wochenzusammenfassung jeden Montag |
| **Mute-Ratio** | Mutes pro Stunde Voice-Zeit (AFK-Channel ausgeschlossen) |
| **Persistenz** | Alle Daten werden in `data.json` gespeichert |
---
## Voraussetzungen
- Python 3.10 oder neuer
- Ein Discord-Bot-Token ([Discord Developer Portal](https://discord.com/developers/applications))
---
## Installation
**1. Abhängigkeiten installieren**
```bash
pip install -r requirements.txt
```
**2. Umgebungsvariablen konfigurieren**
```bash
cp .env.example .env
```
Dann `.env` öffnen und die Werte eintragen:
```env
DISCORD_TOKEN=dein_bot_token_hier
MILESTONE_CHANNEL_ID=1234567890123456789 # optional
```
**3. Bot starten**
```bash
python bot.py
```
> Slash-Commands werden beim ersten Start synchronisiert. Es kann bis zu einer Stunde dauern, bis sie Discord-weit erscheinen.
---
## Konfiguration
| Variable | Pflicht | Beschreibung |
|---|---|---|
| `DISCORD_TOKEN` | ✅ | Bot-Token aus dem Developer Portal |
| `MILESTONE_CHANNEL_ID` | ❌ | Channel-ID für Meilensteine, Overtakes & Weekly-Posting. Fallback: Server-Systemchannel |
**Channel-ID kopieren:** Rechtsklick auf Channel → *ID kopieren* (Entwicklermodus muss aktiviert sein: Einstellungen → Erweitert → Entwicklermodus)
---
## Befehle
### `/mutescore`
Zeigt das Mute-Leaderboard.
| Parameter | Typ | Standard | Beschreibung |
|---|---|---|---|
| `period` | Auswahl | All-Time | `Gesamt`, `Diese Woche` oder `Diesen Monat` |
| `limit` | Zahl | 10 | Anzahl der angezeigten Plätze (125) |
| `user` | @Mention | — | Fokus-Ansicht für einen bestimmten Nutzer |
**Beispiele:**
```
/mutescore
/mutescore period:Diese Woche limit:5
/mutescore user:@Max
/mutescore user:@Max period:Diesen Monat
```
---
### `/mymutes`
Zeigt die eigenen Mute-Stats (nur für den Nutzer selbst sichtbar).
```
📊 All-Time 🎙️ 12x Mikro | 🔇 5x Deaf | 17x gesamt
📅 Diese Woche 4x
🗓️ Diesen Monat 11x
☀️ Heute 2x
🏆 Rekordtag 15.01.2026 mit 6x
⚡ Mute-Ratio 2.4 Mutes/Stunde (Voice-Zeit: 7h 5min)
🏷️ Rang 💯 Mute-Veteran
```
---
## Rang-Rollen
Rollen werden automatisch erstellt und zugewiesen. Bei einem Aufstieg wird die alte Rolle entfernt.
| Mutes | Rolle |
|---|---|
| 10 | 🔇 Stummer Gast |
| 25 | 😶 Gelegentlicher Muter |
| 50 | 🤫 Fortgeschrittener Schweiger |
| 100 | 💯 Mute-Veteran |
| 250 | 🏅 Schweige-Meister |
| 500 | 🎖️ Elite-Muter |
| 1000 | 👑 Mute-König |
---
## Bot-Berechtigungen
Folgende Berechtigungen werden benötigt:
| Berechtigung | Wozu |
|---|---|
| `View Channels` | Channels sehen |
| `Send Messages` | Nachrichten & Embeds senden |
| `Embed Links` | Embeds posten |
| `Manage Roles` | Rang-Rollen erstellen & vergeben |
| `Connect` | Voice-Channels sehen (für AFK-Erkennung) |
**Privileged Gateway Intents** (im Developer Portal aktivieren):
- `Server Members Intent`
- `Voice State Intent` (standardmäßig aktiv)
**Einlade-Scopes:** `bot` + `applications.commands`
---
## Datenspeicherung
Alle Daten werden lokal in `data.json` gespeichert.
```
MuteCounter/
├── bot.py
├── data.json # automatisch erstellt
├── .env # nicht in Git einchecken!
├── .env.example
├── requirements.txt
└── README.md
```
> `data.json` und `.env` sollten **nicht** in ein öffentliches Repository eingecheckt werden.
---
## Mute-Typen
| Aktion in Discord | Typ | Symbol |
|---|---|---|
| Mikrofon-Button drücken | `mic` | 🎙️ |
| Deaf-Button drücken (taubstellen) | `deaf` | 🔇 |
Wenn sich jemand taubstellt, wird nur der Deaf-Counter erhöht (nicht doppelt gezählt).

433
bot.py
View File

@@ -1,10 +1,11 @@
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands, tasks
import json import json
import os import os
import time import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -13,29 +14,10 @@ MILESTONE_CHANNEL_ID = int(os.getenv("MILESTONE_CHANNEL_ID", 0))
DATA_FILE = "data.json" DATA_FILE = "data.json"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Datenformat pro Nutzer: # Konfiguration
# {
# "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" }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- MILESTONES: dict[int, tuple[str, str]] = {
# Meilenstein-Konfiguration
# ---------------------------------------------------------------------------
MILESTONES = {
10: ("🔇", "Erste Schritte in die Stille — **{name}** hat sich **10x** gemutet!"), 10: ("🔇", "Erste Schritte in die Stille — **{name}** hat sich **10x** gemutet!"),
25: ("😶", "**{name}** hält langsam die Klappe... **25 Mutes** erreicht!"), 25: ("😶", "**{name}** hält langsam die Klappe... **25 Mutes** erreicht!"),
50: ("🤫", "**{name}** ist auf dem Weg zur Legende — **50 Mutes**!"), 50: ("🤫", "**{name}** ist auf dem Weg zur Legende — **50 Mutes**!"),
@@ -45,10 +27,8 @@ MILESTONES = {
1000: ("👑", "**{name}** ist der unbestrittene **MUTE-KÖNIG** mit **1000 Mutes**!"), 1000: ("👑", "**{name}** ist der unbestrittene **MUTE-KÖNIG** mit **1000 Mutes**!"),
} }
# --------------------------------------------------------------------------- # Absteigend nach Schwellenwert sortiert
# Rang-Rollen-Konfiguration (absteigend nach Schwellenwert) RANK_ROLES: list[tuple[int, str]] = [
# ---------------------------------------------------------------------------
RANK_ROLES = [
(1000, "👑 Mute-König"), (1000, "👑 Mute-König"),
(500, "🎖️ Elite-Muter"), (500, "🎖️ Elite-Muter"),
(250, "🏅 Schweige-Meister"), (250, "🏅 Schweige-Meister"),
@@ -57,33 +37,89 @@ RANK_ROLES = [
(25, "😶 Gelegentlicher Muter"), (25, "😶 Gelegentlicher Muter"),
(10, "🔇 Stummer Gast"), (10, "🔇 Stummer Gast"),
] ]
RANK_ROLE_NAMES = {name for _, name in RANK_ROLES} 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: "🥉"}
def get_target_role_name(total: int): # ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def get_target_role_name(total: int) -> Optional[str]:
for threshold, name in RANK_ROLES: for threshold, name in RANK_ROLES:
if total >= threshold: if total >= threshold:
return name return name
return None return None
# ---------------------------------------------------------------------------
# Zeitraum-Hilfsfunktionen
# ---------------------------------------------------------------------------
def get_week_start() -> str: def get_week_start() -> str:
"""Gibt den Montag der aktuellen Woche als 'YYYY-MM-DD' zurück."""
today = date.today() today = date.today()
return (today - timedelta(days=today.weekday())).isoformat() return (today - timedelta(days=today.weekday())).isoformat()
def get_month_key() -> str: def get_month_key() -> str:
"""Gibt den aktuellen Monat als 'YYYY-MM' zurück."""
return date.today().strftime("%Y-%m") 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 # Datenverwaltung
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_data() -> dict: def load_data() -> dict:
if os.path.exists(DATA_FILE): if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r", encoding="utf-8") as f: with open(DATA_FILE, "r", encoding="utf-8") as f:
@@ -91,7 +127,7 @@ def load_data() -> dict:
return {} return {}
def save_data(data: dict): def save_data(data: dict) -> None:
with open(DATA_FILE, "w", encoding="utf-8") as f: with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2, ensure_ascii=False)
@@ -108,26 +144,47 @@ def ensure_meta(data: dict, guild_id: int) -> dict:
return data[gid]["_meta"] return data[gid]["_meta"]
def reset_periods_if_needed(data: dict, guild_id: int): def reset_periods_if_needed(data: dict, guild_id: int) -> bool:
"""Setzt Wochen-/Monatszähler zurück falls der Zeitraum gewechselt hat.""" """
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) meta = ensure_meta(data, guild_id)
gid = str(guild_id) gid = str(guild_id)
changed = False
current_week = get_week_start() current_week = get_week_start()
if meta["weekly_start"] != current_week: if meta["weekly_start"] != current_week:
for uid, entry in data[gid].items(): snapshot = [
if uid != "_meta" and isinstance(entry, dict): {"uid": uid, "w_mic": e.get("w_mic", 0), "w_deaf": e.get("w_deaf", 0)}
entry["w_mic"] = 0 for uid, e in data[gid].items()
entry["w_deaf"] = 0 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 meta["weekly_start"] = current_week
changed = True
current_month = get_month_key() current_month = get_month_key()
if meta["monthly_key"] != current_month: if meta["monthly_key"] != current_month:
for uid, entry in data[gid].items(): for uid, e in data[gid].items():
if uid != "_meta" and isinstance(entry, dict): if uid != "_meta" and isinstance(e, dict):
entry["m_mic"] = 0 e["m_mic"] = 0
entry["m_deaf"] = 0 e["m_deaf"] = 0
meta["monthly_key"] = current_month meta["monthly_key"] = current_month
changed = True
return changed
def default_user_entry() -> dict: def default_user_entry() -> dict:
@@ -149,87 +206,46 @@ def get_user_entry(data: dict, guild_id: int, user_id: int) -> dict:
return data[gid][uid] return data[gid][uid]
def update_daily(entry: dict, delta: int = 1): def update_daily(entry: dict) -> None:
"""Aktualisiert den heutigen Tageszähler und ggf. den Rekordtag."""
today_str = date.today().isoformat() today_str = date.today().isoformat()
if entry.get("today") != today_str: if entry.get("today") != today_str:
entry["today"] = today_str entry["today"] = today_str
entry["today_count"] = 0 entry["today_count"] = 0
entry["today_count"] += delta entry["today_count"] += 1
if entry["today_count"] > entry.get("best_day_count", 0): if entry["today_count"] > entry.get("best_day_count", 0):
entry["best_day"] = today_str entry["best_day"] = today_str
entry["best_day_count"] = entry["today_count"] 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: def increment(data: dict, guild_id: int, user_id: int, kind: str) -> int:
"""Erhöht alle relevanten Zähler und gibt den neuen All-Time-Gesamtwert zurück.""" """Erhöht alle Zähler und gibt den neuen All-Time-Gesamtwert zurück."""
reset_periods_if_needed(data, guild_id) reset_periods_if_needed(data, guild_id)
entry = get_user_entry(data, guild_id, user_id) entry = get_user_entry(data, guild_id, user_id)
entry[kind] += 1 entry[kind] += 1
entry[f"w_{kind}"] += 1 entry[f"w_{kind}"] += 1
entry[f"m_{kind}"] += 1 entry[f"m_{kind}"] += 1
update_daily(entry, 1) update_daily(entry)
return entry_total(entry) return entry.get("mic", 0) + entry.get("deaf", 0)
def get_sorted_lb(data: dict, guild_id: int, period: str = "alltime") -> list: 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"} 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) return sorted(guild_data.items(), key=lambda x: total(x[1]), reverse=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Hilfsfunktionen # Bot-Events
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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
async def check_milestone(guild: discord.Guild, member: discord.Member, total: int) -> None:
def find_announce_channel(guild: discord.Guild): if total not in MILESTONES:
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 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
# ---------------------------------------------------------------------------
async def check_milestone(guild: discord.Guild, member: discord.Member, new_total: int):
if new_total not in MILESTONES:
return return
emoji, template = MILESTONES[new_total] emoji, template = MILESTONES[total]
channel = find_announce_channel(guild) channel = find_announce_channel(guild)
if not channel: if not channel:
return return
@@ -239,15 +255,12 @@ async def check_milestone(guild: discord.Guild, member: discord.Member, new_tota
color=discord.Color.gold(), color=discord.Color.gold(),
) )
embed.set_thumbnail(url=member.display_avatar.url) embed.set_thumbnail(url=member.display_avatar.url)
embed.set_footer(text=f"Gesamt: {new_total} Mutes") embed.set_footer(text=f"Gesamt: {total} Mutes")
await channel.send(embed=embed) await channel.send(embed=embed)
# --------------------------------------------------------------------------- async def update_rank_role(guild: discord.Guild, member: discord.Member, total: int) -> None:
# Feature: Rang-Rollen target_name = get_target_role_name(total)
# ---------------------------------------------------------------------------
async def update_rank_role(guild: discord.Guild, member: discord.Member, new_total: int):
target_name = get_target_role_name(new_total)
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:
@@ -263,30 +276,22 @@ async def update_rank_role(guild: discord.Guild, member: discord.Member, new_tot
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 beim Erstellen von '{target_name}'.")
return return
try: try:
await member.add_roles(role, reason="MuteCounter Rang-Update") await member.add_roles(role, reason="MuteCounter Rang-Update")
except discord.Forbidden: except discord.Forbidden:
print(f"[RANG] Fehlende Berechtigung zum Zuweisen der Rolle '{target_name}'.") print(f"[RANG] Fehlende Berechtigung beim Zuweisen von '{target_name}'.")
# --------------------------------------------------------------------------- async def check_overtake(guild: discord.Guild, member: discord.Member,
# Feature: Overtake-Alert old_lb: list, new_lb: list) -> None:
# ---------------------------------------------------------------------------
async def check_overtake(
guild: discord.Guild,
member: discord.Member,
old_lb: list,
new_lb: list,
):
uid = str(member.id) uid = str(member.id)
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)
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
if new_rank - 1 >= len(old_lb): if new_rank - 1 >= len(old_lb):
return return
@@ -310,23 +315,70 @@ async def check_overtake(
await channel.send(embed=embed) 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 # Bot-Setup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
intents = discord.Intents.default() intents = discord.Intents.default()
intents.voice_states = True intents.voice_states = True
intents.members = True 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 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 @bot.event
async def on_ready(): async def on_ready() -> None:
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: for guild in bot.guilds:
afk = guild.afk_channel afk = guild.afk_channel
for vc in guild.voice_channels: for vc in guild.voice_channels:
@@ -335,56 +387,48 @@ async def on_ready():
for member in vc.members: for member in vc.members:
if not member.bot: if not member.bot:
voice_sessions[(guild.id, member.id)] = time.time() voice_sessions[(guild.id, member.id)] = time.time()
print(f"{sum(1 for _ in voice_sessions)} Voice-Sessions wiederhergestellt.") print(f"{len(voice_sessions)} Voice-Session(s) wiederhergestellt.")
if not weekly_post_task.is_running():
weekly_post_task.start()
try: try:
synced = await bot.tree.sync() synced = await bot.tree.sync()
print(f"{len(synced)} Slash-Commands synchronisiert.") print(f"{len(synced)} Slash-Command(s) synchronisiert.")
except Exception as e: except Exception as e:
print(f"Fehler beim Sync: {e}") print(f"Fehler beim Sync: {e}")
# ---------------------------------------------------------------------------
# Voice-State-Tracking
# ---------------------------------------------------------------------------
@bot.event @bot.event
async def on_voice_state_update( async def on_voice_state_update(member: discord.Member,
member: discord.Member,
before: discord.VoiceState, before: discord.VoiceState,
after: discord.VoiceState, after: discord.VoiceState) -> None:
):
if member.bot: if member.bot:
return return
guild = member.guild guild = member.guild
afk_channel = guild.afk_channel afk_channel = guild.afk_channel
was_regular = is_regular_voice(before.channel, afk_channel) was_regular = is_regular_voice(before.channel, afk_channel)
now_regular = is_regular_voice(after.channel, afk_channel) now_regular = is_regular_voice(after.channel, afk_channel)
# --- Voice-Zeit tracken ---
if not was_regular and now_regular: if not was_regular and now_regular:
voice_sessions[(guild.id, member.id)] = time.time() voice_sessions[(guild.id, member.id)] = time.time()
elif was_regular and not now_regular: elif was_regular and not now_regular:
join_time = voice_sessions.pop((guild.id, member.id), None) join_time = voice_sessions.pop((guild.id, member.id), None)
if join_time is not None: if join_time is not None:
duration = time.time() - join_time
entry = get_user_entry(mute_data, guild.id, member.id) entry = get_user_entry(mute_data, guild.id, member.id)
entry["voice_sec"] = entry.get("voice_sec", 0.0) + duration entry["voice_sec"] = entry.get("voice_sec", 0.0) + (time.time() - join_time)
save_data(mute_data) save_data(mute_data)
# --- Mute-Erkennung (nur in regulären Channels) ---
if not now_regular: 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
just_mic_muted = (not before.self_mute) and after.self_mute and (not 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: if not just_deafened and not just_mic_muted:
return return
kind = "deaf" if just_deafened else "mic" kind = "deaf" if just_deafened else "mic"
label = "DEAF" if just_deafened else "MIC"
old_lb = get_sorted_lb(mute_data, guild.id) old_lb = get_sorted_lb(mute_data, guild.id)
new_total = increment(mute_data, guild.id, member.id, kind) new_total = increment(mute_data, guild.id, member.id, kind)
@@ -392,7 +436,8 @@ async def on_voice_state_update(
new_lb = get_sorted_lb(mute_data, guild.id) 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"[{'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 check_milestone(guild, member, new_total)
await update_rank_role(guild, member, new_total) await update_rank_role(guild, member, new_total)
@@ -402,60 +447,75 @@ 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( @app_commands.describe(
limit="Wie viele Plätze anzeigen? (Standard: 10)", limit="Wie viele Plätze anzeigen? (Standard: 10, Max: 25)",
period="Zeitraum (Standard: Gesamt)", period="Zeitraum (Standard: Gesamt)",
user="Fokus-Ansicht für einen bestimmten Nutzer.",
) )
@app_commands.choices(period=[ @app_commands.choices(period=[
app_commands.Choice(name="Gesamt (All-Time)", value="alltime"), app_commands.Choice(name="Gesamt (All-Time)", value="alltime"),
app_commands.Choice(name="Diese Woche", value="weekly"), app_commands.Choice(name="Diese Woche", value="weekly"),
app_commands.Choice(name="Diesen Monat", value="monthly"), app_commands.Choice(name="Diesen Monat", value="monthly"),
]) ])
async def mutescore(interaction: discord.Interaction, limit: int = 10, period: str = "alltime"): async def mutescore(
limit = max(1, min(limit, 25)) interaction: discord.Interaction,
limit: int = 10,
period: str = "alltime",
user: Optional[discord.Member] = None,
) -> None:
guild = interaction.guild guild = interaction.guild
if reset_periods_if_needed(mute_data, guild.id):
# Perioden-Reset prüfen bevor angezeigt wird save_data(mute_data)
reset_periods_if_needed(mute_data, guild.id)
lb = get_sorted_lb(mute_data, guild.id, period) lb = get_sorted_lb(mute_data, guild.id, period)
lb_filtered = [(uid, e) for uid, e in lb if entry_total(e, period) > 0] 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 not lb_filtered: 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( await interaction.response.send_message(
f"Für den Zeitraum **{PERIOD_LABELS[period]}** gibt es noch keine Mutes.", f"**{user.display_name}** hat noch keine Mutes im Zeitraum "
f"**{PERIOD_LABELS[period]}**.",
ephemeral=True, ephemeral=True,
) )
return return
top = lb_filtered[:limit] ctx_start = max(0, user_rank - 3)
medals = {1: "🥇", 2: "🥈", 3: "🥉"} ctx_end = min(len(lb_filtered), user_rank + 2)
lines = [] 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])
]
for rank, (uid, counts) in enumerate(top, start=1): embed = discord.Embed(
m = guild.get_member(int(uid)) title=f"🔍 Mute-Leaderboard — Fokus: {user.display_name}",
name = m.display_name if m else f"Unbekannt ({uid})" color=discord.Color.blurple(),
medal = 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)
lines.append(
f"{medal} {name} — **{mic + deaf}x** gesamt "
f"*(🎙️ {mic}x Mikro / 🔇 {deaf}x Deaf)*"
) )
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( embed = discord.Embed(
title=f"Mute-Leaderboard — {PERIOD_LABELS[period]}", title=f"Mute-Leaderboard — {PERIOD_LABELS[period]}",
@@ -469,51 +529,46 @@ async def mutescore(interaction: discord.Interaction, limit: int = 10, period: s
@bot.tree.command(name="mymutes", description="Zeigt deine persönlichen 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) -> None:
reset_periods_if_needed(mute_data, interaction.guild.id) if reset_periods_if_needed(mute_data, interaction.guild.id):
entry = get_user_entry(mute_data, interaction.guild.id, interaction.user.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) mic, deaf = entry.get("mic", 0), entry.get("deaf", 0)
w_mic, w_deaf = entry.get("w_mic", 0), entry.get("w_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) m_mic, m_deaf = entry.get("m_mic", 0), entry.get("m_deaf", 0)
total = mic + deaf total = mic + deaf
# Mute-Ratio
voice_sec = entry.get("voice_sec", 0.0) voice_sec = entry.get("voice_sec", 0.0)
voice_h = voice_sec / 3600 voice_h = voice_sec / 3600
if voice_h >= 0.5: ratio_str = (
ratio_str = f"**{total / voice_h:.1f}** Mutes/Stunde *(Voice-Zeit: {format_voice_time(voice_sec)})*" f"**{total / voice_h:.1f}** Mutes/Stunde *(Voice-Zeit: {format_voice_time(voice_sec)})*"
else: if voice_h >= 0.5 else
ratio_str = f"Noch nicht genug Voice-Zeit *(bisher: {format_voice_time(voice_sec)})*" f"Noch nicht genug Voice-Zeit *(bisher: {format_voice_time(voice_sec)})*"
)
# Rekordtag
best_day = entry.get("best_day") best_day = entry.get("best_day")
best_count = entry.get("best_day_count", 0) 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" 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_str = date.today().isoformat()
today_count = entry.get("today_count", 0) if entry.get("today") == today_str else 0 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( embed = discord.Embed(
title=f"Mute-Stats von {interaction.user.display_name}", title=f"Mute-Stats von {interaction.user.display_name}",
color=discord.Color.blurple(), color=discord.Color.blurple(),
) )
embed.set_thumbnail(url=interaction.user.display_avatar.url) embed.set_thumbnail(url=interaction.user.display_avatar.url)
embed.add_field(name="📊 All-Time",
embed.add_field(
name="📊 All-Time",
value=f"🎙️ {mic}x Mikro | 🔇 {deaf}x Deaf | **{total}x gesamt**", value=f"🎙️ {mic}x Mikro | 🔇 {deaf}x Deaf | **{total}x gesamt**",
inline=False, inline=False)
)
embed.add_field(name="📅 Diese Woche", value=f"**{w_mic + w_deaf}x**", inline=True) 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="🗓️ 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="☀️ Heute", value=f"**{today_count}x**", inline=True)
embed.add_field(name="🏆 Rekordtag", value=best_str, inline=False) 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="⚡ Mute-Ratio", value=ratio_str, inline=False)
embed.add_field(name="🏷️ Rang", value=rank_name, 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) await interaction.response.send_message(embed=embed, ephemeral=True)