From c0dab116cb66f1efc57942919417665ce5c5f217 Mon Sep 17 00:00:00 2001 From: Medicopter117 Date: Sun, 18 Jan 2026 09:21:25 +0100 Subject: [PATCH] UPDATED --- docs/source/changelog/index.rst | 3 + main.py | 3 +- src/bot/cogs/guild/globalchat.py | 1518 +++++++++++++++++++++++++ src/bot/cogs/guild/levelsystem.py | 974 ++++++++++++++++ src/bot/cogs/guild/loggingsystem.py | 1465 ++++++++++++++++++++++++ src/bot/cogs/guild/tempvc.py | 612 ++++++++++ src/bot/cogs/guild/welcome.py | 1467 ++++++++++++++++++++++++ src/bot/cogs/management/autodelete.py | 309 +++++ src/bot/cogs/management/autorole.py | 273 +++++ src/bot/cogs/moderation/antispam.py | 435 +++++++ src/bot/cogs/moderation/moderation.py | 550 +++++++++ src/bot/cogs/moderation/notes.py | 70 ++ src/bot/cogs/moderation/warn.py | 557 +++++++++ src/bot/cogs/user/settings.py | 108 ++ src/bot/cogs/user/stats.py | 598 ++++++++++ src/bot/core/__init__.py | 1 - translation/en_en.json | 31 + translation/ez_de.json | 32 + translation/messages/de.yaml | 122 ++ translation/messages/en.yaml | 119 ++ 20 files changed, 9244 insertions(+), 3 deletions(-) create mode 100644 src/bot/cogs/guild/globalchat.py create mode 100644 src/bot/cogs/guild/levelsystem.py create mode 100644 src/bot/cogs/guild/loggingsystem.py create mode 100644 src/bot/cogs/guild/tempvc.py create mode 100644 src/bot/cogs/guild/welcome.py create mode 100644 src/bot/cogs/management/autodelete.py create mode 100644 src/bot/cogs/management/autorole.py create mode 100644 src/bot/cogs/moderation/antispam.py create mode 100644 src/bot/cogs/moderation/moderation.py create mode 100644 src/bot/cogs/moderation/notes.py create mode 100644 src/bot/cogs/moderation/warn.py create mode 100644 src/bot/cogs/user/settings.py create mode 100644 src/bot/cogs/user/stats.py create mode 100644 translation/en_en.json create mode 100644 translation/ez_de.json create mode 100644 translation/messages/de.yaml create mode 100644 translation/messages/en.yaml diff --git a/docs/source/changelog/index.rst b/docs/source/changelog/index.rst index e69de29..cfbea3d 100644 --- a/docs/source/changelog/index.rst +++ b/docs/source/changelog/index.rst @@ -0,0 +1,3 @@ +All Versions of ManagerX. +========================================= + diff --git a/main.py b/main.py index 6dc07c8..30b27d9 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,6 @@ from src.bot.core.database import DatabaseManager from src.bot.core.dashboard import DashboardTask from src.bot.core.utils import print_logo - # ============================================================================= # SETUP # ============================================================================= @@ -38,7 +37,6 @@ # Sys-Path if str(BASEDIR) not in sys.path: sys.path.append(str(BASEDIR)) - # ============================================================================= # MAIN EXECUTION # ============================================================================= @@ -88,6 +86,7 @@ async def on_ready(): # Commands sync await bot.sync_commands() logger.success("COMMANDS", "Application Commands synchronisiert") + # Minimaler KeepAlive Cog - damit Bot immer online bleibt class KeepAlive(discord.ext.commands.Cog): diff --git a/src/bot/cogs/guild/globalchat.py b/src/bot/cogs/guild/globalchat.py new file mode 100644 index 0000000..daa4686 --- /dev/null +++ b/src/bot/cogs/guild/globalchat.py @@ -0,0 +1,1518 @@ +# Copyright (c) 2025 OPPRO.NET Network +import discord +from discord.ext import commands, tasks +from discord import slash_command, Option, SlashCommandGroup +from DevTools.backend.database.globalchat_db import GlobalChatDatabase, db +import asyncio +import logging +import re +import time +from typing import List, Optional, Dict, Tuple +import aiohttp +import io +import json +from datetime import datetime, timedelta +import ezcord +from collections import defaultdict +from discord.ui import Container + +# Logger konfigurieren +logger = logging.getLogger(__name__) + + +class GlobalChatConfig: + """Zentrale Konfiguration für GlobalChat""" + RATE_LIMIT_MESSAGES = 15 + RATE_LIMIT_SECONDS = 60 + CACHE_DURATION = 180 # 3 Minuten + CLEANUP_DAYS = 30 + MIN_MESSAGE_LENGTH = 0 # Erlaube Nachrichten ohne Text (nur Medien) + DEFAULT_MAX_MESSAGE_LENGTH = 1900 + DEFAULT_EMBED_COLOR = '#5865F2' + + # Medien-Limits + MAX_FILE_SIZE_MB = 25 # Discord-Standard + MAX_ATTACHMENTS = 10 + ALLOWED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] + ALLOWED_VIDEO_FORMATS = ['mp4', 'mov', 'webm', 'avi', 'mkv'] + ALLOWED_AUDIO_FORMATS = ['mp3', 'wav', 'ogg', 'm4a', 'flac'] + ALLOWED_DOCUMENT_FORMATS = ['pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', '7z'] + + # Bot Owner IDs + BOT_OWNERS = [1093555256689959005, 1427994077332373554] + + # Content Filter Patterns + DISCORD_INVITE_PATTERN = r'(?i)\b(discord\.gg|discord\.com/invite|discordapp\.com/invite)/[a-zA-Z0-9]+\b' + URL_PATTERN = r'(?i)\bhttps?://(?:[a-zA-Z0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F]{2}))+\b' + + # NSFW Keywords + NSFW_KEYWORDS = [ + 'nsfw', 'porn', 'sex', 'xxx', 'nude', 'hentai', + 'dick', 'pussy', 'cock', 'tits', 'ass', 'fuck' + ] + + +class MediaHandler: + """Verarbeitet alle Arten von Medien und Anhängen""" + + def __init__(self, config: GlobalChatConfig): + self.config = config + + def validate_attachments(self, attachments: List[discord.Attachment]) -> Tuple[bool, str, List[discord.Attachment]]: + """Validiert Attachments und gibt valide zurück""" + if not attachments: + return True, "", [] + + if len(attachments) > self.config.MAX_ATTACHMENTS: + return False, f"Zu viele Anhänge (max. {self.config.MAX_ATTACHMENTS})", [] + + valid_attachments = [] + max_size_bytes = self.config.MAX_FILE_SIZE_MB * 1024 * 1024 + + for attachment in attachments: + # Größe prüfen + if attachment.size > max_size_bytes: + return False, f"Datei '{attachment.filename}' ist zu groß (max. {self.config.MAX_FILE_SIZE_MB}MB)", [] + + # Dateiformat prüfen + file_ext = attachment.filename.split('.')[-1].lower() if '.' in attachment.filename else '' + + all_allowed = ( + self.config.ALLOWED_IMAGE_FORMATS + + self.config.ALLOWED_VIDEO_FORMATS + + self.config.ALLOWED_AUDIO_FORMATS + + self.config.ALLOWED_DOCUMENT_FORMATS + ) + + if file_ext and file_ext not in all_allowed: + return False, f"Dateiformat '.{file_ext}' nicht erlaubt", [] + + valid_attachments.append(attachment) + + return True, "", valid_attachments + + def categorize_attachment(self, attachment: discord.Attachment) -> str: + """Kategorisiert einen Anhang nach Typ""" + if not attachment.filename or '.' not in attachment.filename: + return 'other' + + file_ext = attachment.filename.split('.')[-1].lower() + + if file_ext in self.config.ALLOWED_IMAGE_FORMATS: + return 'image' + elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: + return 'video' + elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: + return 'audio' + elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: + return 'document' + else: + return 'other' + + def get_attachment_icon(self, attachment: discord.Attachment) -> str: + """Gibt passendes Icon für Attachment-Typ zurück""" + category = self.categorize_attachment(attachment) + + icons = { + 'image': '🖼️', + 'video': '🎥', + 'audio': '🎵', + 'document': '📄', + 'other': '📎' + } + + return icons.get(category, '📎') + + def format_file_size(self, size_bytes: int) -> str: + """Formatiert Dateigröße leserlich""" + for unit in ['B', 'KB', 'MB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} GB" + + +class MessageValidator: + """Validiert und filtert Nachrichten""" + + def __init__(self, config: GlobalChatConfig): + self.config = config + self.media_handler = MediaHandler(config) + self._compile_patterns() + + def _compile_patterns(self): + """Kompiliert Regex-Patterns für bessere Performance""" + self.invite_pattern = re.compile(self.config.DISCORD_INVITE_PATTERN) + self.url_pattern = re.compile(self.config.URL_PATTERN) + + def validate_message(self, message: discord.Message, settings: Dict) -> Tuple[bool, str]: + """Hauptvalidierung für Nachrichten""" + # Bot-Nachrichten ignorieren + if message.author.bot: + return False, "Bot-Nachricht" + + # Blacklist prüfen + if db.is_blacklisted('user', message.author.id): + return False, "User auf Blacklist" + + if db.is_blacklisted('guild', message.guild.id): + return False, "Guild auf Blacklist" + + # Leere Nachrichten (ohne Text UND ohne Anhänge/Sticker) + if not message.content and not message.attachments and not message.stickers: + return False, "Leere Nachricht" + + # Nachrichtenlänge (nur wenn Text vorhanden) + if message.content: + content_length = len(message.content.strip()) + + # Mindestlänge nur bei reinen Text-Nachrichten + if content_length < self.config.MIN_MESSAGE_LENGTH and not message.attachments and not message.stickers: + return False, "Zu kurze Nachricht" + + max_length = settings.get('max_message_length', self.config.DEFAULT_MAX_MESSAGE_LENGTH) + if content_length > max_length: + return False, f"Nachricht zu lang (max. {max_length} Zeichen)" + + # Attachments validieren + if message.attachments: + valid, reason, _ = self.media_handler.validate_attachments(message.attachments) + if not valid: + return False, f"Ungültige Anhänge: {reason}" + + # Content Filter + if settings.get('filter_enabled', True): + is_filtered, filter_reason = self.check_filtered_content(message.content) + if is_filtered: + return False, f"Gefilterte Inhalte: {filter_reason}" + + # NSFW Filter + if settings.get('nsfw_filter', True): + if self.check_nsfw_content(message.content): + return False, "NSFW Inhalt erkannt" + + return True, "OK" + + def check_filtered_content(self, content: str) -> Tuple[bool, str]: + """Prüft auf gefilterte Inhalte mit detailliertem Grund""" + if not content: + return False, "" + + # Discord Invites + if self.invite_pattern.search(content): + return True, "Discord Invite" + + return False, "" + + def check_nsfw_content(self, content: str) -> bool: + """Erweiterte NSFW-Erkennung""" + if not content: + return False + + content_lower = content.lower() + + # Keyword-Check mit Wortgrenzen + for keyword in self.config.NSFW_KEYWORDS: + pattern = r'\b' + re.escape(keyword) + r'\b' + if re.search(pattern, content_lower): + return True + + return False + + def clean_content(self, content: str) -> str: + """Bereinigt Nachrichteninhalt""" + if not content: + return "" + + # @everyone und @here neutralisieren + content = content.replace('@everyone', '@everyone') + content = content.replace('@here', '@here') + + # Rolle-Mentions neutralisieren + content = re.sub(r'<@&(\d+)>', r'@role', content) + + return content + + +class EmbedBuilder: + """Erstellt formatierte Embeds für GlobalChat mit vollständigem Medien-Support""" + + def __init__(self, config: GlobalChatConfig, bot=None): + self.config = config + self.media_handler = MediaHandler(config) + self.bot = bot # Bot für Message-Fetching + + async def create_message_embed(self, message: discord.Message, settings: Dict, attachment_data: List[Tuple[str, bytes, str]] = None) -> Tuple[discord.Embed, List[Tuple[str, bytes]]]: + """Erstellt ein verbessertes Embed mit vollständigem Medien-Support + + attachment_data: Liste von (filename, bytes, content_type) - schon heruntergeladene Dateien + Gibt (embed, [(filename, bytes), ...]) zurück - Bytes statt discord.File! + """ + if attachment_data is None: + attachment_data = [] + + content = self._clean_content(message.content) + + # Embed-Farbe + embed_color = self._parse_color(settings.get('embed_color', self.config.DEFAULT_EMBED_COLOR)) + + # Beschreibung + if content: + description = content + elif message.attachments or message.stickers or attachment_data: + description = "*Medien-Nachricht*" + else: + description = "*Keine Beschreibung*" + + # Embed erstellen + embed = discord.Embed( + description=description, + color=embed_color, + timestamp=message.created_at + ) + + # Author mit Badges + author_text, badges = self._build_author_info(message.author) + embed.set_author( + name=author_text, + icon_url=message.author.display_avatar.url + ) + + # Footer mit Server-Info UND Original-Message-ID (für Reply-Tracking) + footer_text = f"📍 {message.guild.name} • #{message.channel.name} • ID:{message.id}" + embed.set_footer( + text=footer_text, + icon_url=message.guild.icon.url if message.guild.icon else None + ) + + # Reply-Kontext hinzufügen (robust, ohne invasive Änderungen) + if message.reference: + try: + # Versuche zuerst die gecachte referenzierte Nachricht + replied_msg = message.reference.resolved + + # Falls nicht im Cache, versuche die referenzierte Nachricht aus dem referenzierten Kanal zu holen + if not replied_msg and getattr(message.reference, 'message_id', None): + ref_channel = None + ref_chan_id = getattr(message.reference, 'channel_id', None) + if ref_chan_id: + # Versuche zuerst den Kanal vom Bot-Cache + ref_channel = self.bot.get_channel(ref_chan_id) + # Fallback auf Guild-Kanal + if not ref_channel and message.guild: + try: + ref_channel = message.guild.get_channel(ref_chan_id) + except Exception: + ref_channel = None + if not ref_channel: + ref_channel = message.channel + + if ref_channel: + try: + replied_msg = await ref_channel.fetch_message(message.reference.message_id) + except Exception: + replied_msg = None + + # Wenn wir eine referenzierte Nachricht haben, bereite Vorschau vor + if isinstance(replied_msg, discord.Message): + # Text-Vorschau (bevorzuge echten content) + preview = replied_msg.content or "" + + # Wenn die referenzierte Nachricht das Relay-Bot-Embed ist, versuche Text aus dem Embed + if not preview and replied_msg.embeds: + try: + preview = replied_msg.embeds[0].description or "" + except Exception: + preview = "" + + # Fallback auf Anhänge/Sticker + if not preview: + if replied_msg.attachments: + preview = f"📎 {len(replied_msg.attachments)} Datei(en)" + elif replied_msg.stickers: + preview = "🎨 Sticker" + else: + preview = "*(Leere Nachricht)*" + + preview = self._clean_content(preview) + preview_short = (preview[:200] + "...") if len(preview) > 200 else preview + + # Author bestimmen: falls die referenzierte Nachricht vom Bot ist, versuche embed.author + author_display = None + try: + if replied_msg.author and replied_msg.author.id == getattr(self.bot, 'user', None).id and replied_msg.embeds: + emb = replied_msg.embeds[0] + if emb.author and emb.author.name: + author_display = emb.author.name + except Exception: + author_display = None + + if not author_display: + try: + author_display = replied_msg.author.display_name + except Exception: + author_display = "Unbekannter User" + + # Herkunft (Server • #channel) + origin = None + try: + if getattr(replied_msg, 'guild', None) and getattr(replied_msg, 'channel', None): + origin = f"{replied_msg.guild.name} • #{replied_msg.channel.name}" + except Exception: + origin = None + + reply_field = f"**{author_display}:** {preview_short}" + if origin: + reply_field += f"\n_{origin}_" + + embed.add_field(name="↩️ Antwort (Vorschau)", value=reply_field, inline=False) + except Exception: + # Never fail building the embed just because reply resolution failed + pass + + # Medien verarbeiten mit heruntergeladenen Dateien + files_to_upload = await self._process_media(embed, message, attachment_data) + + # Rückgabe: Embed + Liste von discord.File Objekten + return embed, files_to_upload + + async def _process_media(self, embed: discord.Embed, message: discord.Message, attachment_data: List[Tuple[str, bytes, str]] = None) -> List[Tuple[str, bytes]]: + """Verarbeitet alle Medien-Typen mit heruntergeladenen Anhängen + + attachment_data: Liste von (filename, bytes, content_type) - bereits heruntergeladen + Gibt Liste von (filename, bytes) zurück - NOT discord.File! + """ + if attachment_data is None: + attachment_data = [] + + attachment_bytes: List[Tuple[str, bytes]] = [] + + # === HERUNTERGELADENE ATTACHMENTS === + if attachment_data: + attachment_bytes.extend(self._process_downloaded_attachments(embed, attachment_data)) + + # === STICKERS === + if message.stickers: + self._process_stickers(embed, message.stickers) + + # === ORIGINAL EMBEDS (z.B. von Links) === + if message.embeds: + self._process_embeds(embed, message.embeds) + + return attachment_bytes + + def _process_downloaded_attachments(self, embed: discord.Embed, attachment_data: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes]]: + """Verarbeitet heruntergeladene Anhänge und gibt (filename, bytes) zurück + + attachment_data: [(filename, bytes_data, content_type), ...] + Gibt [(filename, bytes), ...] zurück - NICHT discord.File! + """ + attachment_bytes: List[Tuple[str, bytes]] = [] + + # Kategorisiere nach Typ + images = [] + videos = [] + audios = [] + documents = [] + others = [] + + for filename, data, content_type in attachment_data: + # Bestimme Dateityp anhand von content_type und Dateiendung + category = self._get_attachment_category(filename, content_type) + + if category == 'image': + images.append((filename, data)) + elif category == 'video': # HIER wurde der Code vervollständigt + videos.append((filename, data)) + elif category == 'audio': + audios.append((filename, data)) + elif category == 'document': + documents.append((filename, data)) + else: + others.append((filename, data)) # Vervollständigt + + # === IMAGE (NUR das erste Bild als embed.image) === + if images: + # Das erste Bild als Embed-Bild setzen + embed.set_image(url=f"attachment://{images[0][0]}") + # Alle Bilder für den Upload vorbereiten + for filename, data in images: + attachment_bytes.append((filename, data)) + + if len(images) > 1: + # Füge einen Hinweis hinzu, dass weitere Bilder angehängt sind + embed.add_field( + name="🖼️ Weitere Bilder", + value=f"_{len(images)-1} zusätzliche Bilder angehängt._", + inline=False + ) + + # === VIDEOS === + if videos: + video_links = [] + for video_name, video_data in videos: + size = len(video_data) + size_str = self.media_handler.format_file_size(size) + video_links.append(f"🎥 {video_name} ({size_str})") + attachment_bytes.append((video_name, video_data)) + + if video_links: + embed.add_field( + name="🎬 Videos", + value="\n".join(video_links[:3]), # Max 3 + inline=False + ) + + # === AUDIO === + if audios: + audio_links = [] + for audio_name, audio_data in audios: + size = len(audio_data) + size_str = self.media_handler.format_file_size(size) + audio_links.append(f"🎵 {audio_name} ({size_str})") + attachment_bytes.append((audio_name, audio_data)) + + if audio_links: + embed.add_field( + name="🎧 Audio-Dateien", + value="\n".join(audio_links[:3]), # Max 3 + inline=False + ) + + # === DOKUMENTE === + if documents: + doc_links = [] + for doc_name, doc_data in documents: + size = len(doc_data) + size_str = self.media_handler.format_file_size(size) + doc_links.append(f"📄 {doc_name} ({size_str})") + attachment_bytes.append((doc_name, doc_data)) + + if doc_links: + embed.add_field( + name="📄 Dokumente", + value="\n".join(doc_links[:3]), # Max 3 + inline=False + ) + + # === SONSTIGE === + if others: + other_links = [] + for other_name, other_data in others: + size = len(other_data) + size_str = self.media_handler.format_file_size(size) + other_links.append(f"📎 {other_name} ({size_str})") + attachment_bytes.append((other_name, other_data)) + + if other_links: + embed.add_field( + name="📎 Sonstige", + value="\n".join(other_links[:3]), # Max 3 + inline=False + ) + + return attachment_bytes # Wichtig: bytes zurückgeben + + def _process_stickers(self, embed: discord.Embed, stickers: List[discord.StickerItem]): + """Verarbeitet Discord Sticker""" + if not stickers: + return + + sticker_info = [] + for sticker in stickers: + sticker_type = "Standard" if sticker.url.endswith('.png') else "Animiert" + sticker_info.append(f"🎨 **{sticker.name}** ({sticker_type})") + + embed.add_field( + name="🎨 Sticker", + value="\n".join(sticker_info[:3]), + inline=False + ) + + # Versuche, das erste Bild (falls vorhanden) als Thumbnail zu setzen + if stickers[0].format.name in ['PNG', 'LOTTIE']: + embed.set_thumbnail(url=stickers[0].url) + + def _process_embeds(self, main_embed: discord.Embed, embeds: List[discord.Embed]): + """Verarbeitet Original-Embeds (z.B. Link-Vorschauen)""" + if not embeds: + return + + link_embeds = [] + for embed in embeds: + # Nur Embeds mit Titeln oder Beschreibungen, die keine eigenen Attachments sind, verarbeiten + if embed.type not in ['image', 'video', 'gifv'] and (embed.title or embed.description or embed.url): + + title = embed.title or "Unbekannter Link" + description = (embed.description[:100] + "...") if embed.description else "" + url = embed.url or "" + + link_embeds.append(f"**[{title}]({url})**\n_{description}_") + + if link_embeds: + main_embed.add_field( + name="🔗 Verlinkte Inhalte", + value="\n\n".join(link_embeds), + inline=False + ) + + def _get_attachment_category(self, filename: str, content_type: str) -> str: + """Hilfsfunktion zur Kategorisierung basierend auf Name und Content-Type""" + if content_type.startswith('image/'): + return 'image' + elif content_type.startswith('video/'): + return 'video' + elif content_type.startswith('audio/'): + return 'audio' + + # Fallback auf Dateiendung + if not filename or '.' not in filename: + return 'other' + + file_ext = filename.split('.')[-1].lower() + if file_ext in self.config.ALLOWED_IMAGE_FORMATS: + return 'image' + elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: + return 'video' + elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: + return 'audio' + elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: + return 'document' + else: + return 'other' + + def _clean_content(self, content: str) -> str: + """Bereinigt Nachrichteninhalt""" + if not content: + return "" + content = content.replace('@everyone', '@everyone') + content = content.replace('@here', '@here') + content = re.sub(r'<@&(\d+)>', r'@role', content) + return content.strip() + + def _parse_color(self, color_hex: str) -> discord.Color: + """Parst Hex-Farbe zu discord.Color""" + try: + color_hex = color_hex.lstrip('#') + return discord.Color(int(color_hex, 16)) + except (ValueError, TypeError): + return discord.Color.blurple() + + def _build_author_info(self, author: discord.Member) -> Tuple[str, List[str]]: + """Baut Author-Text mit Badges""" + badges = [] + roles = [] + # Bot Owner + if author.id in self.config.BOT_OWNERS: + badges.append("👑") + roles.append("Bot Owner") + # Server Admin/Mod + if author.guild_permissions.administrator: + badges.append("⚡") + roles.append("Admin") + elif author.guild_permissions.manage_guild: + badges.append("🔧") + roles.append("Mod") + + badge_text = " ".join(badges) + author_text = f"{badge_text} {author.display_name}".strip() + + # Hinzufügen von Discord System Badges (z.B. Bot, Verified Bot) + if author.bot: + author_text += " [BOT]" + + return author_text, roles + + +class GlobalChatSender: + """Verantwortlich für das Senden der Nachricht an alle verbundenen Kanäle""" + def __init__(self, bot, config: GlobalChatConfig, embed_builder: EmbedBuilder, cache_ref: List[int]): + self.bot = bot + self.config = config + self.embed_builder = embed_builder + self._cached_channels = cache_ref # Referenz zum Cache in der Cog + + async def _get_all_active_channels(self) -> List[int]: + """Ruft alle aktiven Channel-IDs ab, nutzt den Cache""" + if self._cached_channels is None: + # Cache initial füllen + self._cached_channels = await self._fetch_all_channels() + return self._cached_channels + + async def _fetch_all_channels(self) -> List[int]: + """Holt Channel IDs direkt aus der Datenbank""" + try: + channel_ids = db.get_all_channels() + return channel_ids + except Exception as e: + logger.error(f"❌ Fehler beim Abrufen aller Channel-IDs: {e}", exc_info=True) + return [] + + async def _send_to_channel(self, channel_id: int, embed: discord.Embed, attachment_bytes: List[Tuple[str, bytes]]) -> bool: + """Sendet die Embed-Nachricht an einen spezifischen Channel mit Error-Handling + attachment_bytes: Liste von (filename, bytes) - wird zu discord.File konvertiert + Wichtig: Raw bytes, nicht discord.File, da File-Streams verbraucht sind! + """ + try: + channel = self.bot.get_channel(channel_id) + if not channel: + logger.warning(f"⚠️ Channel {channel_id} nicht gefunden") + return False + + # Permissions prüfen + perms = channel.permissions_for(channel.guild.me) + if not perms.send_messages or not perms.embed_links: + logger.warning(f"⚠️ Keine Permissions in {channel_id}") + return False + + # Erstelle NEUE discord.File Objekte für diesen Channel (wichtig!) + # Jeder Channel bekommt seine eigenen frischen Files! + files = [] + if attachment_bytes: + for filename, data in attachment_bytes: + try: + files.append(discord.File(io.BytesIO(data), filename=filename)) + except Exception as e: + logger.warning(f"⚠️ Error creating file {filename}: {e}") + + # Sende mit Retry-Logik + max_retries = 3 + for attempt in range(max_retries): + try: + if files: + await channel.send(embed=embed, files=files) + else: + await channel.send(embed=embed) + return True + except (ConnectionResetError, aiohttp.ClientConnectorError, asyncio.TimeoutError) as e: + logger.warning(f"❌ Sendefehler (Retry {attempt+1}/{max_retries}) in {channel_id}: {e}") + await asyncio.sleep(1 + attempt * 2) + except discord.Forbidden: + logger.warning(f"❌ Bot hat Senderechte in {channel_id} verloren. Enferne aus Cache.") + if channel_id in self._cached_channels: + self._cached_channels.remove(channel_id) + return False + except Exception as e: + logger.error(f"❌ Unerwarteter Sendefehler in {channel_id}: {e}") + return False + + # Wenn alle Retries fehlschlagen + logger.error(f"❌ Senden nach {max_retries} Retries in {channel_id} fehlgeschlagen.") + return False + + except Exception as e: + logger.error(f"❌ Generischer Fehler im _send_to_channel: {e}", exc_info=True) + return False + + async def send_global_message(self, message: discord.Message, attachment_data: List[Tuple[str, bytes, str]] = None) -> Tuple[int, int]: + """Sendet eine Nachricht global an alle verbundenen Channels""" + settings = db.get_guild_settings(message.guild.id) + + embed, files_to_upload = await self.embed_builder.create_message_embed(message, settings, attachment_data) + + active_channels = await self._get_all_active_channels() + successful_sends = 0 + failed_sends = 0 + + # Berechne, wie viele Tasks gleichzeitig laufen sollen (z.B. 10) + tasks = [] + for channel_id in active_channels: + # Sende nicht an den Ursprungskanal zurück + if channel_id == message.channel.id: + continue + + tasks.append(self._send_to_channel(channel_id, embed, files_to_upload)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + if result is True: + successful_sends += 1 + else: + failed_sends += 1 + if isinstance(result, Exception): + logger.error(f"❌ Task-Fehler beim Senden: {result}") + + return successful_sends, failed_sends + + +class GlobalChatCog(ezcord.Cog): + """Haupt-Cog für das GlobalChat-System""" + + globalchat = SlashCommandGroup("globalchat", "GlobalChat Verwaltung") + + + def __init__(self, bot): + self.bot = bot + self.config = GlobalChatConfig() + self.validator = MessageValidator(self.config) + self.embed_builder = EmbedBuilder(self.config, bot) + self.message_cooldown = commands.CooldownMapping.from_cooldown( + self.config.RATE_LIMIT_MESSAGES, + self.config.RATE_LIMIT_SECONDS, + commands.BucketType.user + ) + self._cached_channels: Optional[List[int]] = None + self.sender = GlobalChatSender(self.bot, self.config, self.embed_builder, self._cached_channels) + self.cleanup_task.start() + + @tasks.loop(hours=12) + async def cleanup_task(self): + """Task zur Bereinigung abgelaufener Blacklist-Einträge und Cache-Aktualisierung""" + # db.delete_expired_blacklist_entries() <--- DIESE ZEILE AUSKOMMENTIEREN + # logger.info("🗑️ GlobalChat: Abgelaufene Blacklist-Einträge bereinigt.") + + # Cache neu laden, um Änderungen in der DB zu sehen + self._cached_channels = await self.sender._fetch_all_channels() + logger.info("🧠 GlobalChat: Channel-Cache neu geladen.") + + @ezcord.Cog.listener() + async def on_message(self, message: discord.Message): + """Haupt-Listener für eingehende GlobalChat-Nachrichten""" + if not message.guild or message.author.bot: + return + + # Prüfen ob Channel ein GlobalChat-Channel ist + global_chat_channel_id = db.get_globalchat_channel(message.guild.id) + if message.channel.id != global_chat_channel_id: + return + + # Guild-Settings laden + settings = db.get_guild_settings(message.guild.id) + + # Message validieren + is_valid, reason = self.validator.validate_message(message, settings) + if not is_valid: + logger.debug(f"❌ Nachricht abgelehnt: {reason} (User: {message.author.id})") + + # User benachrichtigen bei bestimmten Gründen + if any(keyword in reason for keyword in ["Blacklist", "NSFW", "Gefilterte", "Ungültige Anhänge", "zu groß"]): + try: + await message.add_reaction("❌") + # Info-Nachricht für spezifische Fehler + if "Ungültige Anhänge" in reason or "zu groß" in reason: + info_msg = await message.reply( + f"❌ **Fehler:** {reason}\n" + f"**Max. Größe:** {self.config.MAX_FILE_SIZE_MB}MB pro Datei\n" + f"**Max. Anhänge:** {self.config.MAX_ATTACHMENTS}", + delete_after=7 + ) + await asyncio.sleep(2) + await message.delete() + except (discord.Forbidden, discord.NotFound): + pass # Kann Nachricht nicht löschen/reagieren + return + + # Rate Limiting prüfen + bucket = self.message_cooldown.get_bucket(message) + retry_after = bucket.update_rate_limit() + if retry_after: + try: + await message.add_reaction("⏰") + await asyncio.sleep(2) + await message.delete() + logger.debug(f"⏰ Nachricht von {message.author.id} wegen Rate Limit entfernt.") + except (discord.Forbidden, discord.NotFound): + pass + return + + # === Medien herunterladen (wenn vorhanden) === + attachment_data: List[Tuple[str, bytes, str]] = [] + if message.attachments: + try: + await message.channel.trigger_typing() + for attachment in message.attachments: + # Maximal 25MB (Discord-Limit) + if attachment.size <= self.config.MAX_FILE_SIZE_MB * 1024 * 1024: + data = await attachment.read() + attachment_data.append((attachment.filename, data, attachment.content_type)) + except Exception as e: + logger.error(f"❌ Fehler beim Herunterladen von Attachments: {e}") + # Wenn Download fehlschlägt, Nachricht trotzdem ohne Medien senden + attachment_data = [] + + # Nachricht senden + successful, failed = await self.sender.send_global_message(message, attachment_data) + + # Ursprüngliche Nachricht löschen, wenn Relaying erfolgreich war + if settings.get('delete_original', False): + try: + await message.delete() + except discord.Forbidden: + logger.warning(f"⚠️ Keine Permissions zum Löschen der Original-Nachricht in {message.channel.id}") + except discord.NotFound: + pass + + logger.info(f"🌍 GlobalChat: Nachricht von {message.guild.name} | User: {message.author.name} | ✅ {successful} | ❌ {failed}") + + + # ==================== Slash Commands ==================== + + @globalchat.command( + name="setup", + description="Richtet einen GlobalChat-Channel ein" + ) + async def setup_globalchat( + self, + ctx: discord.ApplicationContext, + channel: discord.TextChannel = Option(discord.TextChannel, "Der GlobalChat-Channel", required=True) + ): + """Setup-Command für GlobalChat""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + # Bot Permissions prüfen + bot_perms = channel.permissions_for(ctx.guild.me) + missing_perms = [] + if not bot_perms.send_messages: missing_perms.append("Nachrichten senden") + if not bot_perms.manage_messages: missing_perms.append("Nachrichten verwalten") + if not bot_perms.embed_links: missing_perms.append("Links einbetten") + if not bot_perms.read_message_history: missing_perms.append("Nachrichten-Historie lesen") + if not bot_perms.attach_files: missing_perms.append("Dateien anhängen") # Wichtig für Medien + + if missing_perms: + perms_list = "\n".join([f"• {p}" for p in missing_perms]) + await ctx.respond( + f"❌ Mir fehlen wichtige Berechtigungen in {channel.mention}:\n{perms_list}", + ephemeral=True + ) + return + + try: + db.set_globalchat_channel(ctx.guild.id, channel.id) + + # Cache aktualisieren + self._cached_channels = await self.sender._fetch_all_channels() + + # UI Container für eine schönere Antwort (falls vorhanden) + container = Container() + + status_text = f"✅ **GlobalChat eingerichtet!**\n\n" + status_text += f"Der GlobalChat ist nun in {channel.mention} aktiv.\n" + status_text += f"Aktuell verbunden: **{len(self._cached_channels)}** Server." + + container.add_text(status_text) + container.add_separator() + + # Feature-Liste + feature_text = ( + "**Unterstützte Features:**\n" + "• 🖼️ Bilder, 🎥 Videos, 🎵 Audio\n" + "• 📄 Dokumente (Office, PDF, Archive)\n" + "• 🎨 Discord Sticker\n" + "• 🔗 Automatische Link-Previews\n" + "• ↩️ Reply auf andere Nachrichten\n\n" + "**Nächste Schritte:**\n" + "• `/globalchat settings` - Einstellungen anpassen\n" + "• `/globalchat stats` - Statistiken anzeigen\n" + "• `/globalchat media-info` - Medien-Limits anzeigen" + ) + container.add_text(feature_text) + + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + + except Exception as e: + logger.error(f"❌ Setup-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) + + @globalchat.command( + name="remove", + description="Entfernt den GlobalChat-Channel" + ) + async def remove_globalchat(self, ctx: discord.ApplicationContext): + """Entfernt GlobalChat vom Server""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + # Prüfen ob Channel existiert + channel_id = db.get_globalchat_channel(ctx.guild.id) + if not channel_id: + await ctx.respond("❌ GlobalChat ist auf diesem Server nicht eingerichtet.", ephemeral=True) + return + + try: + db.set_globalchat_channel(ctx.guild.id, None) + + # Cache aktualisieren + self._cached_channels = await self.sender._fetch_all_channels() + + await ctx.respond( + f"✅ **GlobalChat entfernt!**\n\n" + f"Der GlobalChat wurde von diesem Server entfernt.\n" + f"Es sind nun noch **{len(self._cached_channels)}** Server verbunden.", + ephemeral=True + ) + except Exception as e: + logger.error(f"❌ Remove-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) + + @globalchat.command( + name="settings", + description="Verwaltet Server-spezifische GlobalChat-Einstellungen" + ) + async def settings_globalchat( + self, + ctx: discord.ApplicationContext, + filter_enabled: Optional[bool] = Option(bool, "Content-Filter aktivieren/deaktivieren (Invites, etc.)", required=False), + nsfw_filter: Optional[bool] = Option(bool, "NSFW-Filter aktivieren/deaktivieren", required=False), + embed_color: Optional[str] = Option(str, "Hex-Farbcode für Embeds (z.B. #FF00FF)", required=False), + max_message_length: Optional[int] = Option( + int, + "Maximale Nachrichtenlänge", + required=False, + min_value=50, + max_value=2000 + ) + ): + """Verwaltet Server-spezifische Einstellungen""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + # Prüfen ob GlobalChat aktiv + if not db.get_globalchat_channel(ctx.guild.id): + await ctx.respond( + "❌ Dieser Server nutzt GlobalChat nicht!\n" + "Nutze `/globalchat setup` zuerst.", + ephemeral=True + ) + return + + updated = [] + # Filter aktivieren/deaktivieren + if filter_enabled is not None: + if db.update_guild_setting(ctx.guild.id, 'filter_enabled', filter_enabled): + updated.append(f"Content-Filter: {'✅ An' if filter_enabled else '❌ Aus'}") + + if nsfw_filter is not None: + if db.update_guild_setting(ctx.guild.id, 'nsfw_filter', nsfw_filter): + updated.append(f"NSFW-Filter: {'✅ An' if nsfw_filter else '❌ Aus'}") + + if embed_color: + # Hex-Validierung + if not re.match(r'^#[0-9a-fA-F]{6}$', embed_color): + await ctx.respond("❌ Ungültiger Hex-Farbcode. Erwarte z.B. `#5865F2`.", ephemeral=True) + return + if db.update_guild_setting(ctx.guild.id, 'embed_color', embed_color): + updated.append(f"Embed-Farbe: `{embed_color}`") + + if max_message_length is not None: + if db.update_guild_setting(ctx.guild.id, 'max_message_length', max_message_length): + updated.append(f"Max. Länge: **{max_message_length}** Zeichen") + + if not updated: + await ctx.respond("ℹ️ Keine Änderungen vorgenommen.", ephemeral=True) + return + + # Erfolgs-Embed + embed = discord.Embed( + title="✅ GlobalChat Einstellungen aktualisiert", + description="\n".join(updated), + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + + @globalchat.command( + name="ban", + description="🔨 Bannt einen User oder Server vom GlobalChat" + ) + async def globalchat_ban( + self, + ctx: discord.ApplicationContext, + entity_id: str = Option(str, "ID des Users oder Servers (Guild-ID)", required=True), + entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True), + reason: str = Option(str, "Grund für den Ban", required=True), + duration: Optional[int] = Option(int, "Dauer in Stunden (optional, permanent wenn leer)", required=False) + ): + """Bannt eine Entität aus dem GlobalChat""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + try: + entity_id_int = int(entity_id) + except ValueError: + await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) + return + + # Ban ausführen + try: + success = db.add_to_blacklist( + entity_type, + entity_id_int, + reason, + ctx.author.id, + duration + ) + if not success: + await ctx.respond("❌ Fehler beim Bannen!", ephemeral=True) + return + + # Success-Response + duration_text = f"{duration} Stunden" if duration else "Permanent" + embed = discord.Embed( + title="🔨 GlobalChat-Ban verhängt", + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + embed.add_field(name="Typ", value=entity_type.title(), inline=True) + embed.add_field(name="ID", value=f"`{entity_id_int}`", inline=True) + embed.add_field(name="Dauer", value=duration_text, inline=True) + embed.add_field(name="Grund", value=reason, inline=False) + embed.add_field(name="Von", value=ctx.author.mention, inline=True) + + if duration: + expires = datetime.utcnow() + timedelta(hours=duration) + embed.add_field( + name="Läuft ab", + value=f"", + inline=True + ) + + await ctx.respond(embed=embed) + logger.info( + f"🔨 Ban: {entity_type} {entity_id_int} | Grund: {reason} | Dauer: {duration_text} | Von: {ctx.author.id}" + ) + + except Exception as e: + logger.error(f"❌ Ban-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten beim Bannen!", ephemeral=True) + + + @globalchat.command( + name="unban", + description="🔓 Entfernt einen User oder Server von der GlobalChat-Blacklist" + ) + async def globalchat_unban( + self, + ctx: discord.ApplicationContext, + entity_id: str = Option(str, "ID des Users oder Servers", required=True), + entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True) + ): + """Entfernt eine Entität von der GlobalChat Blacklist""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + try: + entity_id_int = int(entity_id) + except ValueError: + await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) + return + + try: + if not db.is_blacklisted(entity_type, entity_id_int): + await ctx.respond(f"ℹ️ {entity_type.title()} `{entity_id_int}` ist nicht auf der Blacklist.", ephemeral=True) + return + + if db.remove_from_blacklist(entity_type, entity_id_int): + embed = discord.Embed( + title="🔓 GlobalChat-Unban erfolgreich", + description=f"{entity_type.title()} mit ID `{entity_id_int}` wurde von der Blacklist entfernt.", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + await ctx.respond(embed=embed) + logger.info(f"🔓 Unban: {entity_type} {entity_id_int} | Von: {ctx.author.id}") + else: + await ctx.respond("❌ Fehler beim Entfernen von der Blacklist!", ephemeral=True) + + except Exception as e: + logger.error(f"❌ Unban-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten beim Unbannen!", ephemeral=True) + + + @globalchat.command( + name="info", + description="Zeigt Informationen über den GlobalChat" + ) + async def globalchat_info(self, ctx: discord.ApplicationContext): + """Zeigt allgemeine Informationen""" + active_servers = await self.sender._get_all_active_channels() + + embed = discord.Embed( + title="🌍 GlobalChat - Vollständiger Medien-Support", + description=( + "Ein serverübergreifendes Chat-System mit vollständigem Medien-Support.\n\n" + f"**📊 Aktuell verbunden:** **{len(active_servers)}** Server\n\n" + "**🎯 Hauptfeatures:**\n" + "• Nachrichten werden an alle verbundenen Server gesendet\n" + "• Vollständiger Medien-Support (Bilder, Videos, Audio, Dokumente)\n" + "• Discord Sticker und Link-Previews\n" + "• Reply-Unterstützung mit Kontext\n" + "• Automatische Moderation und Filter\n" + "• Rate-Limiting gegen Spam\n" + "• Individuelle Server-Einstellungen" + ), + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="📁 Unterstützte Medien (Details: `/globalchat media-info`)", + value=( + "• 🖼️ Bilder\n" + "• 🎥 Videos\n" + "• 🎵 Audio\n" + "• 📄 Dokumente (PDF, Office, Archive)" + ), + inline=True + ) + + embed.add_field( + name="🛡️ Moderation", + value=( + f"• **Content-Filter:** {db.get_guild_settings(ctx.guild.id).get('filter_enabled', True) and '✅ An' or '❌ Aus'}\n" + f"• **NSFW-Filter:** {db.get_guild_settings(ctx.guild.id).get('nsfw_filter', True) and '✅ An' or '❌ Aus'}\n" + f"• **Nachrichtenlänge:** {db.get_guild_settings(ctx.guild.id).get('max_message_length', self.config.DEFAULT_MAX_MESSAGE_LENGTH)} Zeichen\n" + ), + inline=True + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @globalchat.command( + name="stats", + description="Zeigt GlobalChat-Statistiken" + ) + async def globalchat_stats(self, ctx: discord.ApplicationContext): + """Zeigt Statistiken (z.B. Blacklist-Einträge)""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + user_bans, guild_bans = db.get_blacklist_stats() + active_servers = await self.sender._get_all_active_channels() + + embed = discord.Embed( + title="📊 GlobalChat System-Statistiken", + color=discord.Color.gold(), + timestamp=datetime.utcnow() + ) + + embed.add_field(name="🌍 Verbundene Server", value=f"**{len(active_servers)}**", inline=True) + embed.add_field(name="👥 Gebannte User", value=f"**{user_bans}**", inline=True) + embed.add_field(name="🛡️ Gebannte Server", value=f"**{guild_bans}**", inline=True) + embed.add_field(name="⏳ Cache-Dauer", value=f"{self.config.CACHE_DURATION} Sekunden", inline=True) + embed.add_field(name="📜 Protokoll Bereinigung", value=f"Alle {self.config.CLEANUP_DAYS} Tage", inline=True) + embed.add_field( + name="⏰ Rate-Limit", + value=f"{self.config.RATE_LIMIT_MESSAGES} Nachrichten / {self.config.RATE_LIMIT_SECONDS} Sekunden", + inline=True + ) + + await ctx.respond(embed=embed, ephemeral=True) + + + @globalchat.command( + name="media-info", + description="Zeigt Details zu Medien-Limits und erlaubten Formaten" + ) + async def globalchat_media_info(self, ctx: discord.ApplicationContext): + """Zeigt Medien-Limits und unterstützte Formate""" + embed = discord.Embed( + title="📁 GlobalChat Medien-Limits & Formate", + description="Details zu den maximal erlaubten Dateigrößen und unterstützten Formaten.", + color=discord.Color.purple(), + timestamp=datetime.utcnow() + ) + + # Limits + embed.add_field( + name="⚠️ Wichtige Limits", + value=( + f"• **Max. {self.config.MAX_ATTACHMENTS} Anhänge** pro Nachricht\n" + f"• **Max. {self.config.MAX_FILE_SIZE_MB} MB** pro Datei (Discord-Limit)\n" + f"• **Max. {self.config.DEFAULT_MAX_MESSAGE_LENGTH} Zeichen** Textlänge\n" + f"• **Rate-Limit:** {self.config.RATE_LIMIT_MESSAGES} Nachrichten pro {self.config.RATE_LIMIT_SECONDS} Sekunden" + ), + inline=False + ) + + # Unterstützte Formate + embed.add_field( + name="🖼️ Bilder", + value=", ".join(self.config.ALLOWED_IMAGE_FORMATS).upper(), + inline=True + ) + embed.add_field( + name="🎥 Videos", + value=", ".join(self.config.ALLOWED_VIDEO_FORMATS).upper(), + inline=True + ) + embed.add_field( + name="🎵 Audio", + value=", ".join(self.config.ALLOWED_AUDIO_FORMATS).upper(), + inline=True + ) + embed.add_field( + name="📄 Dokumente/Archive", + value=", ".join(self.config.ALLOWED_DOCUMENT_FORMATS).upper(), + inline=False + ) + + await ctx.respond(embed=embed, ephemeral=True) + + + @globalchat.command( + name="help", + description="Zeigt die Hilfe-Seite für GlobalChat" + ) + async def globalchat_help(self, ctx: discord.ApplicationContext): + """Zeigt eine Übersicht aller verfügbaren Commands und Features.""" + embed = discord.Embed( + title="❓ GlobalChat Hilfe & Übersicht", + description="Übersicht aller verfügbaren Commands und Features.", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + # Setup & Verwaltung + embed.add_field( + name="⚙️ Setup & Verwaltung", + value=( + "`/globalchat setup` - Channel einrichten\n" + "`/globalchat remove` - Channel entfernen\n" + "`/globalchat settings` - Einstellungen anpassen" + ), + inline=False + ) + + # Informationen + embed.add_field( + name="📊 Informationen", + value=( + "`/globalchat info` - Allgemeine Infos\n" + "`/globalchat stats` - Statistiken anzeigen\n" + "`/globalchat media-info` - Medien-Details\n" + "`/globalchat help` - Diese Hilfe" + ), + inline=False + ) + + # Moderation (Admin) - Nur für Bot Owner + if ctx.author.id in self.config.BOT_OWNERS: + embed.add_field( + name="🛡️ Moderation (Bot Owner)", + value=( + "`/globalchat ban` - User/Server bannen\n" + "`/globalchat unban` - User/Server entbannen" + ), + inline=False + ) + + # Test & Debug (Admin) + if ctx.author.id in self.config.BOT_OWNERS: + embed.add_field( + name="🧪 Test & Debug (Bot Owner)", + value=( + "`/globalchat test-media` - Medien-Test\n" + "`/globalchat broadcast` - Nachricht an alle senden\n" + "`/globalchat reload-cache` - Cache neu laden\n" + "`/globalchat debug` - Debug-Info" + ), + inline=False + ) + + await ctx.respond(embed=embed, ephemeral=True) + + + @globalchat.command( + name="test-media", + description="🧪 Test-Command für Medien-Upload und -Anzeige" + ) + async def globalchat_test_media(self, ctx: discord.ApplicationContext): + """Zeigt Anweisungen für den Medien-Test""" + channel_id = db.get_globalchat_channel(ctx.guild.id) + if not channel_id: + await ctx.respond("❌ GlobalChat ist auf diesem Server nicht eingerichtet.", ephemeral=True) + return + + embed = discord.Embed( + title="🧪 GlobalChat Medien-Test", + description=( + "Dieser Test zeigt dir, welche Medien-Typen erfolgreich übermittelt werden können.\n\n" + "**Unterstützte Medien:**\n" + "• Bilder, Videos, Audio, Dokumente\n" + "• Discord Sticker\n" + "• Antworten auf andere Nachrichten\n\n" + "**So testest du:**\n" + f"1. Gehe zu <#{channel_id}> und sende eine Nachricht mit Anhängen.\n" + "2. Die Nachricht erscheint auf allen verbundenen Servern.\n\n" + "Probiere verschiedene Kombinationen aus! (Mehrere Dateien, Sticker + Text, Reply + Datei)" + ), + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="📊 Aktuelle Limits", + value=( + f"• Max. {self.config.MAX_ATTACHMENTS} Anhänge\n" + f"• Max. {self.config.MAX_FILE_SIZE_MB} MB pro Datei\n" + f"• {self.config.RATE_LIMIT_MESSAGES} Nachrichten / {self.config.RATE_LIMIT_SECONDS} Sekunden" + ), + inline=True + ) + + embed.add_field( + name="✅ Unterstützte Formate", + value=( + "Bilder, Videos, Audio,\n" + "Dokumente, Archive,\n" + "Office-Dateien, PDFs" + ), + inline=True + ) + + embed.set_footer(text=f"Test von {ctx.author}", icon_url=ctx.author.display_avatar.url) + + await ctx.respond(embed=embed, ephemeral=True) + + + @globalchat.command( + name="broadcast", + description="📢 Sendet eine Nachricht an alle verbundenen GlobalChat-Server" + ) + async def globalchat_broadcast( + self, + ctx: discord.ApplicationContext, + title: str = Option(str, "Der Titel der Broadcast-Nachricht", required=True), + message: str = Option(str, "Die Nachricht selbst", required=True) + ): + """Sendet einen Broadcast (nur Bot Owner)""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + + try: + # Broadcast Embed erstellen + embed = discord.Embed( + title=f"📢 GlobalChat Broadcast: {title}", + description=message, + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + embed.set_footer( + text=f"GlobalChat Broadcast von {ctx.author}", + icon_url=ctx.author.display_avatar.url + ) + + # An alle Channels senden + successful, failed = await self.sender.send_global_broadcast_message(embed) # Annahme: Eine separate Broadcast-Methode in Sender + + # Response + result_embed = discord.Embed( + title="✅ Broadcast gesendet", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + result_embed.add_field( + name="📊 Ergebnis", + value=( + f"**Erfolgreich:** {successful}\n" + f"**Fehlgeschlagen:** {failed}\n" + f"**Gesamt:** {successful + failed}" + ), + inline=False + ) + result_embed.add_field( + name="📝 Nachricht", + value=f"**{title}**\n{message[:100]}{'...' if len(message) > 100 else ''}", + inline=False + ) + await ctx.respond(embed=result_embed, ephemeral=True) + logger.info( + f"📢 Broadcast: '{title}' | Von: {ctx.author} | " + f"✅ {successful} | ❌ {failed}" + ) + except Exception as e: + logger.error(f"❌ Broadcast-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Fehler beim Senden des Broadcasts!", ephemeral=True) + + @globalchat.command( + name="reload-cache", + description="🧠 Lädt alle Cache-Daten neu (Admin)" + ) + async def globalchat_reload_cache(self, ctx: discord.ApplicationContext): + """Lädt den Channel-Cache neu (Bot Owner)""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + try: + old_count = len(self._cached_channels or []) + self._cached_channels = await self.sender._fetch_all_channels() + new_count = len(self._cached_channels) + + await ctx.respond( + f"✅ **Cache neu geladen!**\n\n" + f"Alte Channel-Anzahl: **{old_count}**\n" + f"Neue Channel-Anzahl: **{new_count}**", + ephemeral=True + ) + logger.info(f"🧠 GlobalChat Cache manuell neu geladen. {old_count} -> {new_count}") + + except Exception as e: + logger.error(f"❌ Cache Reload Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) + + + @globalchat.command( + name="debug", + description="🐛 Zeigt Debug-Informationen an (Admin)" + ) + async def globalchat_debug(self, ctx: discord.ApplicationContext): + """Zeigt Debug-Informationen (Bot Owner)""" + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + try: + cached_channels = len(self._cached_channels or []) + all_settings = db.get_all_guild_settings() + + debug_info = ( + f"**Bot-Status:**\n" + f"• Latency: `{round(self.bot.latency * 1000)}ms`\n" + f"• Guilds: `{len(self.bot.guilds)}`\n" + f"• Uptime: ``\n\n" + f"**GlobalChat-Status:**\n" + f"• Aktive Channels (Cache): `{cached_channels}`\n" + f"• DB Settings Einträge: `{len(all_settings)}`\n" + f"• Cleanup Task: `{'Aktiv' if self.cleanup_task.is_running() else 'Inaktiv'}`\n" + ) + + # Beispiel für Blacklist-Info + user_bans, guild_bans = db.get_blacklist_stats() + debug_info += ( + f"• Gebannte User/Server: `{user_bans} / {guild_bans}`" + ) + + embed = discord.Embed( + title="🐛 GlobalChat Debug-Informationen", + description=debug_info, + color=discord.Color.orange(), + timestamp=datetime.utcnow() + ) + await ctx.respond(embed=embed, ephemeral=True) + except Exception as e: + logger.error(f"❌ Debug Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) + + +# ==================== Setup Funktion ==================== +def setup(bot): + """Setup-Funktion für the cog when loaded by classic...""" + # Stelle sicher, dass die Datenbank initialisiert wird, falls nicht schon geschehen + GlobalChatDatabase().create_tables() + # Füge die Cog hinzu + bot.add_cog(GlobalChatCog(bot)) \ No newline at end of file diff --git a/src/bot/cogs/guild/levelsystem.py b/src/bot/cogs/guild/levelsystem.py new file mode 100644 index 0000000..e669b69 --- /dev/null +++ b/src/bot/cogs/guild/levelsystem.py @@ -0,0 +1,974 @@ +# Copyright (c) 2025 OPPRO.NET Network +import discord +from discord import SlashCommandGroup, Option +from discord.ext import commands, tasks +import time +import random +from DevTools import LevelDatabase +import asyncio +import io +import csv +from typing import Optional +from discord.ui import Container + + +class PrestigeConfirmView(discord.ui.View): + def __init__(self, db, user, guild): + super().__init__(timeout=300) + self.db = db + self.user = user + self.guild = guild + + @discord.ui.button(label="Bestätigen", style=discord.ButtonStyle.danger, emoji="⚠️") + async def confirm_prestige(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user != self.user: + await interaction.response.send_message("Nur der User kann sein eigenes Prestige bestätigen!", ephemeral=True) + return + + success = self.db.prestige_user(self.user.id, self.guild.id) + if success: + embed = discord.Embed( + title="✨ Prestige erfolgreich!", + description=f"{self.user.mention} hat ein Prestige durchgeführt!\nDu startest wieder bei Level 0, aber behältst deinen Prestige-Rang!", + color=0xff69b4 + ) + embed.set_footer(text="Gratulation zu deinem Prestige!") + else: + embed = discord.Embed( + title="❌ Prestige fehlgeschlagen", + description="Prestige konnte nicht durchgeführt werden. Möglicherweise erfüllst du nicht die Anforderungen.", + color=0xff0000 + ) + + await interaction.response.edit_message(embed=embed, view=None) + + @discord.ui.button(label="Abbrechen", style=discord.ButtonStyle.secondary) + async def cancel_prestige(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user != self.user: + await interaction.response.send_message("Nur der User kann seine eigene Aktion abbrechen!", ephemeral=True) + return + + embed = discord.Embed( + title="❌ Prestige abgebrochen", + description="Das Prestige wurde abgebrochen.", + color=0x999999 + ) + await interaction.response.edit_message(embed=embed, view=None) + + +class LevelSystem(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.db = LevelDatabase() + self.xp_cooldowns = {} # User-ID -> Timestamp + + # Starte Background Tasks + self.cleanup_expired_boosts.start() + self.cleanup_temporary_roles.start() + + def cog_unload(self): + """Cleanup beim Entladen der Cog""" + self.cleanup_expired_boosts.cancel() + self.cleanup_temporary_roles.cancel() + + levelsystem = SlashCommandGroup("levelsystem", "Verwalte das Levelsystem") + levelrole = SlashCommandGroup("levelrole", "Verwalte Level-Rollen") + xpboost = SlashCommandGroup("xpboost", "Verwalte XP-Boosts") + levelconfig = SlashCommandGroup("levelconfig", "Konfiguriere das Levelsystem") + + @tasks.loop(hours=1) + async def cleanup_expired_boosts(self): + """Entfernt abgelaufene XP-Boosts""" + # Hier würde die DB-Cleanup Logik implementiert werden + pass + + @tasks.loop(hours=1) + async def cleanup_temporary_roles(self): + """Entfernt abgelaufene temporäre Rollen""" + # Hier würde die temporäre Rollen Cleanup Logik implementiert werden + pass + + def create_level_up_embed(self, user: discord.Member, level: int, is_role_reward: bool = False, role: Optional[discord.Role] = None): + """Erstellt ein verbessertes Level-Up Embed""" + embed = discord.Embed(color=0x00ff00) + embed.set_author(name="🎉 Level Up!", icon_url=user.avatar.url if user.avatar else user.default_avatar.url) + embed.description = f"**{user.mention}** erreichte **Level {level}**!" + + if is_role_reward and role: + embed.add_field(name="🏆 Neue Rolle erhalten", value=f"**{role.name}**", inline=False) + embed.color = 0xffff00 + + embed.set_thumbnail(url=user.avatar.url if user.avatar else user.default_avatar.url) + return embed + + @commands.Cog.listener() + async def on_message(self, message): + # Ignoriere Bot-Nachrichten + if message.author.bot: + return + + # Nur in Servern, nicht in DMs + if message.guild is None: + return + + # Prüfe ob Levelsystem aktiviert ist + if not self.db.is_levelsystem_enabled(message.guild.id): + return + + # Prüfe ob Kanal auf Blacklist steht + if self.db.is_channel_blacklisted(message.guild.id, message.channel.id): + return + + user_id = message.author.id + guild_id = message.guild.id + current_time = time.time() + + # Guild-Konfiguration holen + config = self.db.get_guild_config(guild_id) + cooldown = config.get('cooldown', 30) + + # XP-Cooldown prüfen + if user_id in self.xp_cooldowns: + if current_time - self.xp_cooldowns[user_id] < cooldown: + return + + # Kanal-spezifischen Multiplikator anwenden + channel_multiplier = self.db.get_channel_multiplier(guild_id, message.channel.id) + + # XP berechnen + min_xp = config.get('min_xp', 10) + max_xp = config.get('max_xp', 20) + base_xp = random.randint(min_xp, max_xp) + final_xp = int(base_xp * channel_multiplier) + + # XP hinzufügen mit Anti-Spam Protection + level_up, new_level = self.db.add_xp(user_id, guild_id, final_xp, message.content) + + if not level_up and new_level == 0: + return # Anti-Spam blockierte die XP + + # Cooldown setzen + self.xp_cooldowns[user_id] = current_time + + # Level Up Behandlung + if level_up: + # Bestimme Zielkanal für Level-Up Nachrichten + target_channel = message.channel + level_up_channel_id = config.get('level_up_channel') + + if level_up_channel_id: + level_up_channel = message.guild.get_channel(level_up_channel_id) + if level_up_channel: + target_channel = level_up_channel + + embed = self.create_level_up_embed(message.author, new_level) + await target_channel.send(embed=embed) + + # Level-Rolle vergeben + role_id = self.db.get_role_for_level(guild_id, new_level) + if role_id: + role = message.guild.get_role(role_id) + if role: + try: + await message.author.add_roles(role, reason=f"Level {new_level} erreicht") + role_embed = discord.Embed( + title="🏆 Neue Rolle erhalten!", + description=f"{message.author.mention} hat die Rolle **{role.name}** erhalten!", + color=0xffff00 + ) + role_embed.set_thumbnail(url=message.author.avatar.url if message.author.avatar else message.author.default_avatar.url) + await target_channel.send(embed=role_embed) + except discord.Forbidden: + # Log oder Nachricht an Admins falls Bot keine Berechtigung hat + pass + + @levelsystem.command(description="Zeigt das Server-Leaderboard mit Paginierung") + async def leaderboard(self, ctx, + anzahl: discord.Option(int, "Anzahl der User", default=10, min_value=1, max_value=50)): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="❌ Levelsystem deaktiviert", + description="Das Levelsystem ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed) + return + + leaderboard_data = self.db.get_leaderboard(ctx.guild.id, anzahl) + + if not leaderboard_data: + embed = discord.Embed( + title="📊 Leaderboard", + description="Noch keine User im Leaderboard!", + color=0x0099ff + ) + await ctx.respond(embed=embed) + return + + embed = discord.Embed( + title=f"📊 Leaderboard - Top {len(leaderboard_data)}", + color=0x0099ff, + timestamp=discord.utils.utcnow() + ) + + description = "" + for i, (user_id, xp, level, messages, prestige) in enumerate(leaderboard_data, 1): + user = self.bot.get_user(user_id) + username = user.display_name if user else f"User {user_id}" + + if i == 1: + medal = "🥇" + elif i == 2: + medal = "🥈" + elif i == 3: + medal = "🥉" + else: + medal = f"**{i}.**" + + prestige_text = f"⭐{prestige} " if prestige > 0 else "" + description += f"{medal} {prestige_text}**{username}** - Level {level} ({xp:,} XP)\n" + + embed.description = description + embed.set_footer(text=f"Server: {ctx.guild.name}") + + await ctx.respond(embed=embed) + + @levelsystem.command(description="Zeigt erweiterte Benutzerstatistiken") + async def profil(self, ctx, + user: discord.Option(discord.Member, "User dessen Profil angezeigt werden soll", default=None)): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="❌ Levelsystem deaktiviert", + description="Das Levelsystem ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed) + return + + target_user = user or ctx.author + user_stats = self.db.get_user_stats(target_user.id, ctx.guild.id) + + if not user_stats: + embed = discord.Embed( + title="❌ Kein Profil gefunden", + description=f"{target_user.display_name} hat noch keine XP gesammelt!", + color=0xff0000 + ) + await ctx.respond(embed=embed) + return + + xp, level, messages, xp_needed, prestige, total_earned = user_stats + rank = self.db.get_user_rank(target_user.id, ctx.guild.id) + + embed = discord.Embed( + title=f"📊 Profil von {target_user.display_name}", + color=target_user.color or 0x0099ff, + timestamp=discord.utils.utcnow() + ) + + # Erste Zeile + embed.add_field(name="🏆 Level", value=str(level), inline=True) + embed.add_field(name="⭐ XP", value=f"{xp:,}", inline=True) + embed.add_field(name="📈 Rang", value=f"#{rank}", inline=True) + + # Zweite Zeile + embed.add_field(name="💬 Nachrichten", value=f"{messages:,}", inline=True) + embed.add_field(name="🎯 XP bis nächstes Level", value=f"{xp_needed:,}", inline=True) + + if prestige > 0: + embed.add_field(name="✨ Prestige", value=f"⭐{prestige}", inline=True) + + # Dritte Zeile + embed.add_field(name="💰 Gesamt verdiente XP", value=f"{total_earned:,}", inline=True) + + # XP pro Nachricht berechnen + xp_per_msg = total_earned / messages if messages > 0 else 0 + embed.add_field(name="📊 Ø XP/Nachricht", value=f"{xp_per_msg:.1f}", inline=True) + + # Aktiver XP-Multiplikator + multiplier = self.db.get_active_xp_multiplier(ctx.guild.id, target_user.id) + if multiplier > 1.0: + embed.add_field(name="🚀 Aktiver Boost", value=f"{multiplier}x", inline=True) + + # Fortschrittsbalken + current_level_xp = xp - self.db.xp_for_level(level) + next_level_xp = self.db.xp_for_level(level + 1) - self.db.xp_for_level(level) + progress = current_level_xp / next_level_xp if next_level_xp > 0 else 1 + + progress_bar = "█" * int(progress * 15) + "░" * (15 - int(progress * 15)) + embed.add_field( + name="📊 Level-Fortschritt", + value=f"`{progress_bar}` {progress * 100:.1f}%\n`{current_level_xp:,} / {next_level_xp:,} XP`", + inline=False + ) + + embed.set_thumbnail(url=target_user.avatar.url if target_user.avatar else target_user.default_avatar.url) + embed.set_footer(text=f"Server: {ctx.guild.name}") + + await ctx.respond(embed=embed) + + @levelsystem.command(description="Führt ein Prestige durch (Level 50+)") + async def prestige(self, ctx): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="❌ Levelsystem deaktiviert", + description="Das Levelsystem ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + config = self.db.get_guild_config(ctx.guild.id) + if not config.get('prestige_enabled', True): + embed = discord.Embed( + title="❌ Prestige deaktiviert", + description="Das Prestige-System ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + user_stats = self.db.get_user_stats(ctx.author.id, ctx.guild.id) + min_level = config.get('prestige_min_level', 50) + + if not user_stats or user_stats[1] < min_level: + embed = discord.Embed( + title="❌ Prestige nicht verfügbar", + description=f"Du musst mindestens Level {min_level} erreichen!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + # Bestätigung erforderlich + view = PrestigeConfirmView(self.db, ctx.author, ctx.guild) + embed = discord.Embed( + title="⚠️ Prestige Bestätigung", + description=f"Möchtest du wirklich dein Level zurücksetzen?\n\n**Was passiert:**\n• Dein Level wird auf 0 zurückgesetzt\n• Deine XP werden auf 0 zurückgesetzt\n• Du erhältst einen Prestige-Rang (⭐)\n• Du behältst deine Nachrichten-Anzahl\n\n**Aktuelles Level:** {user_stats[1]}", + color=0xffff00 + ) + embed.set_footer(text="Diese Aktion kann nicht rückgängig gemacht werden!") + await ctx.respond(embed=embed, view=view, ephemeral=True) + + @levelsystem.command(description="Zeigt erweiterte Server-Analytics") + async def analytics(self, ctx): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="❌ Levelsystem deaktiviert", + description="Das Levelsystem ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + analytics = self.db.get_detailed_analytics(ctx.guild.id) + + embed = discord.Embed( + title="📊 Server Analytics", + color=0x0099ff, + timestamp=discord.utils.utcnow() + ) + + # Grundlegende Statistiken + embed.add_field(name="👥 Aktive User", value=f"{analytics['total_users']:,}", inline=True) + embed.add_field(name="📈 Durchschnittslevel", value=f"{analytics['avg_level']:.1f}", inline=True) + embed.add_field(name="🏆 Höchstes Level", value=f"{analytics['max_level']}", inline=True) + + embed.add_field(name="⚡ Gesamt XP", value=f"{analytics['total_xp']:,}", inline=True) + embed.add_field(name="💬 Gesamt Nachrichten", value=f"{analytics['total_messages']:,}", inline=True) + embed.add_field(name="🕒 Heute aktiv", value=f"{analytics['active_today']}", inline=True) + + # Level-Verteilung + distribution = analytics['level_distribution'] + embed.add_field( + name="📊 Level-Verteilung", + value=f"🌱 Anfänger (1-10): {distribution['novice']}\n" + f"📚 Fortgeschrittene (11-25): {distribution['intermediate']}\n" + f"🎯 Experten (26-50): {distribution['advanced']}\n" + f"👑 Meister (50+): {distribution['expert']}", + inline=False + ) + + embed.set_footer(text=f"Server: {ctx.guild.name}") + await ctx.respond(embed=embed) + + @levelsystem.command(description="Exportiert Leveldaten als CSV") + @commands.has_permissions(administrator=True) + async def export_data(self, ctx): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="❌ Levelsystem deaktiviert", + description="Das Levelsystem ist auf diesem Server deaktiviert.", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + await ctx.defer(ephemeral=True) + + data = self.db.export_guild_data(ctx.guild.id) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['User ID', 'Username', 'Level', 'XP', 'Messages', 'Prestige', 'Total XP Earned']) + + for row in data: + user_id, xp, level, messages, prestige, total_earned = row + user = self.bot.get_user(user_id) + username = user.display_name if user else "Unbekannt" + writer.writerow([user_id, username, level, xp, messages, prestige, total_earned]) + + file_content = output.getvalue().encode('utf-8') + file = discord.File(io.BytesIO(file_content), filename=f"leveldata_{ctx.guild.id}_{int(time.time())}.csv") + + embed = discord.Embed( + title="✅ Datenexport erfolgreich", + description=f"Daten von {len(data)} Usern exportiert.", + color=0x00ff00 + ) + + await ctx.followup.send(embed=embed, file=file) + + # Level-Rollen Commands + @levelrole.command(description="Fügt eine Level-Rolle hinzu") + @commands.has_permissions(manage_roles=True) + async def add(self, ctx, + level: discord.Option(int, "Level für die Rolle", min_value=1), + rolle: discord.Option(discord.Role, "Die Rolle die vergeben werden soll"), + temporaer: discord.Option(bool, "Temporäre Rolle", default=False), + dauer_stunden: discord.Option(int, "Dauer in Stunden (nur bei temporären Rollen)", default=0)): + + if rolle.position >= ctx.author.top_role.position and ctx.author != ctx.guild.owner: + embed = discord.Embed( + title="❌ Keine Berechtigung", + description="Du kannst keine Rolle hinzufügen, die höher oder gleich deiner höchsten Rolle ist!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if rolle.position >= ctx.guild.me.top_role.position: + embed = discord.Embed( + title="❌ Bot-Berechtigung fehlt", + description="Ich kann diese Rolle nicht vergeben, da sie höher oder gleich meiner höchsten Rolle ist!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if temporaer and dauer_stunden <= 0: + embed = discord.Embed( + title="❌ Ungültige Dauer", + description="Temporäre Rollen benötigen eine Dauer > 0 Stunden!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + self.db.add_level_role(ctx.guild.id, level, rolle.id, temporaer, dauer_stunden) + + temp_text = f" (temporär für {dauer_stunden}h)" if temporaer else "" + embed = discord.Embed( + title="✅ Level-Rolle hinzugefügt", + description=f"Die Rolle **{rolle.name}** wird nun bei **Level {level}**{temp_text} vergeben!", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelrole.command(description="Fügt mehrere Rollen für ein Level hinzu") + @commands.has_permissions(manage_roles=True) + async def add_multiple(self, ctx, level: int, *roles: discord.Role): + if not roles: + await ctx.respond("Du musst mindestens eine Rolle angeben!", ephemeral=True) + return + + added_roles = [] + failed_roles = [] + + for role in roles: + if role.position >= ctx.author.top_role.position and ctx.author != ctx.guild.owner: + failed_roles.append(f"{role.name} (keine Berechtigung)") + continue + + if role.position >= ctx.guild.me.top_role.position: + failed_roles.append(f"{role.name} (Bot-Berechtigung fehlt)") + continue + + self.db.add_level_role(ctx.guild.id, level, role.id) + added_roles.append(role.name) + + embed = discord.Embed(color=0x00ff00 if added_roles else 0xff0000) + + if added_roles: + embed.title = "✅ Level-Rollen hinzugefügt" + embed.description = f"**Level {level}:** {', '.join(added_roles)}" + + if failed_roles: + if added_roles: + embed.add_field(name="❌ Fehlgeschlagen", value='\n'.join(failed_roles), inline=False) + else: + embed.title = "❌ Keine Rollen hinzugefügt" + embed.description = '\n'.join(failed_roles) + + await ctx.respond(embed=embed) + + @levelrole.command(description="Bearbeitet eine bestehende Level-Rolle") + @commands.has_permissions(manage_roles=True) + async def edit(self, ctx, + level: discord.Option(int, "Level der zu bearbeitenden Rolle", min_value=1), + neue_rolle: discord.Option(discord.Role, "Die neue Rolle")): + # Prüfen ob Level-Rolle existiert + level_roles = self.db.get_level_roles(ctx.guild.id) + if not any(l == level for l, r, t, d in level_roles): + embed = discord.Embed( + title="❌ Level-Rolle nicht gefunden", + description=f"Für Level {level} ist keine Rolle konfiguriert!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if neue_rolle.position >= ctx.author.top_role.position and ctx.author != ctx.guild.owner: + embed = discord.Embed( + title="❌ Keine Berechtigung", + description="Du kannst keine Rolle setzen, die höher oder gleich deiner höchsten Rolle ist!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if neue_rolle.position >= ctx.guild.me.top_role.position: + embed = discord.Embed( + title="❌ Bot-Berechtigung fehlt", + description="Ich kann diese Rolle nicht vergeben, da sie höher oder gleich meiner höchsten Rolle ist!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + self.db.add_level_role(ctx.guild.id, level, neue_rolle.id) + + embed = discord.Embed( + title="✅ Level-Rolle bearbeitet", + description=f"Die Rolle für **Level {level}** wurde zu **{neue_rolle.name}** geändert!", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelrole.command(description="Entfernt eine Level-Rolle") + @commands.has_permissions(manage_roles=True) + async def remove(self, ctx, level: discord.Option(int, "Level der zu entfernenden Rolle", min_value=1)): + # Prüfen ob Level-Rolle existiert + level_roles = self.db.get_level_roles(ctx.guild.id) + if not any(l == level for l, r, t, d in level_roles): + embed = discord.Embed( + title="❌ Level-Rolle nicht gefunden", + description=f"Für Level {level} ist keine Rolle konfiguriert!", + color=0xff0000 + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + self.db.remove_level_role(ctx.guild.id, level) + + embed = discord.Embed( + title="✅ Level-Rolle entfernt", + description=f"Die Level-Rolle für **Level {level}** wurde entfernt!", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelrole.command(description="Zeigt alle konfigurierten Level-Rollen") + async def list(self, ctx): + level_roles = self.db.get_level_roles(ctx.guild.id) + + if not level_roles: + embed = discord.Embed( + title="📝 Level-Rollen", + description="Keine Level-Rollen konfiguriert!", + color=0x0099ff + ) + await ctx.respond(embed=embed) + return + + embed = discord.Embed( + title="📝 Level-Rollen", + color=0x0099ff, + timestamp=discord.utils.utcnow() + ) + + description = "" + for level, role_id, is_temp, duration in level_roles: + role = ctx.guild.get_role(role_id) + role_name = role.name if role else f"Gelöschte Rolle ({role_id})" + + temp_text = f" ⏰({duration}h)" if is_temp else "" + description += f"**Level {level}:** {role_name}{temp_text}\n" + + embed.description = description + embed.set_footer(text=f"Server: {ctx.guild.name}") + + await ctx.respond(embed=embed) + + # XP-Boost Commands + @xpboost.command(description="Fügt einen globalen XP-Boost hinzu") + @commands.has_permissions(manage_guild=True) + async def add_global(self, ctx, + multiplier: discord.Option(float, "XP-Multiplikator", min_value=1.1, max_value=5.0), + dauer_stunden: discord.Option(int, "Dauer in Stunden", min_value=1, max_value=168)): + + self.db.add_xp_boost(ctx.guild.id, None, multiplier, dauer_stunden) + + embed = discord.Embed( + title="🚀 Globaler XP-Boost aktiviert", + description=f"**{multiplier}x** XP-Multiplikator für **{dauer_stunden} Stunden**\n" + f"Alle Server-Mitglieder erhalten mehr XP!", + color=0x00ff00 + ) + embed.set_footer(text="Der Boost ist sofort aktiv!") + await ctx.respond(embed=embed) + + @xpboost.command(description="Fügt einen persönlichen XP-Boost hinzu") + @commands.has_permissions(manage_guild=True) + async def add_user(self, ctx, + user: discord.Option(discord.Member, "Benutzer für den Boost"), + multiplier: discord.Option(float, "XP-Multiplikator", min_value=1.1, max_value=5.0), + dauer_stunden: discord.Option(int, "Dauer in Stunden", min_value=1, max_value=168)): + + self.db.add_xp_boost(ctx.guild.id, user.id, multiplier, dauer_stunden) + + embed = discord.Embed( + title="🚀 Persönlicher XP-Boost aktiviert", + description=f"**{user.mention}** erhält **{multiplier}x** XP für **{dauer_stunden} Stunden**!", + color=0x00ff00 + ) + embed.set_footer(text="Der Boost ist sofort aktiv!") + await ctx.respond(embed=embed) + + # Konfiguration Commands + @levelconfig.command(description="Konfiguriert XP-Einstellungen") + @commands.has_permissions(manage_guild=True) + async def xp_settings(self, ctx, + min_xp: discord.Option(int, "Minimum XP pro Nachricht", default=None, min_value=1, max_value=50), + max_xp: discord.Option(int, "Maximum XP pro Nachricht", default=None, min_value=1, max_value=100), + cooldown: discord.Option(int, "Cooldown in Sekunden", default=None, min_value=5, max_value=300)): + + config_updates = {} + if min_xp is not None: + config_updates['min_xp'] = min_xp + if max_xp is not None: + config_updates['max_xp'] = max_xp + if cooldown is not None: + config_updates['xp_cooldown'] = cooldown + + if max_xp and min_xp and max_xp < min_xp: + await ctx.respond("❌ Maximum XP kann nicht kleiner als Minimum XP sein!", ephemeral=True) + return + + if not config_updates: + await ctx.respond("❌ Du musst mindestens einen Wert ändern!", ephemeral=True) + return + + self.db.set_guild_config(ctx.guild.id, **config_updates) + + current_config = self.db.get_guild_config(ctx.guild.id) + + embed = discord.Embed( + title="✅ XP-Einstellungen aktualisiert", + color=0x00ff00 + ) + + embed.add_field(name="💰 XP-Bereich", value=f"{current_config['min_xp']}-{current_config['max_xp']}", inline=True) + embed.add_field(name="⏱️ Cooldown", value=f"{current_config['xp_cooldown']}s", inline=True) + + await ctx.respond(embed=embed) + + @levelconfig.command(description="Setzt XP-Multiplikator für einen Kanal") + @commands.has_permissions(manage_guild=True) + async def channel_multiplier(self, ctx, + channel: discord.Option(discord.TextChannel, "Kanal"), + multiplier: discord.Option(float, "Multiplikator (0.0 = keine XP)", min_value=0.0, max_value=5.0)): + + self.db.set_channel_multiplier(ctx.guild.id, channel.id, multiplier) + + if multiplier == 0: + description = f"{channel.mention} gibt keine XP mehr." + color = 0xff0000 + else: + description = f"{channel.mention} hat **{multiplier}x** XP-Multiplikator." + color = 0x00ff00 + + embed = discord.Embed( + title="✅ Kanal-Multiplikator gesetzt", + description=description, + color=color + ) + await ctx.respond(embed=embed) + + @levelconfig.command(description="Fügt einen Kanal zur XP-Blacklist hinzu") + @commands.has_permissions(manage_guild=True) + async def blacklist_channel(self, ctx, + channel: discord.Option(discord.TextChannel, "Kanal zum Ausschließen")): + + self.db.add_blacklisted_channel(ctx.guild.id, channel.id) + + embed = discord.Embed( + title="✅ Kanal ausgeschlossen", + description=f"{channel.mention} wurde vom Levelsystem ausgeschlossen.", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelconfig.command(description="Setzt den Level-Up Nachrichten-Kanal") + @commands.has_permissions(manage_guild=True) + async def levelup_channel(self, ctx, + channel: discord.Option(discord.TextChannel, "Kanal für Level-Up Nachrichten", default=None)): + + if channel: + self.db.set_guild_config(ctx.guild.id, level_up_channel=channel.id) + embed = discord.Embed( + title="✅ Level-Up Kanal gesetzt", + description=f"Level-Up Nachrichten werden in {channel.mention} gesendet.", + color=0x00ff00 + ) + else: + self.db.set_guild_config(ctx.guild.id, level_up_channel=None) + embed = discord.Embed( + title="✅ Level-Up Kanal zurückgesetzt", + description="Level-Up Nachrichten werden wieder im ursprünglichen Kanal gesendet.", + color=0x00ff00 + ) + + await ctx.respond(embed=embed) + + @levelconfig.command(description="Konfiguriert Prestige-Einstellungen") + @commands.has_permissions(manage_guild=True) + async def prestige_settings(self, ctx, + aktiviert: discord.Option(bool, "Prestige-System aktivieren/deaktivieren"), + min_level: discord.Option(int, "Minimum Level für Prestige", default=50, min_value=10, max_value=200)): + + self.db.set_guild_config(ctx.guild.id, prestige_enabled=aktiviert, prestige_min_level=min_level) + + embed = discord.Embed( + title="✅ Prestige-Einstellungen aktualisiert", + color=0x00ff00 + ) + + status = "aktiviert" if aktiviert else "deaktiviert" + embed.add_field(name="✨ Status", value=status.title(), inline=True) + if aktiviert: + embed.add_field(name="🎯 Minimum Level", value=str(min_level), inline=True) + + await ctx.respond(embed=embed) + + # System Commands + @levelsystem.command(description="Aktiviert das Levelsystem") + @commands.has_permissions(manage_guild=True) + async def enable(self, ctx): + if self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="ℹ️ Bereits aktiviert", + description="Das Levelsystem ist bereits aktiviert!", + color=0x0099ff + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + self.db.set_levelsystem_enabled(ctx.guild.id, True) + + embed = discord.Embed( + title="✅ Levelsystem aktiviert", + description="Das Levelsystem wurde erfolgreich aktiviert!\n\nBenutze `/levelconfig` um weitere Einstellungen vorzunehmen.", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelsystem.command(description="Deaktiviert das Levelsystem") + @commands.has_permissions(manage_guild=True) + async def disable(self, ctx): + if not self.db.is_levelsystem_enabled(ctx.guild.id): + embed = discord.Embed( + title="ℹ️ Bereits deaktiviert", + description="Das Levelsystem ist bereits deaktiviert!", + color=0x0099ff + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + self.db.set_levelsystem_enabled(ctx.guild.id, False) + + embed = discord.Embed( + title="✅ Levelsystem deaktiviert", + description="Das Levelsystem wurde erfolgreich deaktiviert!\n\n*Hinweis: Alle Daten bleiben erhalten und können bei Reaktivierung wiederhergestellt werden.*", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelsystem.command(description="Zeigt den detaillierten Status des Levelsystems") + async def status(self, ctx): + enabled = self.db.is_levelsystem_enabled(ctx.guild.id) + config = self.db.get_guild_config(ctx.guild.id) + + embed = discord.Embed( + title="📊 Levelsystem Status", + description=f"Das Levelsystem ist **{'aktiviert' if enabled else 'deaktiviert'}**", + color=0x00ff00 if enabled else 0xff0000, + timestamp=discord.utils.utcnow() + ) + + if enabled: + # Grundkonfiguration + embed.add_field( + name="⚙️ Konfiguration", + value=f"**XP-Bereich:** {config['min_xp']}-{config['max_xp']}\n" + f"**Cooldown:** {config['xp_cooldown']}s\n" + f"**Prestige:** {'✅' if config['prestige_enabled'] else '❌'} (Level {config['prestige_min_level']}+)", + inline=True + ) + + # Statistiken + leaderboard = self.db.get_leaderboard(ctx.guild.id, 1) + level_roles = self.db.get_level_roles(ctx.guild.id) + total_users = len(self.db.get_leaderboard(ctx.guild.id, 1000)) + + embed.add_field( + name="📈 Statistiken", + value=f"**Aktive User:** {total_users:,}\n" + f"**Level-Rollen:** {len(level_roles)}\n" + f"**XP-Boosts:** Aktiv", + inline=True + ) + + if leaderboard: + top_user = self.bot.get_user(leaderboard[0][0]) + top_username = top_user.display_name if top_user else f"User {leaderboard[0][0]}" + prestige_text = f"⭐{leaderboard[0][4]} " if leaderboard[0][4] > 0 else "" + + embed.add_field( + name="👑 Top User", + value=f"{prestige_text}**{top_username}**\nLevel {leaderboard[0][2]} ({leaderboard[0][1]:,} XP)", + inline=True + ) + + # Level-Up Kanal + if config['level_up_channel']: + channel = ctx.guild.get_channel(config['level_up_channel']) + channel_text = channel.mention if channel else "Gelöschter Kanal" + else: + channel_text = "Standard (Nachrichtenkanal)" + + embed.add_field(name="📢 Level-Up Kanal", value=channel_text, inline=True) + + embed.set_footer(text=f"Server: {ctx.guild.name}") + await ctx.respond(embed=embed) + + # Admin Commands + @levelsystem.command(description="Setzt das Level eines Users (Admin)") + @commands.has_permissions(administrator=True) + async def set_level(self, ctx, + user: discord.Option(discord.Member, "Benutzer"), + level: discord.Option(int, "Neues Level", min_value=0, max_value=1000)): + + required_xp = self.db.xp_for_level(level) + + # User in Datenbank erstellen/aktualisieren + conn = self.db.db_path + import sqlite3 + conn = sqlite3.connect(self.db.db_path) + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO user_levels (user_id, guild_id, xp, level, messages, last_message, total_xp_earned) + VALUES (?, ?, ?, ?, + COALESCE((SELECT messages FROM user_levels WHERE user_id = ? AND guild_id = ?), 0), + ?, + COALESCE((SELECT total_xp_earned FROM user_levels WHERE user_id = ? AND guild_id = ?), 0) + ?) + ''', (user.id, ctx.guild.id, required_xp, level, user.id, ctx.guild.id, time.time(), user.id, ctx.guild.id, required_xp)) + + conn.commit() + conn.close() + + embed = discord.Embed( + title="✅ Level gesetzt", + description=f"{user.mention} ist jetzt **Level {level}** ({required_xp:,} XP)", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelsystem.command(description="Fügt einem User XP hinzu (Admin)") + @commands.has_permissions(administrator=True) + async def add_xp(self, ctx, + user: discord.Option(discord.Member, "Benutzer"), + xp_amount: discord.Option(int, "XP-Menge", min_value=1, max_value=100000)): + + level_up, new_level = self.db.add_xp(user.id, ctx.guild.id, xp_amount, "Admin XP Grant") + + embed = discord.Embed( + title="✅ XP hinzugefügt", + description=f"{user.mention} hat **{xp_amount:,} XP** erhalten!", + color=0x00ff00 + ) + + if level_up: + embed.add_field(name="🎉 Level Up!", value=f"Neues Level: **{new_level}**", inline=False) + + await ctx.respond(embed=embed) + + @levelsystem.command(description="Setzt die Nachrichten-Anzahl eines Users (Admin)") + @commands.has_permissions(administrator=True) + async def set_messages(self, ctx, + user: discord.Option(discord.Member, "Benutzer"), + messages: discord.Option(int, "Anzahl Nachrichten", min_value=0, max_value=1000000)): + + import sqlite3 + conn = sqlite3.connect(self.db.db_path) + cursor = conn.cursor() + + cursor.execute(''' + UPDATE user_levels SET messages = ? + WHERE user_id = ? AND guild_id = ? + ''', (messages, user.id, ctx.guild.id)) + + conn.commit() + conn.close() + + embed = discord.Embed( + title="✅ Nachrichten-Anzahl gesetzt", + description=f"{user.mention} hat jetzt **{messages:,} Nachrichten**", + color=0x00ff00 + ) + await ctx.respond(embed=embed) + + @levelsystem.command(description="Löscht die Leveldaten eines Users (Admin)") + @commands.has_permissions(administrator=True) + async def reset_user(self, ctx, + user: discord.Option(discord.Member, "Benutzer zum Zurücksetzen")): + + import sqlite3 + conn = sqlite3.connect(self.db.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM user_levels WHERE user_id = ? AND guild_id = ?', (user.id, ctx.guild.id)) + + affected_rows = cursor.rowcount + conn.commit() + conn.close() + + if affected_rows > 0: + embed = discord.Embed( + title="✅ User zurückgesetzt", + description=f"Alle Leveldaten von {user.mention} wurden gelöscht.", + color=0x00ff00 + ) + else: + embed = discord.Embed( + title="ℹ️ Keine Daten gefunden", + description=f"{user.mention} hat keine Leveldaten auf diesem Server.", + color=0x0099ff + ) + + await ctx.respond(embed=embed, ephemeral=True) + + +def setup(bot): + bot.add_cog(LevelSystem(bot)) \ No newline at end of file diff --git a/src/bot/cogs/guild/loggingsystem.py b/src/bot/cogs/guild/loggingsystem.py new file mode 100644 index 0000000..818a863 --- /dev/null +++ b/src/bot/cogs/guild/loggingsystem.py @@ -0,0 +1,1465 @@ +# Copyright (c) 2025 OPPRO.NET Network +# File: logging_cog.py + +import discord +from discord import SlashCommandGroup +from discord.ext import commands +from datetime import datetime, timedelta +from typing import Dict, Set, Optional, List +import asyncio +import logging + +# Import our separate database class +from DevTools import LoggingDatabase + +# Setup logging +logger = logging.getLogger(__name__) + +class LoggingCog(commands.Cog): + """ + Comprehensive Discord logging system with improved performance and features + """ + + def __init__(self, bot): + self.bot = bot + self.db = LoggingDatabase() + + # Improved caching system + self._edit_tasks: Dict[int, asyncio.Task] = {} + self._bulk_deletes: Dict[int, Dict[str, any]] = {} + self._voice_cache: Dict[int, Dict[int, Optional[discord.VoiceState]]] = {} + + # Configuration + self.config = { + 'edit_debounce_time': 3.0, # Sekunden + 'bulk_delete_threshold': 3, # Anzahl für Bulk-Erkennung + 'bulk_delete_window': 2.0, # Sekunden Zeitfenster + 'max_content_length': 1500, # Max Content-Länge in Embeds + 'max_embed_fields': 25, # Discord Limit + 'cleanup_interval': 300, # 5 Minuten Cache-Cleanup + 'max_attachment_display': 5, # Max Attachments in Embed + 'max_role_display': 10, # Max Roles in Embed + } + + # Performance tracking + self._stats = { + 'events_processed': 0, + 'logs_sent': 0, + 'errors': 0, + 'cache_hits': 0, + 'startup_time': datetime.utcnow(), + } + + # Start background tasks + self._cleanup_task = None + self.bot.loop.create_task(self._start_background_tasks()) + + logger.info("LoggingCog initialized successfully") + + async def _start_background_tasks(self): + """Startet Background-Tasks nachdem der Bot bereit ist""" + await self.bot.wait_until_ready() + self._cleanup_task = self.bot.loop.create_task(self._cleanup_loop()) + logger.info("Background tasks started") + + def cog_unload(self): + """Cleanup beim Entladen der Cog""" + logger.info("Unloading LoggingCog...") + + if self._cleanup_task and not self._cleanup_task.done(): + self._cleanup_task.cancel() + + # Cancel all edit tasks + for task in self._edit_tasks.values(): + if not task.done(): + task.cancel() + + # Close database connection + self.db.close() + logger.info("LoggingCog unloaded successfully") + + async def _cleanup_loop(self): + """Regelmäßige Cache-Bereinigung mit verbesserter Logik""" + while not self.bot.is_closed(): + try: + await asyncio.sleep(self.config['cleanup_interval']) + await self._cleanup_caches() + except asyncio.CancelledError: + logger.info("Cleanup loop cancelled") + break + except Exception as e: + logger.error(f"Cleanup loop error: {e}") + self._stats['errors'] += 1 + + async def _cleanup_caches(self): + """Bereinigt alle Caches""" + try: + cleanup_count = 0 + + # Edit Tasks bereinigen + completed_tasks = [ + msg_id for msg_id, task in self._edit_tasks.items() + if task.done() + ] + for msg_id in completed_tasks: + del self._edit_tasks[msg_id] + cleanup_count += 1 + + # Bulk Delete Cache bereinigen (älter als 5 Minuten) + current_time = datetime.utcnow() + expired_guilds = [] + + for guild_id, data in self._bulk_deletes.items(): + if 'timestamp' in data: + age = (current_time - data['timestamp']).total_seconds() + if age > 300: # 5 Minuten + expired_guilds.append(guild_id) + + for guild_id in expired_guilds: + del self._bulk_deletes[guild_id] + cleanup_count += 1 + + # Voice Cache für offline Mitglieder bereinigen + for guild_id in list(self._voice_cache.keys()): + guild = self.bot.get_guild(guild_id) + if not guild: + del self._voice_cache[guild_id] + cleanup_count += 1 + continue + + offline_members = [] + for member_id in self._voice_cache[guild_id]: + member = guild.get_member(member_id) + if not member or not member.voice: + offline_members.append(member_id) + + for member_id in offline_members: + del self._voice_cache[guild_id][member_id] + cleanup_count += 1 + + if cleanup_count > 0: + logger.debug(f"Cache cleanup: {cleanup_count} items removed") + + except Exception as e: + logger.error(f"Cache cleanup error: {e}") + self._stats['errors'] += 1 + + async def send_log(self, guild_id: int, embed: discord.Embed, log_type: str = "general") -> bool: + """Verbesserte Log-Versendung mit Retry-Logik""" + try: + channel_id = await self.db.get_log_channel(guild_id, log_type) + if not channel_id: + return False + + channel = self.bot.get_channel(channel_id) + if not channel: + # Channel nicht mehr vorhanden, aus DB entfernen + await self.db.remove_log_channel(guild_id, log_type) + logger.warning(f"Removed invalid channel {channel_id} for guild {guild_id}") + return False + + # Embed validieren und anpassen + if len(embed) > 6000: # Discord Limit + embed.description = "⚠️ Inhalt zu lang für Anzeige" + # Felder reduzieren falls nötig + while len(embed.fields) > 10: + embed.remove_field(-1) + + # Embed senden + await channel.send(embed=embed) + self._stats['logs_sent'] += 1 + return True + + except discord.Forbidden: + logger.warning(f"No permission for log channel in guild {guild_id}") + await self.db.remove_log_channel(guild_id, log_type) + return False + except discord.NotFound: + logger.warning(f"Log channel not found in guild {guild_id}") + await self.db.remove_log_channel(guild_id, log_type) + return False + except discord.HTTPException as e: + if e.code == 50035: # Invalid form body + logger.error(f"Invalid embed content for guild {guild_id}: {e}") + # Fallback embed senden + try: + fallback_embed = discord.Embed( + title="⚠️ Log-Fehler", + description="Originale Log-Nachricht konnte nicht angezeigt werden (zu lang oder ungültig)", + color=discord.Color.orange(), + timestamp=datetime.utcnow() + ) + await channel.send(embed=fallback_embed) + except: + pass + else: + logger.error(f"HTTP error sending log to guild {guild_id}: {e}") + except Exception as e: + logger.error(f"Unexpected error sending log to guild {guild_id}: {e}") + self._stats['errors'] += 1 + + return False + + def _create_user_embed(self, title: str, user: discord.User, color: discord.Color, + extra_fields: Dict[str, str] = None, + description: str = None) -> discord.Embed: + """Verbesserte User-Embed Erstellung""" + embed = discord.Embed( + title=title, + description=description, + color=color, + timestamp=datetime.utcnow() + ) + + # User Info - immer als erstes + embed.add_field( + name="👤 User", + value=f"{user.mention}\n`{user}`", + inline=True + ) + embed.add_field( + name="🆔 ID", + value=f"`{user.id}`", + inline=True + ) + embed.add_field( + name="📅 Erstellt", + value=f"", + inline=True + ) + + # Extra Felder hinzufügen + if extra_fields: + for name, value in extra_fields.items(): + if len(embed.fields) < self.config['max_embed_fields']: + embed.add_field(name=name, value=str(value)[:1000], inline=True) + + # Avatar und Footer + if user.display_avatar: + embed.set_thumbnail(url=user.display_avatar.url) + + embed.set_footer(text=f"User ID: {user.id}") + return embed + + def _truncate_content(self, content: str, max_length: int = None) -> str: + """Kürzt Content intelligent""" + if not content: + return "*Leer*" + + max_length = max_length or self.config['max_content_length'] + + if len(content) <= max_length: + return content + + # An Wort-Grenzen kürzen wenn möglich + truncated = content[:max_length-3] + last_space = truncated.rfind(' ') + + if last_space > max_length * 0.8: # Nur wenn nicht zu viel verloren geht + truncated = truncated[:last_space] + + return f"{truncated}..." + + def _format_content_for_embed(self, content: str, escape_markdown: bool = True) -> str: + """Formatiert Content sicher für Embeds""" + if not content: + return "*Leer*" + + content = self._truncate_content(content) + + if escape_markdown: + # Escape problematische Zeichen + content = content.replace("```", "'''") + content = content.replace("`", "'") + + return f"```\n{content}\n```" + + # ============================================================================= + # SLASH COMMANDS - Improved + # ============================================================================= + + logging = SlashCommandGroup("logging", description="Setze die Logging Systeme") + + @logging.command(name="channel", description="Setzt den Log-Channel für verschiedene Events") + @discord.default_permissions(administrator=True) + async def set_log_channel(self, ctx, + channel: discord.TextChannel, + log_type: discord.Option(str, + choices=["general", "moderation", "voice", "messages", "all"], + description="Art der Logs", + default="general")): + """Verbesserte Log-Channel Konfiguration""" + try: + # Berechtigungen prüfen + perms = channel.permissions_for(ctx.guild.me) + if not perms.send_messages: + embed = discord.Embed( + title="❌ Keine Berechtigung", + description=f"Ich kann keine Nachrichten in {channel.mention} senden.", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if not perms.embed_links: + embed = discord.Embed( + title="⚠️ Fehlende Berechtigung", + description=f"Ich benötige die 'Embed Links' Berechtigung in {channel.mention}.", + color=discord.Color.orange() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if log_type == "all": + # Alle Log-Typen setzen + types = ["general", "moderation", "voice", "messages"] + for lt in types: + await self.db.set_log_channel(ctx.guild.id, channel.id, lt) + + embed = discord.Embed( + title="✅ Alle Log-Channels gesetzt", + description=f"Alle Logs werden nun in {channel.mention} gesendet.\n\n" + + f"**Konfigurierte Typen:** {', '.join(types)}", + color=discord.Color.green() + ) + else: + await self.db.set_log_channel(ctx.guild.id, channel.id, log_type) + + embed = discord.Embed( + title="✅ Log-Channel gesetzt", + description=f"**{log_type.title()}**-Logs werden nun in {channel.mention} gesendet.", + color=discord.Color.green() + ) + + embed.set_footer(text=f"Konfiguriert von {ctx.author}") + await ctx.respond(embed=embed, ephemeral=True) + + # Test-Nachricht senden + test_embed = discord.Embed( + title="🧪 Test-Nachricht", + description=f"Log-Channel für **{log_type}** erfolgreich konfiguriert!", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + test_embed.set_footer(text="Dies ist eine Test-Nachricht") + await self.send_log(ctx.guild.id, test_embed, "general" if log_type == "all" else log_type) + + except Exception as e: + embed = discord.Embed( + title="❌ Fehler", + description=f"Fehler beim Setzen des Log-Channels:\n```{str(e)}```", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + logger.error(f"Error in set_log_channel: {e}") + + @logging.command(name="remove", description="Entfernt einen Log-Channel") + @discord.default_permissions(administrator=True) + async def remove_log_channel(self, ctx, + log_type: discord.Option(str, + choices=["general", "moderation", "voice", "messages", "all"], + description="Art der Logs", default="all")): + """Entfernt Log-Channel Konfiguration""" + try: + if log_type == "all": + deleted_count = await self.db.remove_all_log_channels(ctx.guild.id) + description = f"Alle Log-Channels wurden entfernt. ({deleted_count} Einträge)" + else: + deleted_count = await self.db.remove_log_channel(ctx.guild.id, log_type) + if deleted_count > 0: + description = f"{log_type.title()}-Logging wurde deaktiviert." + else: + description = f"Kein {log_type.title()}-Logging war konfiguriert." + + embed = discord.Embed( + title="🗑️ Log-Channel entfernt", + description=description, + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + embed.set_footer(text=f"Entfernt von {ctx.author}") + await ctx.respond(embed=embed, ephemeral=True) + + except Exception as e: + embed = discord.Embed( + title="❌ Fehler", + description=f"Fehler beim Entfernen des Log-Channels:\n```{str(e)}```", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + logger.error(f"Error in remove_log_channel: {e}") + + @logging.command(name="status", description="Zeigt die aktuellen Log-Einstellungen") + @discord.default_permissions(administrator=True) + async def log_status(self, ctx): + """Verbesserter Log-Status mit mehr Details""" + try: + channels = await self.db.get_all_log_channels(ctx.guild.id) + stats = await self.db.get_statistics() + + embed = discord.Embed( + title="📊 Logging Status", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + if not channels: + embed.description = "❌ Keine Log-Channels konfiguriert." + embed.add_field( + name="💡 Tipp", + value="Nutze `/setlogchannel` um Logging zu aktivieren.", + inline=False + ) + else: + status_text = f"✅ **{len(channels)}** Log-Typ(en) konfiguriert\n\n" + + for log_type, channel_id in channels.items(): + channel = self.bot.get_channel(channel_id) + if channel: + status_text += f"**{log_type.title()}:** {channel.mention}\n" + else: + status_text += f"**{log_type.title()}:** ❌ *Channel nicht gefunden* (`{channel_id}`)\n" + + embed.description = status_text + + # Bot Statistiken + uptime = datetime.utcnow() - self._stats['startup_time'] + uptime_str = f"{uptime.days}d {uptime.seconds//3600}h {(uptime.seconds%3600)//60}m" + + embed.add_field( + name="📈 Cog Statistiken", + value=f"Events verarbeitet: **{self._stats['events_processed']:,}**\n" + + f"Logs gesendet: **{self._stats['logs_sent']:,}**\n" + + f"Fehler: **{self._stats['errors']}**\n" + + f"Uptime: **{uptime_str}**", + inline=True + ) + + # Cache Info + voice_cache_size = sum(len(vc) for vc in self._voice_cache.values()) + embed.add_field( + name="🗄️ Cache Status", + value=f"Edit Tasks: **{len(self._edit_tasks)}**\n" + + f"Bulk Deletes: **{len(self._bulk_deletes)}**\n" + + f"Voice Cache: **{voice_cache_size}**", + inline=True + ) + + # Datenbank Statistiken + if stats: + embed.add_field( + name="🗃️ Datenbank", + value=f"Aktive Channels: **{stats.get('enabled_entries', 0)}**\n" + + f"Guilds mit Logging: **{stats.get('unique_guilds', 0)}**\n" + + f"Einzigartige Channels: **{stats.get('unique_channels', 0)}**", + inline=True + ) + + embed.set_footer(text=f"Guild ID: {ctx.guild.id}") + await ctx.respond(embed=embed, ephemeral=True) + + except Exception as e: + embed = discord.Embed( + title="❌ Fehler", + description=f"Fehler beim Abrufen des Status:\n```{str(e)}```", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + logger.error(f"Error in log_status: {e}") + + @logging.command(name="backup", description="Erstellt ein Backup der Log-Konfiguration") + @discord.default_permissions(administrator=True) + async def log_backup(self, ctx): + """Erstellt ein Datenbank-Backup""" + try: + backup_path = f"data/log_channels_backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.db" + success = await self.db.backup_database(backup_path) + + if success: + embed = discord.Embed( + title="✅ Backup erstellt", + description=f"Datenbank-Backup wurde erfolgreich erstellt:\n`{backup_path}`", + color=discord.Color.green() + ) + else: + embed = discord.Embed( + title="❌ Backup fehlgeschlagen", + description="Backup konnte nicht erstellt werden. Prüfe die Logs für Details.", + color=discord.Color.red() + ) + + await ctx.respond(embed=embed, ephemeral=True) + + except Exception as e: + embed = discord.Embed( + title="❌ Fehler", + description=f"Fehler beim Backup:\n```{str(e)}```", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + logger.error(f"Error in log_backup: {e}") + + # ============================================================================= + # EVENT HANDLERS - Enhanced + # ============================================================================= + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """Verbessertes Member Join Logging""" + try: + self._stats['events_processed'] += 1 + + account_age = datetime.utcnow() - member.created_at + age_days = account_age.days + + # Verdächtigkeits-Score + suspicious_factors = [] + if age_days < 1: + suspicious_factors.append("Sehr neues Konto (< 1 Tag)") + elif age_days < 7: + suspicious_factors.append("Neues Konto (< 7 Tage)") + + if member.display_avatar.is_default(): + suspicious_factors.append("Standard Avatar") + + # Default Username Pattern + if len(member.name) > 10 and member.discriminator != "0": + if member.name.lower().startswith(("discord", "user", "member")): + suspicious_factors.append("Verdächtiger Username") + + # Farbe basierend auf Verdächtigkeits-Level + if member.bot: + color = discord.Color.purple() + elif len(suspicious_factors) >= 2: + color = discord.Color.red() + elif suspicious_factors: + color = discord.Color.orange() + else: + color = discord.Color.green() + + extra_fields = { + "🎂 Konto-Alter": f"{age_days} Tag{'e' if age_days != 1 else ''}", + "👥 Member #": f"{member.guild.member_count}", + } + + if suspicious_factors: + extra_fields["⚠️ Verdächtig"] = "\n".join(suspicious_factors[:3]) + + if member.bot: + extra_fields["🤖 Bot"] = "✅" + + embed = self._create_user_embed( + "📥 Member beigetreten", + member, + color, + extra_fields + ) + + await self.send_log(member.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_member_join: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + """Verbessertes Member Leave Logging""" + try: + self._stats['events_processed'] += 1 + + extra_fields = { + "🎭 Rollen": f"{len(member.roles) - 1}", # -1 für @everyone + "👥 Member #": f"{member.guild.member_count}", + } + + if member.joined_at: + duration = datetime.utcnow() - member.joined_at + days = duration.days + hours = duration.seconds // 3600 + + if days > 0: + duration_str = f"{days} Tag{'e' if days != 1 else ''}" + elif hours > 0: + duration_str = f"{hours} Stunde{'n' if hours != 1 else ''}" + else: + minutes = duration.seconds // 60 + duration_str = f"{minutes} Minute{'n' if minutes != 1 else ''}" + + extra_fields["⏱️ Mitgliedschaftsdauer"] = duration_str + + # Top Rollen anzeigen (nicht @everyone) + top_roles = [role for role in member.roles if role.name != "@everyone"] + if top_roles: + top_roles = sorted(top_roles, key=lambda r: r.position, reverse=True)[:3] + extra_fields["🏆 Top Rollen"] = ", ".join([role.name for role in top_roles]) + + embed = self._create_user_embed( + "📤 Member verlassen", + member, + discord.Color.red(), + extra_fields + ) + + await self.send_log(member.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_member_remove: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message): + """Stark verbessertes Message Delete Logging mit Bulk-Detection""" + try: + if message.author.bot or not message.guild: + return + + self._stats['events_processed'] += 1 + guild_id = message.guild.id + + # Bulk Delete Detection + current_time = datetime.utcnow() + + if guild_id not in self._bulk_deletes: + self._bulk_deletes[guild_id] = { + 'messages': set(), + 'timestamp': current_time, + 'channels': set() + } + + bulk_data = self._bulk_deletes[guild_id] + + # Reset wenn zu alt + if (current_time - bulk_data['timestamp']).total_seconds() > self.config['bulk_delete_window']: + bulk_data['messages'].clear() + bulk_data['channels'].clear() + bulk_data['timestamp'] = current_time + + bulk_data['messages'].add(message.id) + bulk_data['channels'].add(message.channel.id) + + # Kurz warten um weitere Deletes zu erfassen + await asyncio.sleep(0.3) + + # Bulk Delete Check + if len(bulk_data['messages']) >= self.config['bulk_delete_threshold']: + embed = discord.Embed( + title="🗑️ Bulk-Löschung erkannt", + description=f"**{len(bulk_data['messages'])}** Nachrichten wurden in kurzer Zeit gelöscht", + color=discord.Color.dark_red(), + timestamp=datetime.utcnow() + ) + + # Channel Info + affected_channels = [] + for ch_id in bulk_data['channels']: + channel = self.bot.get_channel(ch_id) + if channel: + affected_channels.append(channel.mention) + + if affected_channels: + embed.add_field( + name="📍 Betroffene Channels", + value="\n".join(affected_channels[:5]), + inline=False + ) + + embed.add_field(name="⏱️ Zeitfenster", value=f"< {self.config['bulk_delete_window']}s", inline=True) + embed.add_field(name="🔍 Hinweis", value="Mögliche Moderator-Aktion oder Bot-Cleanup", inline=True) + + await self.send_log(guild_id, embed, "messages") + + # Cache zurücksetzen + bulk_data['messages'].clear() + bulk_data['channels'].clear() + return + + # Normale Delete-Behandlung + if message.id not in bulk_data['messages']: + return # Bereits als Bulk verarbeitet + + embed = discord.Embed( + title="🗑️ Nachricht gelöscht", + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + + # Author Info + embed.add_field( + name="👤 Author", + value=f"{message.author.mention}\n`{message.author}`", + inline=True + ) + embed.add_field( + name="📍 Channel", + value=message.channel.mention, + inline=True + ) + embed.add_field( + name="⏰ Erstellt", + value=f"", + inline=True + ) + + # Content + if message.content: + embed.add_field( + name="💬 Inhalt", + value=self._format_content_for_embed(message.content), + inline=False + ) + + # Attachments + if message.attachments: + attachment_info = [] + for att in message.attachments[:self.config['max_attachment_display']]: + size_kb = att.size // 1024 + attachment_info.append(f"📎 `{att.filename}` ({size_kb} KB)") + + if len(message.attachments) > self.config['max_attachment_display']: + attachment_info.append(f"... und {len(message.attachments) - self.config['max_attachment_display']} weitere") + + embed.add_field( + name="📎 Anhänge", + value="\n".join(attachment_info), + inline=False + ) + + # Embeds + if message.embeds: + embed.add_field( + name="📋 Embeds", + value=f"{len(message.embeds)} Embed(s)", + inline=True + ) + + # Reactions + if message.reactions: + reaction_count = sum(r.count for r in message.reactions) + embed.add_field( + name="👍 Reaktionen", + value=f"{reaction_count} Reaktionen", + inline=True + ) + + embed.set_author(name=message.author.display_name, icon_url=message.author.display_avatar.url) + embed.set_footer(text=f"Message ID: {message.id} | User ID: {message.author.id}") + + await self.send_log(guild_id, embed, "messages") + + # Message aus bulk cache entfernen + if message.id in bulk_data['messages']: + bulk_data['messages'].discard(message.id) + + except Exception as e: + logger.error(f"Error in on_message_delete: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + """Verbessertes Message Edit Logging mit intelligentem Debouncing""" + try: + if (before.author.bot or not before.guild or + before.content == after.content or not before.content): + return + + self._stats['events_processed'] += 1 + message_id = before.id + + # Bestehenden Task canceln + if message_id in self._edit_tasks: + self._edit_tasks[message_id].cancel() + + # Neuen debounced Task erstellen + self._edit_tasks[message_id] = asyncio.create_task( + self._delayed_edit_log(before, after) + ) + + except Exception as e: + logger.error(f"Error in on_message_edit: {e}") + self._stats['errors'] += 1 + + async def _delayed_edit_log(self, before: discord.Message, after: discord.Message): + """Verzögertes Edit-Logging mit verbesserter Logik""" + try: + await asyncio.sleep(self.config['edit_debounce_time']) + + # Aktuellste Version der Nachricht holen + try: + fresh_message = await before.channel.fetch_message(before.id) + after = fresh_message # Aktuellste Version verwenden + except (discord.NotFound, discord.Forbidden): + pass # Nachricht wurde gelöscht oder keine Berechtigung + + await self._log_message_edit(before, after) + + except asyncio.CancelledError: + pass # Task wurde gecancelt + except Exception as e: + logger.error(f"Error in delayed edit log: {e}") + finally: + # Task aus Cache entfernen + if before.id in self._edit_tasks: + del self._edit_tasks[before.id] + + async def _log_message_edit(self, before: discord.Message, after: discord.Message): + """Internes Message Edit Logging mit Diff-Anzeige""" + try: + embed = discord.Embed( + title="✏️ Nachricht bearbeitet", + color=discord.Color.yellow(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="👤 Author", + value=f"{before.author.mention}\n`{before.author}`", + inline=True + ) + embed.add_field( + name="📍 Channel", + value=before.channel.mention, + inline=True + ) + embed.add_field( + name="🔗 Nachricht", + value=f"[Zur Nachricht]({after.jump_url})", + inline=True + ) + + # Content Comparison - intelligenter + before_content = self._truncate_content(before.content or "", 700) + after_content = self._truncate_content(after.content or "", 700) + + if len(before_content) + len(after_content) < 2000: + embed.add_field( + name="📝 Vorher", + value=self._format_content_for_embed(before_content, escape_markdown=True), + inline=False + ) + embed.add_field( + name="📝 Nachher", + value=self._format_content_for_embed(after_content, escape_markdown=True), + inline=False + ) + else: + # Zu lang - nur Änderungsinfo + char_diff = len(after.content) - len(before.content) + diff_text = f"**Zeichen-Änderung:** {char_diff:+d}\n" + diff_text += f"**Länge:** {len(before.content)} → {len(after.content)}" + + embed.add_field( + name="📊 Änderungsinfo", + value=diff_text, + inline=False + ) + + # Timestamp der ursprünglichen Nachricht + embed.add_field( + name="🕐 Original erstellt", + value=f"", + inline=True + ) + + embed.set_author(name=before.author.display_name, icon_url=before.author.display_avatar.url) + embed.set_footer(text=f"Message ID: {before.id} | User ID: {before.author.id}") + + await self.send_log(before.guild.id, embed, "messages") + + except Exception as e: + logger.error(f"Error in _log_message_edit: {e}") + +# ============================================================================= + # VOICE STATE EVENTS + # ============================================================================= + + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + """Verbessertes Voice State Logging mit intelligenter Filterung""" + try: + if member.bot: + return + + self._stats['events_processed'] += 1 + guild_id = member.guild.id + + # Cache initialisieren + if guild_id not in self._voice_cache: + self._voice_cache[guild_id] = {} + + guild_cache = self._voice_cache[guild_id] + member_id = member.id + + # Vorherigen State aus Cache holen oder setzen + cached_before = guild_cache.get(member_id) + guild_cache[member_id] = after + + # Event-Typ bestimmen + event_type = None + color = discord.Color.blue() + title = "" + + if not before.channel and after.channel: + # Join + event_type = "join" + title = "🔊 Voice Channel beigetreten" + color = discord.Color.green() + elif before.channel and not after.channel: + # Leave + event_type = "leave" + title = "🔇 Voice Channel verlassen" + color = discord.Color.red() + elif before.channel != after.channel and before.channel and after.channel: + # Move + event_type = "move" + title = "🔄 Voice Channel gewechselt" + color = discord.Color.orange() + elif before.channel == after.channel: + # State changes (mute, deafen, etc.) + changes = [] + if before.self_mute != after.self_mute: + changes.append(f"Self Mute: {'✅' if after.self_mute else '❌'}") + if before.self_deaf != after.self_deaf: + changes.append(f"Self Deaf: {'✅' if after.self_deaf else '❌'}") + if before.mute != after.mute: + changes.append(f"Server Mute: {'✅' if after.mute else '❌'}") + if before.deaf != after.deaf: + changes.append(f"Server Deaf: {'✅' if after.deaf else '❌'}") + if before.streaming != after.streaming: + changes.append(f"Streaming: {'✅' if after.streaming else '❌'}") + if before.self_video != after.self_video: + changes.append(f"Camera: {'✅' if after.self_video else '❌'}") + + if changes: + event_type = "state_change" + title = "🎛️ Voice Status geändert" + color = discord.Color.blue() + + if not event_type: + return + + embed = discord.Embed( + title=title, + color=color, + timestamp=datetime.utcnow() + ) + + # User Info + embed.add_field( + name="👤 User", + value=f"{member.mention}\n`{member}`", + inline=True + ) + + # Channel Info + if event_type == "join": + embed.add_field( + name="📍 Channel", + value=after.channel.mention, + inline=True + ) + # Wer ist noch im Channel? + other_members = [m for m in after.channel.members if m != member and not m.bot] + if other_members: + embed.add_field( + name="👥 Andere Mitglieder", + value=f"{len(other_members)} Mitglied{'er' if len(other_members) != 1 else ''}", + inline=True + ) + + elif event_type == "leave": + embed.add_field( + name="📍 Channel", + value=before.channel.mention, + inline=True + ) + # Session-Dauer berechnen wenn im Cache + if cached_before and cached_before.channel: + # Schätze Join-Zeit (grober Wert) + embed.add_field( + name="⏱️ Ungefähre Dauer", + value="Session beendet", + inline=True + ) + + elif event_type == "move": + embed.add_field( + name="📍 Von", + value=before.channel.mention, + inline=True + ) + embed.add_field( + name="📍 Nach", + value=after.channel.mention, + inline=True + ) + + elif event_type == "state_change": + embed.add_field( + name="📍 Channel", + value=after.channel.mention, + inline=True + ) + embed.add_field( + name="🔧 Änderungen", + value="\n".join(changes), + inline=False + ) + + embed.set_author(name=member.display_name, icon_url=member.display_avatar.url) + embed.set_footer(text=f"User ID: {member.id}") + + await self.send_log(guild_id, embed, "voice") + + except Exception as e: + logger.error(f"Error in on_voice_state_update: {e}") + self._stats['errors'] += 1 + + # ============================================================================= + # MEMBER UPDATE EVENTS + # ============================================================================= + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Member Update Logging mit intelligenter Filterung""" + try: + if before.bot: + return + + self._stats['events_processed'] += 1 + changes = [] + important_change = False + + # Nickname Änderung + if before.display_name != after.display_name: + changes.append({ + 'field': '🏷️ Nickname', + 'before': before.display_name or "*Kein Nickname*", + 'after': after.display_name or "*Kein Nickname*" + }) + important_change = True + + # Rollen Änderung + before_roles = set(before.roles) + after_roles = set(after.roles) + + added_roles = after_roles - before_roles + removed_roles = before_roles - after_roles + + if added_roles or removed_roles: + important_change = True + + if added_roles: + role_names = [role.name for role in added_roles if role.name != "@everyone"] + if role_names: + changes.append({ + 'field': '➕ Rollen hinzugefügt', + 'value': ", ".join(role_names[:5]) # Max 5 anzeigen + }) + + if removed_roles: + role_names = [role.name for role in removed_roles if role.name != "@everyone"] + if role_names: + changes.append({ + 'field': '➖ Rollen entfernt', + 'value': ", ".join(role_names[:5]) # Max 5 anzeigen + }) + + # Premium Status (Nitro Boost) + if hasattr(before, 'premium_since') and hasattr(after, 'premium_since'): + if before.premium_since != after.premium_since: + if after.premium_since and not before.premium_since: + changes.append({ + 'field': '💎 Server Boost', + 'value': 'Begonnen zu boosten' + }) + important_change = True + elif before.premium_since and not after.premium_since: + changes.append({ + 'field': '💎 Server Boost', + 'value': 'Boost beendet' + }) + important_change = True + + # Timeout Status + if hasattr(before, 'timed_out_until') and hasattr(after, 'timed_out_until'): + if before.timed_out_until != after.timed_out_until: + if after.timed_out_until: + changes.append({ + 'field': '⏸️ Timeout', + 'value': f"Bis " + }) + important_change = True + elif before.timed_out_until: + changes.append({ + 'field': '⏸️ Timeout', + 'value': 'Timeout aufgehoben' + }) + important_change = True + + # Nur loggen wenn wichtige Änderungen + if not important_change or not changes: + return + + embed = discord.Embed( + title="👤 Member geändert", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="👤 Member", + value=f"{after.mention}\n`{after}`", + inline=True + ) + + # Änderungen hinzufügen + for change in changes[:self.config['max_embed_fields'] - 2]: # Platz für User und ID + if 'before' in change and 'after' in change: + value = f"**Vorher:** {change['before']}\n**Nachher:** {change['after']}" + else: + value = change['value'] + + embed.add_field( + name=change['field'], + value=value[:1024], # Discord limit + inline=False + ) + + embed.set_author(name=after.display_name, icon_url=after.display_avatar.url) + embed.set_footer(text=f"User ID: {after.id}") + + await self.send_log(after.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_member_update: {e}") + self._stats['errors'] += 1 + + # ============================================================================= + # CHANNEL EVENTS + # ============================================================================= + + @commands.Cog.listener() + async def on_guild_channel_create(self, channel): + """Channel Creation Logging""" + try: + self._stats['events_processed'] += 1 + + embed = discord.Embed( + title="➕ Channel erstellt", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + + # Channel-Typ Icon + type_icons = { + discord.ChannelType.text: "💬", + discord.ChannelType.voice: "🔊", + discord.ChannelType.category: "📁", + discord.ChannelType.news: "📢", + discord.ChannelType.stage_voice: "🎭", + discord.ChannelType.forum: "💭", + discord.ChannelType.private_thread: "🧵", + discord.ChannelType.public_thread: "🧵" + } + + icon = type_icons.get(channel.type, "📍") + embed.add_field( + name="📍 Channel", + value=f"{icon} {channel.mention}\n`{channel.name}`", + inline=True + ) + + embed.add_field( + name="📋 Typ", + value=channel.type.name.replace('_', ' ').title(), + inline=True + ) + + embed.add_field( + name="🆔 ID", + value=f"`{channel.id}`", + inline=True + ) + + # Kategorie info + if hasattr(channel, 'category') and channel.category: + embed.add_field( + name="📁 Kategorie", + value=channel.category.name, + inline=True + ) + + # Position + if hasattr(channel, 'position'): + embed.add_field( + name="📊 Position", + value=str(channel.position), + inline=True + ) + + embed.set_footer(text=f"Channel ID: {channel.id}") + await self.send_log(channel.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_guild_channel_create: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_guild_channel_delete(self, channel): + """Channel Deletion Logging""" + try: + self._stats['events_processed'] += 1 + + embed = discord.Embed( + title="➖ Channel gelöscht", + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + + # Channel-Typ Icon + type_icons = { + discord.ChannelType.text: "💬", + discord.ChannelType.voice: "🔊", + discord.ChannelType.category: "📁", + discord.ChannelType.news: "📢", + discord.ChannelType.stage_voice: "🎭", + discord.ChannelType.forum: "💭" + } + + icon = type_icons.get(channel.type, "📍") + embed.add_field( + name="📍 Channel", + value=f"{icon} `#{channel.name}`", + inline=True + ) + + embed.add_field( + name="📋 Typ", + value=channel.type.name.replace('_', ' ').title(), + inline=True + ) + + embed.add_field( + name="🆔 ID", + value=f"`{channel.id}`", + inline=True + ) + + # Kategorie info + if hasattr(channel, 'category') and channel.category: + embed.add_field( + name="📁 Kategorie", + value=channel.category.name, + inline=True + ) + + embed.set_footer(text=f"Channel ID: {channel.id}") + await self.send_log(channel.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_guild_channel_delete: {e}") + self._stats['errors'] += 1 + + # ============================================================================= + # BAN/KICK EVENTS + # ============================================================================= + + @commands.Cog.listener() + async def on_member_ban(self, guild: discord.Guild, user: discord.User): + """Member Ban Logging""" + try: + self._stats['events_processed'] += 1 + + # Versuche Ban-Info mit Grund zu holen + ban_info = None + try: + ban_info = await guild.fetch_ban(user) + except: + pass + + embed = discord.Embed( + title="🔨 Member gebannt", + color=discord.Color.dark_red(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="👤 User", + value=f"{user.mention}\n`{user}`", + inline=True + ) + + embed.add_field( + name="🆔 ID", + value=f"`{user.id}`", + inline=True + ) + + embed.add_field( + name="📅 Account erstellt", + value=f"", + inline=True + ) + + if ban_info and ban_info.reason: + embed.add_field( + name="📝 Grund", + value=ban_info.reason[:1000], + inline=False + ) + + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_footer(text=f"User ID: {user.id}") + + await self.send_log(guild.id, embed, "moderation") + + except Exception as e: + logger.error(f"Error in on_member_ban: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_member_unban(self, guild: discord.Guild, user: discord.User): + """Member Unban Logging""" + try: + self._stats['events_processed'] += 1 + + embed = discord.Embed( + title="🔓 Member entbannt", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="👤 User", + value=f"{user.mention}\n`{user}`", + inline=True + ) + + embed.add_field( + name="🆔 ID", + value=f"`{user.id}`", + inline=True + ) + + embed.add_field( + name="📅 Account erstellt", + value=f"", + inline=True + ) + + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_footer(text=f"User ID: {user.id}") + + await self.send_log(guild.id, embed, "moderation") + + except Exception as e: + logger.error(f"Error in on_member_unban: {e}") + self._stats['errors'] += 1 + + # ============================================================================= + # INVITE EVENTS + # ============================================================================= + + @commands.Cog.listener() + async def on_invite_create(self, invite: discord.Invite): + """Invite Creation Logging""" + try: + self._stats['events_processed'] += 1 + + embed = discord.Embed( + title="🔗 Invite erstellt", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="🔗 Invite Code", + value=f"`{invite.code}`", + inline=True + ) + + embed.add_field( + name="📍 Channel", + value=invite.channel.mention if invite.channel else "Unbekannt", + inline=True + ) + + if invite.inviter: + embed.add_field( + name="👤 Ersteller", + value=f"{invite.inviter.mention}\n`{invite.inviter}`", + inline=True + ) + + # Invite Settings + settings = [] + if invite.max_uses: + settings.append(f"Max. Nutzungen: {invite.max_uses}") + else: + settings.append("Max. Nutzungen: ∞") + + if invite.max_age: + settings.append(f"Ablauf: ") + else: + settings.append("Ablauf: Nie") + + if invite.temporary: + settings.append("Temporär: Ja") + + if settings: + embed.add_field( + name="⚙️ Einstellungen", + value="\n".join(settings), + inline=False + ) + + if invite.inviter: + embed.set_author(name=invite.inviter.display_name, icon_url=invite.inviter.display_avatar.url) + + embed.set_footer(text=f"Invite Code: {invite.code}") + await self.send_log(invite.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_invite_create: {e}") + self._stats['errors'] += 1 + + @commands.Cog.listener() + async def on_invite_delete(self, invite: discord.Invite): + """Invite Deletion Logging""" + try: + self._stats['events_processed'] += 1 + + embed = discord.Embed( + title="🗑️ Invite gelöscht", + color=discord.Color.red(), + timestamp=datetime.utcnow() + ) + + embed.add_field( + name="🔗 Invite Code", + value=f"`{invite.code}`", + inline=True + ) + + embed.add_field( + name="📍 Channel", + value=invite.channel.mention if invite.channel else "Unbekannt", + inline=True + ) + + if invite.uses is not None: + embed.add_field( + name="📊 Verwendet", + value=f"{invite.uses} mal", + inline=True + ) + + embed.set_footer(text=f"Invite Code: {invite.code}") + await self.send_log(invite.guild.id, embed, "general") + + except Exception as e: + logger.error(f"Error in on_invite_delete: {e}") + self._stats['errors'] += 1 + +def setup(bot): + bot.add_cog(LoggingCog(bot)) \ No newline at end of file diff --git a/src/bot/cogs/guild/tempvc.py b/src/bot/cogs/guild/tempvc.py new file mode 100644 index 0000000..87fb7ec --- /dev/null +++ b/src/bot/cogs/guild/tempvc.py @@ -0,0 +1,612 @@ +# Copyright (c) 2025 OPPRO.NET Network +from DevTools import TempVCDatabase +import discord +from discord import slash_command, option, SlashCommandGroup +from discord.ext import commands +from discord.ui import Container +import ezcord + +db = TempVCDatabase() + + +class TempChannelControlView(discord.ui.View): + def __init__(self, channel_owner_id: int, prefix: str = "🔧"): + super().__init__(timeout=None) + self.channel_owner_id = channel_owner_id + self.prefix = prefix + + # Update button labels with custom prefix + self.rename_button.label = f"{prefix} Umbenennen" + self.limit_button.label = f"{prefix} Limit" + self.lock_button.label = f"{prefix} Sperren" + self.kick_button.label = f"{prefix} Kick" + + @discord.ui.button(label="🔧 Umbenennen", style=discord.ButtonStyle.primary, custom_id="tempvc_rename") + async def rename_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if interaction.user.id != self.channel_owner_id: + container = Container() + container.add_text(f"{emoji_no} Keine Berechtigung\nDu bist nicht der Besitzer dieses Channels!") + return await interaction.response.send_message(view=container, ephemeral=True) + + modal = RenameChannelModal(interaction.channel) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="🔧 Limit", style=discord.ButtonStyle.primary, custom_id="tempvc_limit") + async def limit_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if interaction.user.id != self.channel_owner_id: + container = Container() + container.add_text(f"{emoji_no} Keine Berechtigung\nDu bist nicht der Besitzer dieses Channels!") + return await interaction.response.send_message(view=container, ephemeral=True) + + modal = UserLimitModal(interaction.channel) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="🔧 Sperren", style=discord.ButtonStyle.secondary, custom_id="tempvc_lock") + async def lock_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if interaction.user.id != self.channel_owner_id: + container = Container() + container.add_text(f"{emoji_no} Keine Berechtigung\nDu bist nicht der Besitzer dieses Channels!") + return await interaction.response.send_message(view=container, ephemeral=True) + + channel = interaction.channel + overwrites = channel.overwrites + + # Toggle lock status + is_locked = not overwrites.get(interaction.guild.default_role, discord.PermissionOverwrite()).connect + + if interaction.guild.default_role not in overwrites: + overwrites[interaction.guild.default_role] = discord.PermissionOverwrite() + + overwrites[interaction.guild.default_role].connect = not is_locked + + try: + await channel.edit(overwrites=overwrites) + status = "🔒 gesperrt" if is_locked else "🔓 entsperrt" + button.label = f"{self.prefix} {'Entsperren' if is_locked else 'Sperren'}" + button.style = discord.ButtonStyle.danger if is_locked else discord.ButtonStyle.secondary + + await interaction.response.edit_message(view=self) + + container = Container() + container.add_text(f"Channel wurde {status}!") + await interaction.followup.send(view=container, ephemeral=True) + except discord.Forbidden: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehlende Berechtigungen!") + await interaction.response.send_message(view=container, ephemeral=True) + + @discord.ui.button(label="🔧 Kick", style=discord.ButtonStyle.danger, custom_id="tempvc_kick") + async def kick_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if interaction.user.id != self.channel_owner_id: + container = Container() + container.add_text(f"{emoji_no} Keine Berechtigung\nDu bist nicht der Besitzer dieses Channels!") + return await interaction.response.send_message(view=container, ephemeral=True) + + modal = KickUserModal(interaction.channel) + await interaction.response.send_modal(modal) + + +class RenameChannelModal(discord.ui.Modal): + def __init__(self, channel): + super().__init__(title="Channel umbenennen") + self.channel = channel + + self.name_input = discord.ui.InputText( + label="Neuer Channel-Name", + placeholder="Gib einen neuen Namen ein...", + value=channel.name, + max_length=100, + required=True + ) + self.add_item(self.name_input) + + async def callback(self, interaction: discord.Interaction): + new_name = self.name_input.value.strip() + + # Validate name + if len(new_name) < 1: + container = Container() + container.add_text(f"{emoji_no} Ungültiger Name\nName darf nicht leer sein!") + return await interaction.response.send_message(view=container, ephemeral=True) + + # Check for forbidden characters + forbidden_chars = ['@', '#', ':', '`', '```'] + if any(char in new_name for char in forbidden_chars): + container = Container() + container.add_text(f"{emoji_no} Ungültige Zeichen\nName enthält ungültige Zeichen!") + return await interaction.response.send_message(view=container, ephemeral=True) + + try: + old_name = self.channel.name + await self.channel.edit(name=new_name) + + container = Container() + container.add_text( + f"{emoji_yes} Channel umbenannt\n" + f"**{old_name}** → **{new_name}**" + ) + await interaction.response.send_message(view=container, ephemeral=True) + + except discord.Forbidden: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehlende Berechtigungen zum Umbenennen!") + await interaction.response.send_message(view=container, ephemeral=True) + except discord.HTTPException as e: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehler beim Umbenennen: {str(e)}") + await interaction.response.send_message(view=container, ephemeral=True) + + +class UserLimitModal(discord.ui.Modal): + def __init__(self, channel): + super().__init__(title="User-Limit setzen") + self.channel = channel + + current_limit = channel.user_limit if channel.user_limit else "Kein Limit" + + self.limit_input = discord.ui.InputText( + label="Neues User-Limit (0 = Kein Limit)", + placeholder="Gib eine Zahl zwischen 0-99 ein...", + value=str(current_limit) if isinstance(current_limit, int) else "0", + max_length=2, + required=True + ) + self.add_item(self.limit_input) + + async def callback(self, interaction: discord.Interaction): + try: + limit = int(self.limit_input.value.strip()) + + if limit < 0 or limit > 99: + container = Container() + container.add_text(f"{emoji_no} Ungültiges Limit\nLimit muss zwischen 0 und 99 liegen!") + return await interaction.response.send_message(view=container, ephemeral=True) + + # 0 means no limit in Discord + limit = None if limit == 0 else limit + + await self.channel.edit(user_limit=limit) + + limit_text = "Kein Limit" if limit is None else f"{limit} User" + + container = Container() + container.add_text( + f"{emoji_yes} User-Limit geändert\n" + f"Neues Limit: **{limit_text}**" + ) + await interaction.response.send_message(view=container, ephemeral=True) + + except ValueError: + container = Container() + container.add_text(f"{emoji_no} Ungültige Eingabe\nBitte gib eine gültige Zahl ein!") + await interaction.response.send_message(view=container, ephemeral=True) + except discord.Forbidden: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehlende Berechtigungen!") + await interaction.response.send_message(view=container, ephemeral=True) + except discord.HTTPException as e: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehler beim Setzen des Limits: {str(e)}") + await interaction.response.send_message(view=container, ephemeral=True) + + +class KickUserModal(discord.ui.Modal): + def __init__(self, channel): + super().__init__(title="User kicken") + self.channel = channel + + # Create list of current members (except bot and channel owner) + members_list = [] + for member in channel.members: + if not member.bot and db.get_temp_channel_owner(channel.id) != member.id: + members_list.append(f"{member.display_name} ({member.id})") + + members_text = "\n".join(members_list[:10]) # Limit to first 10 for display + if len(members_list) > 10: + members_text += f"\n... und {len(members_list) - 10} weitere" + + self.user_input = discord.ui.InputText( + label="User zum Kicken", + placeholder="@Username oder User-ID...", + style=discord.InputTextStyle.short, + required=True + ) + self.add_item(self.user_input) + + if members_text: + self.info_input = discord.ui.InputText( + label="Aktuelle Mitglieder:", + value=members_text if members_text else "Keine anderen Mitglieder im Channel", + style=discord.InputTextStyle.paragraph, + required=False + ) + self.add_item(self.info_input) + + async def callback(self, interaction: discord.Interaction): + user_input = self.user_input.value.strip() + + # Try to find user by mention, name or ID + target_user = None + + # Check if it's a mention + if user_input.startswith('<@') and user_input.endswith('>'): + user_id = int(user_input[2:-1].replace('!', '')) + target_user = interaction.guild.get_member(user_id) + else: + # Try by ID first + try: + user_id = int(user_input) + target_user = interaction.guild.get_member(user_id) + except ValueError: + # Try by username/display name + for member in self.channel.members: + if (member.display_name.lower() == user_input.lower() or + member.name.lower() == user_input.lower()): + target_user = member + break + + if not target_user: + container = Container() + container.add_text(f"{emoji_no} Fehler\nUser nicht gefunden!") + return await interaction.response.send_message(view=container, ephemeral=True) + + if target_user not in self.channel.members: + container = Container() + container.add_text(f"{emoji_no} Fehler\nUser ist nicht in diesem Channel!") + return await interaction.response.send_message(view=container, ephemeral=True) + + if target_user.id == db.get_temp_channel_owner(self.channel.id): + container = Container() + container.add_text(f"{emoji_no} Fehler\nDu kannst dich nicht selbst kicken!") + return await interaction.response.send_message(view=container, ephemeral=True) + + if target_user.bot: + container = Container() + container.add_text(f"{emoji_no} Fehler\nBots können nicht gekickt werden!") + return await interaction.response.send_message(view=container, ephemeral=True) + + try: + await target_user.move_to(None) # Disconnect from voice + + container = Container() + container.add_text( + f"{emoji_yes} User gekickt\n" + f"**{target_user.display_name}** wurde aus dem Channel gekickt." + ) + await interaction.response.send_message(view=container, ephemeral=True) + + except discord.Forbidden: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehlende Berechtigungen zum Kicken!") + await interaction.response.send_message(view=container, ephemeral=True) + except discord.HTTPException as e: + container = Container() + container.add_text(f"{emoji_no} Fehler\nFehler beim Kicken: {str(e)}") + await interaction.response.send_message(view=container, ephemeral=True) + + +class TempVC(ezcord.Cog): + def __init__(self, bot): + self.bot = bot + + tempvc = SlashCommandGroup("tempvc", "Verwalte temporäre Voice-Channel Systeme") + + @tempvc.command(name="create", description="Erstelle ein VC-Erstellungssystem") + @option("creator_channel", description="Channel, den Mitglieder betreten, um ihren VC zu erstellen", + channel_types=[discord.ChannelType.voice]) + @option("category", description="Kategorie, in der die Temp-Channels erstellt werden", + channel_types=[discord.ChannelType.category]) + async def tempvc_create(self, ctx: discord.ApplicationContext, creator_channel: discord.VoiceChannel, + category: discord.CategoryChannel): + if not ctx.author.guild_permissions.administrator: + container = Container() + container.add_text( + f"{emoji_no} Keine Berechtigung\n" + "Du brauchst Administratorrechte." + ) + return await ctx.respond(view=container, ephemeral=True) + + try: + db.set_tempvc_settings(ctx.guild.id, creator_channel.id, category.id) + + container = Container() + container.add_text( + f"{emoji_yes} Temp-VC System aktiviert\n" + "Das temporäre Voice-Channel System wurde erfolgreich eingerichtet!" + ) + container.add_separator() + container.add_text( + f"**🎤 Ersteller-Channel:** {creator_channel.mention}\n" + f"**📁 Kategorie:** {category.mention}\n" + "**ℹ️ Information:** Mitglieder können nun den Ersteller-Channel betreten, um automatisch einen eigenen temporären Voice-Channel zu erhalten." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + except Exception as e: + container = Container() + container.add_text( + f"{emoji_no} Fehler beim Erstellen\n" + f"```{str(e)}```" + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + + @tempvc.command(name="remove", description="Entferne das VC-Erstellungssystem") + async def tempvc_remove(self, ctx: discord.ApplicationContext): + if not ctx.author.guild_permissions.administrator: + container = Container() + container.add_text( + f"{emoji_no} Keine Berechtigung\n" + "Du brauchst Administratorrechte." + ) + view = discord.ui.View(container, timeout=None) + return await ctx.respond(view=view, ephemeral=True) + + try: + settings = db.get_tempvc_settings(ctx.guild.id) + if not settings: + container = Container() + container.add_text( + f"{emoji_no} Kein System aktiv\n" + "Es ist derzeit kein Temp-VC System auf diesem Server aktiv." + ) + view = discord.ui.View(container, timeout=None) + return await ctx.respond(view=view, ephemeral=True) + + db.remove_tempvc_settings(ctx.guild.id) + + container = Container() + container.add_text( + f"{emoji_yes} System deaktiviert\n" + "Das Temp-VC System wurde erfolgreich deaktiviert!" + ) + container.add_separator() + container.add_text( + "**ℹ️ Information:** Bestehende temporäre Channels bleiben bestehen, aber es werden keine neuen mehr erstellt." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + except Exception as e: + container = Container() + container.add_text( + f"{emoji_no} Fehler beim Entfernen\n" + f"```{str(e)}```" + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + + @tempvc.command(name="settings", description="Zeige die aktuellen Temp-VC Einstellungen") + async def tempvc_settings(self, ctx: discord.ApplicationContext): + if not ctx.author.guild_permissions.administrator: + container = Container() + container.add_text( + f"{emoji_no} Keine Berechtigung\n" + "Du brauchst Administratorrechte." + ) + view = discord.ui.View(container, timeout=None) + return await ctx.respond(view=view, ephemeral=True) + + settings = db.get_tempvc_settings(ctx.guild.id) + if not settings: + container = Container() + container.add_text( + f"{emoji_no} Kein System aktiv\n" + "Es ist derzeit kein Temp-VC System auf diesem Server aktiv." + ) + container.add_separator() + container.add_text( + "**💡 Tipp:** Verwende `/tempvc create` um ein Temp-VC System einzurichten." + ) + view = discord.ui.View(container, timeout=None) + return await ctx.respond(view=view, ephemeral=True) + + creator_channel_id, category_id, auto_delete_time = settings + creator_channel = ctx.guild.get_channel(creator_channel_id) + category = ctx.guild.get_channel(category_id) + + container = Container() + container.add_text("🎛️ **Temp-VC Einstellungen**\nAktuelle Konfiguration des temporären Voice-Channel Systems") + container.add_separator() + + container.add_text( + f"**🎤 Ersteller-Channel:**\n" + f"{creator_channel.mention if creator_channel else f'{emoji_no} Channel nicht gefunden (ID: {creator_channel_id})'}" + ) + container.add_separator() + + container.add_text( + f"**📁 Kategorie:**\n" + f"{category.mention if category else f'{emoji_no} Kategorie nicht gefunden (ID: {category_id})'}" + ) + container.add_separator() + + container.add_text(f"**⏰ Auto-Löschzeit:**\n{auto_delete_time} Minuten") + container.add_separator() + + # UI Settings + ui_settings = db.get_ui_settings(ctx.guild.id) + if ui_settings: + ui_enabled, ui_prefix = ui_settings + container.add_text( + f"**🖥️ Control-UI:**\n" + f"{'✅ Aktiviert' if ui_enabled else '❌ Deaktiviert'}" + ) + if ui_enabled: + container.add_separator() + container.add_text(f"**🏷️ UI-Prefix:**\n{ui_prefix}") + else: + container.add_text("**🖥️ Control-UI:**\n❌ Deaktiviert") + + container.add_separator() + container.add_text( + f"**ℹ️ Status:**\n" + f"{emoji_yes + ' System aktiv' if creator_channel and category else emoji_no + ' Fehlerhafte Konfiguration'}" + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + + @tempvc.command(name="ui", description="Konfiguriere das Control-UI für Temp-Channels") + @option("enabled", description="Soll das UI aktiviert sein?", choices=[ + discord.OptionChoice(name="Aktiviert", value="true"), + discord.OptionChoice(name="Deaktiviert", value="false") + ]) + @option("prefix", description="Prefix für UI-Buttons (Emoji oder Text)", required=False, default="🔧") + async def tempvc_ui(self, ctx: discord.ApplicationContext, enabled: str, prefix: str = "🔧"): + if not ctx.author.guild_permissions.administrator: + container = Container() + container.add_text( + f"{emoji_no} Keine Berechtigung\n" + "Du brauchst Administratorrechte." + ) + return await ctx.respond(view=container, ephemeral=True) + + # Check if TempVC system exists + settings = db.get_tempvc_settings(ctx.guild.id) + if not settings: + container = Container() + container.add_text( + f"{emoji_no} Kein System aktiv\n" + "Du musst zuerst ein Temp-VC System erstellen!" + ) + container.add_separator() + container.add_text( + "**💡 Tipp:** Verwende `/tempvc create` um ein Temp-VC System einzurichten." + ) + view = discord.ui.View(container, timeout=None) + return await ctx.respond(view=view, ephemeral=True) + + ui_enabled = enabled == "true" + + # Validate prefix + if len(prefix) > 10: + container = Container() + container.add_text(f"{emoji_no} Ungültiger Prefix\nPrefix darf maximal 10 Zeichen lang sein!") + return await ctx.respond(view=container, ephemeral=True) + + try: + db.set_ui_settings(ctx.guild.id, ui_enabled, prefix) + + container = Container() + container.add_text(f"{emoji_yes} UI-Einstellungen gespeichert") + container.add_separator() + container.add_text( + f"**🖥️ Control-UI:** {'✅ Aktiviert' if ui_enabled else '❌ Deaktiviert'}" + ) + if ui_enabled: + container.add_separator() + container.add_text(f"**🏷️ Prefix:** {prefix}") + container.add_separator() + container.add_text( + "**ℹ️ Information:** Das Control-UI wird nun in neu erstellten Temp-Channels angezeigt." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + + except Exception as e: + container = Container() + container.add_text( + f"{emoji_no} Fehler beim Speichern\n" + f"```{str(e)}```" + ) + await ctx.respond(view=container, ephemeral=True) + + @commands.Cog.listener() + async def on_voice_state_update(self, member, before, after): + try: + if after.channel: + await self.handle_creator_channel_join(member, after.channel) + if before.channel: + await self.handle_channel_leave(before.channel) + except Exception as e: + print(f"Error in voice state update: {e}") + + async def handle_creator_channel_join(self, member: discord.Member, channel: discord.VoiceChannel): + settings = db.get_tempvc_settings(member.guild.id) + if not settings: + return + + creator_channel_id, category_id, auto_delete_time = settings + + if channel.id != creator_channel_id: + return + + guild = member.guild + category = discord.utils.get(guild.categories, id=category_id) + if not category: + print(f"Category with ID {category_id} not found in guild {guild.id}") + return + + overwrites = { + guild.default_role: discord.PermissionOverwrite(view_channel=False), + member: discord.PermissionOverwrite( + view_channel=True, + connect=True, + manage_permissions=True, + move_members=True + ) + } + + try: + temp_channel = await guild.create_voice_channel( + name=f"🔊 {member.display_name}'s Raum", + category=category, + overwrites=overwrites + ) + db.add_temp_channel(temp_channel.id, guild.id, member.id) + await member.move_to(temp_channel) + + # Check if UI is enabled and send control panel + ui_settings = db.get_ui_settings(guild.id) + if ui_settings and ui_settings[0]: # UI enabled + ui_enabled, ui_prefix = ui_settings + + container = Container() + container.add_text( + f"## 🎛️ **Channel-Kontrolle**\n" + f"**{member.display_name}**, du bist der Besitzer dieses Channels!\n" + "Verwende die Buttons unten, um deinen Channel zu verwalten." + ) + container.add_separator() + container.add_text( + "**🔧 Verfügbare Aktionen:**\n" + "• **Umbenennen** - Ändere den Channel-Namen\n" + "• **Limit** - Setze ein User-Limit\n" + "• **Sperren** - Sperre/Entsperre den Channel\n" + "• **Kick** - Kicke User aus dem Channel" + ) + container.add_separator() + container.add_text("Diese Buttons funktionieren nur für den Channel-Besitzer.") + + control_view = TempChannelControlView(member.id, ui_prefix) + view = discord.ui.View(container, timeout=None) + await temp_channel.send(view=view) + await temp_channel.send(view=control_view) + + except discord.Forbidden: + print(f"Missing permissions to create voice channel in guild {guild.id}") + except discord.HTTPException as e: + print(f"HTTP error when creating voice channel: {e}") + except Exception as e: + print(f"Unexpected error when creating temp channel: {e}") + + async def handle_channel_leave(self, channel: discord.VoiceChannel): + if len(channel.members) > 0: + return + + if not db.is_temp_channel(channel.id): + return + + try: + db.remove_temp_channel(channel.id) + await channel.delete(reason="Temp channel cleanup - channel empty") + + except discord.Forbidden: + print(f"Missing permissions to delete channel {channel.id}") + except discord.NotFound: + db.remove_temp_channel(channel.id) + except Exception as e: + print(f"Error deleting temp channel {channel.id}: {e}") + + +def setup(bot): + bot.add_cog(TempVC(bot)) \ No newline at end of file diff --git a/src/bot/cogs/guild/welcome.py b/src/bot/cogs/guild/welcome.py new file mode 100644 index 0000000..cc9fb69 --- /dev/null +++ b/src/bot/cogs/guild/welcome.py @@ -0,0 +1,1467 @@ +""" +Welcome System Cog +================== + +Umfassendes Welcome System mit Embed-Support, Auto-Roles, +DM-Nachrichten und Statistiken. +""" + +import discord +from discord.ext import commands +from DevTools import WelcomeDatabase +import asyncio +import json +import io +import logging +from typing import Optional, Dict, Any +import aiosqlite +from datetime import datetime +import ezcord +from discord.ui import Container + + +# Logger Setup +logger = logging.getLogger(__name__) + + +class WelcomeSystem(ezcord.Cog): + """ + Welcome System für Discord Server. + + Bietet umfassende Willkommensnachrichten mit Embed-Support, + automatischen Rollen, privaten Nachrichten und Statistiken. + + Parameters + ---------- + bot : ezcord.Bot + Die Bot-Instanz + + Attributes + ---------- + bot : ezcord.Bot + Die Bot-Instanz + db : WelcomeDatabase + Datenbank-Handler für Welcome-Einstellungen + _settings_cache : dict + Cache für Server-Einstellungen + _cache_timeout : int + Cache-Timeout in Sekunden (Standard: 300) + _rate_limit_cache : dict + Rate-Limiting Cache für Welcome-Messages + """ + + def __init__(self, bot): + """ + Initialisiert das Welcome System. + + Parameters + ---------- + bot : ezcord.Bot + Die Bot-Instanz + """ + self.bot = bot + self.db = WelcomeDatabase() + # Cache für bessere Performance + self._settings_cache = {} + self._cache_timeout = 300 # 5 Minuten Cache + self._rate_limit_cache = {} # Rate Limiting + + async def get_cached_settings(self, guild_id: int): + """ + Holt Einstellungen mit Cache-Unterstützung. + + Parameters + ---------- + guild_id : int + Discord Server ID + + Returns + ------- + dict or None + Server-Einstellungen aus Cache oder Datenbank + + Notes + ----- + Cache wird nach 5 Minuten automatisch invalidiert. + """ + now = asyncio.get_event_loop().time() + + if guild_id in self._settings_cache: + cached_data, timestamp = self._settings_cache[guild_id] + if now - timestamp < self._cache_timeout: + return cached_data + + # Aus Datenbank laden + settings = await self.db.get_welcome_settings(guild_id) + if settings: + self._settings_cache[guild_id] = (settings, now) + return settings + + def invalidate_cache(self, guild_id: int): + """ + Invalidiert Cache für einen Server. + + Parameters + ---------- + guild_id : int + Discord Server ID + + Notes + ----- + Sollte nach jeder Einstellungsänderung aufgerufen werden. + """ + if guild_id in self._settings_cache: + del self._settings_cache[guild_id] + + def check_rate_limit(self, guild_id: int) -> bool: + """ + Prüft Rate Limit für Server. + + Parameters + ---------- + guild_id : int + Discord Server ID + + Returns + ------- + bool + True wenn Rate Limit nicht erreicht, False sonst + + Notes + ----- + Erlaubt maximal eine Welcome Message alle 5 Sekunden pro Server. + """ + now = asyncio.get_event_loop().time() + if guild_id not in self._rate_limit_cache: + self._rate_limit_cache[guild_id] = now + return True + + last_time = self._rate_limit_cache[guild_id] + if now - last_time >= 5: # 5 Sekunden zwischen Welcome Messages + self._rate_limit_cache[guild_id] = now + return True + + return False + + def replace_placeholders(self, text: str, member: discord.Member, guild: discord.Guild) -> str: + """ + Erweiterte Placeholder-Ersetzung mit Rückwärtskompatibilität. + + Parameters + ---------- + text : str + Text mit Placeholders + member : discord.Member + Discord Member Objekt + guild : discord.Guild + Discord Guild Objekt + + Returns + ------- + str + Text mit ersetzten Placeholders + + Notes + ----- + Unterstützte Placeholder-Kategorien: + - User: %user%, %username%, %mention%, %tag%, %userid% + - Server: %servername%, %server%, %guild%, %serverid%, %membercount% + - Zeit: %joindate%, %jointime%, %createddate%, %createdtime%, %accountage% + - Erweitert: %roles%, %rolecount%, %highestrole%, %avatar% + - Statistiken: %onlinemembers%, %textchannels%, %voicechannels% + + Examples + -------- + >>> text = "Willkommen %mention% auf %servername%!" + >>> replace_placeholders(text, member, guild) + "Willkommen @User auf Mein Server!" + """ + if not text: + return text + + try: + # Basis Placeholder (alte Version) + placeholders = { + '%user%': member.display_name, + '%username%': member.name, + '%mention%': member.mention, + '%tag%': str(member), + '%userid%': str(member.id), + '%servername%': guild.name, + '%serverid%': str(guild.id), + '%membercount%': str(guild.member_count), + '%joindate%': member.joined_at.strftime('%d.%m.%Y') if member.joined_at else 'Unbekannt', + '%createddate%': member.created_at.strftime('%d.%m.%Y'), + '%server%': guild.name, + '%guild%': guild.name, + } + + # Erweiterte Placeholder (neue Version) + try: + # Rolleninformationen + roles = [role.name for role in member.roles if role.name != "@everyone"] + highest_role = member.top_role.name if member.top_role.name != "@everyone" else "Keine" + + # Zeitberechnungen + account_age = (discord.utils.utcnow() - member.created_at).days + + # Online-Member zählen (kann fehlschlagen bei großen Servern) + try: + online_count = sum(1 for m in guild.members if m.status != discord.Status.offline) + except: + online_count = "Unbekannt" + + extended_placeholders = { + # Zeitinformationen + '%jointime%': member.joined_at.strftime('%H:%M') if member.joined_at else 'Unbekannt', + '%createdtime%': member.created_at.strftime('%H:%M'), + '%accountage%': f"{account_age} Tage", + + # Erweiterte Infos + '%discriminator%': member.discriminator if hasattr(member, 'discriminator') else "0000", + '%roles%': ', '.join(roles) if roles else 'Keine', + '%rolecount%': str(len(roles)), + '%highestrole%': highest_role, + '%avatar%': member.display_avatar.url, + '%defaultavatar%': member.default_avatar.url, + + # Server Statistiken + '%onlinemembers%': str(online_count), + '%textchannels%': str(len(guild.text_channels)), + '%voicechannels%': str(len(guild.voice_channels)), + '%categories%': str(len(guild.categories)), + '%emojis%': str(len(guild.emojis)), + } + + placeholders.update(extended_placeholders) + + except Exception as e: + logger.warning(f"Erweiterte Placeholder fehlgeschlagen: {e}") + + except Exception as e: + logger.error(f"Placeholder Fehler: {e}") + return text + + # Placeholder ersetzen + for placeholder, value in placeholders.items(): + text = text.replace(placeholder, str(value)) + + return text + + async def send_welcome_dm(self, member: discord.Member, settings: dict): + """ + Sendet private Willkommensnachricht. + + Parameters + ---------- + member : discord.Member + Neues Mitglied + settings : dict + Server-Einstellungen + + Notes + ----- + Fehler beim DM-Versand werden geloggt aber nicht als Fehler behandelt, + da viele User DMs deaktiviert haben. + """ + try: + if not settings.get('join_dm_enabled'): + return + + dm_message = settings.get('join_dm_message', + 'Willkommen auf **%servername%**! Schön, dass du da bist! 🎉') + + processed_message = self.replace_placeholders(dm_message, member, member.guild) + + await member.send(processed_message) + logger.info(f"Welcome DM an {member} gesendet") + + except discord.Forbidden: + logger.warning(f"Konnte keine DM an {member} senden - DMs deaktiviert") + except Exception as e: + logger.error(f"Fehler beim Senden der Welcome DM: {e}") + + async def assign_auto_role(self, member: discord.Member, settings: dict): + """ + Vergibt automatische Rolle. + + Parameters + ---------- + member : discord.Member + Neues Mitglied + settings : dict + Server-Einstellungen mit auto_role_id + + Notes + ----- + Prüft automatisch Berechtigungen und Rollen-Hierarchie. + """ + try: + auto_role_id = settings.get('auto_role_id') + if not auto_role_id: + return + + role = member.guild.get_role(auto_role_id) + if not role: + logger.warning(f"Auto-Role {auto_role_id} nicht gefunden in {member.guild.name}") + return + + if role >= member.guild.me.top_role: + logger.warning(f"Auto-Role {role.name} ist höher als Bot-Rolle") + return + + await member.add_roles(role, reason="Welcome Auto-Role") + logger.info(f"Auto-Role {role.name} an {member} vergeben") + + except discord.Forbidden: + logger.error(f"Keine Berechtigung für Auto-Role") + except Exception as e: + logger.error(f"Auto-Role Fehler: {e}") + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """ + Event wird ausgelöst, wenn ein neuer User dem Server beitritt. + + Parameters + ---------- + member : discord.Member + Neues Mitglied + + Notes + ----- + Führt folgende Aktionen aus (wenn aktiviert): + 1. Rate Limiting Check + 2. Einstellungen aus Cache/DB laden + 3. Auto-Role vergeben + 4. Welcome Message senden (Channel) + 5. Welcome DM senden + 6. Statistiken aktualisieren + """ + try: + # Rate Limiting prüfen + if not self.check_rate_limit(member.guild.id): + logger.info(f"Rate Limit aktiv für {member.guild.name}") + return + + settings = await self.get_cached_settings(member.guild.id) + + if not settings or not settings.get('enabled', True): + return + + # Channel validieren + channel_id = settings.get('channel_id') + if not channel_id: + logger.warning(f"Kein Welcome Channel für {member.guild.name} gesetzt") + return + + channel = self.bot.get_channel(channel_id) + if not channel: + logger.error(f"Welcome Channel {channel_id} nicht gefunden") + # Channel aus DB entfernen + await self.db.update_welcome_settings(member.guild.id, channel_id=None) + self.invalidate_cache(member.guild.id) + return + + # Permissions prüfen + perms = channel.permissions_for(member.guild.me) + if not perms.send_messages: + logger.error(f"Keine Send-Berechtigung in {channel.name}") + return + + # Auto-Role vergeben + await self.assign_auto_role(member, settings) + + # Welcome Message + welcome_message = settings.get('welcome_message', 'Willkommen %mention% auf **%servername%**! 🎉') + processed_message = self.replace_placeholders(welcome_message, member, member.guild) + + # Embed oder normale Nachricht + if settings.get('embed_enabled', False) and perms.embed_links: + await self.send_embed_welcome(channel, member, settings, processed_message) + else: + msg = await channel.send(processed_message) + await self.handle_auto_delete(msg, settings) + + # Private Nachricht senden + await self.send_welcome_dm(member, settings) + + # Statistiken aktualisieren + if settings.get('welcome_stats_enabled'): + await self.db.update_welcome_stats(member.guild.id, joins=1) + + except Exception as e: + logger.exception(f"Welcome System Fehler für {member}: {e}") + + async def send_embed_welcome(self, channel, member, settings, processed_message): + """ + Sendet Embed Welcome Message. + + Parameters + ---------- + channel : discord.TextChannel + Ziel-Channel + member : discord.Member + Neues Mitglied + settings : dict + Server-Einstellungen + processed_message : str + Verarbeitete Welcome Message (Fallback) + + Notes + ----- + Fallback auf normale Nachricht bei Embed-Fehlern. + """ + try: + embed = discord.Embed() + + # Embed Farbe + color_hex = settings.get('embed_color', '#00ff00') + try: + color = int(color_hex.replace('#', ''), 16) + embed.color = discord.Color(color) + except: + embed.color = discord.Color.green() + + # Embed Titel + embed_title = settings.get('embed_title') + if embed_title: + embed.title = self.replace_placeholders(embed_title, member, member.guild) + + # Embed Beschreibung + embed_description = settings.get('embed_description') + if embed_description: + embed.description = self.replace_placeholders(embed_description, member, member.guild) + else: + embed.description = processed_message + + # Embed Thumbnail + if settings.get('embed_thumbnail', False): + embed.set_thumbnail(url=member.display_avatar.url) + + # Embed Footer + embed_footer = settings.get('embed_footer') + if embed_footer: + embed.set_footer(text=self.replace_placeholders(embed_footer, member, member.guild)) + + # Nachricht senden + content = member.mention if settings.get('ping_user', False) else None + msg = await channel.send(content=content, embed=embed) + + await self.handle_auto_delete(msg, settings) + + except Exception as e: + logger.error(f"Embed Welcome Fehler: {e}") + # Fallback auf normale Nachricht + msg = await channel.send(processed_message) + await self.handle_auto_delete(msg, settings) + + async def handle_auto_delete(self, message, settings): + """ + Behandelt automatisches Löschen von Nachrichten. + + Parameters + ---------- + message : discord.Message + Zu löschende Nachricht + settings : dict + Server-Einstellungen mit delete_after + + Notes + ----- + Wartet die angegebene Zeit und löscht dann die Nachricht. + Fehler beim Löschen werden geloggt aber nicht weitergegeben. + """ + try: + delete_after = settings.get('delete_after', 0) + if delete_after > 0: + await asyncio.sleep(delete_after) + try: + await message.delete() + except discord.NotFound: + pass # Message bereits gelöscht + except discord.Forbidden: + logger.warning("Keine Berechtigung zum Löschen der Welcome Message") + except Exception as e: + logger.error(f"Auto-Delete Fehler: {e}") + + # Alle Commands bleiben gleich, aber mit Cache-Invalidierung + welcome = discord.SlashCommandGroup("welcome", "Welcome System Einstellungen") + + @welcome.command(name="channel", description="Setzt den Welcome Channel") + @commands.has_permissions(manage_guild=True) + async def set_welcome_channel(self, ctx, channel: discord.TextChannel): + """ + Setzt den Channel für Welcome Messages. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + channel : discord.TextChannel + Ziel-Channel für Welcome Messages + """ + success = await self.db.update_welcome_settings(ctx.guild.id, channel_id=channel.id) + self.invalidate_cache(ctx.guild.id) + + if success: + container = Container() + container.add_text( + f"{emoji_yes} Welcome Channel gesetzt" + ) + container.add_separator() + container.add_text( + f"Welcome Messages werden nun in {channel.mention} gesendet." + ) + view = discord.ui.View(container, timeout=None) + else: + container = Container() + container.add_text( + f"{emoji_no} Fehler" + ) + container.add_separator() + container.add_text( + "Der Welcome Channel konnte nicht gesetzt werden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="message", description="Setzt die Welcome Message über ein Modal") + @commands.has_permissions(manage_guild=True) + async def set_welcome_message(self, ctx): + """ + Öffnet ein Modal zum Setzen der Welcome Message. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Zeigt ein Modal mit der aktuellen Message als Vorausfüllung. + Bietet nach dem Speichern eine Vorschau der neuen Message. + """ + + # Aktuelle Einstellungen laden für Vorausfüllung + current_settings = await self.get_cached_settings(ctx.guild.id) + current_message = current_settings.get('welcome_message', '') if current_settings else '' + + class WelcomeMessageModal(discord.ui.Modal): + """ + Modal für Welcome Message Konfiguration. + + Parameters + ---------- + cog : WelcomeSystem + Parent Cog Instanz + current_msg : str, optional + Aktuelle Message für Vorausfüllung + """ + + def __init__(self, cog, current_msg=""): + super().__init__(title="Welcome Message konfigurieren") + self.cog = cog + + self.message_input = discord.ui.InputText( + label="Welcome Message", + placeholder="z.B: Willkommen %mention% auf **%servername%**! 🎉", + style=discord.InputTextStyle.long, + value=current_msg, + max_length=2000, + required=True + ) + self.add_item(self.message_input) + + async def callback(self, interaction: discord.Interaction): + """ + Callback nach Modal-Submit. + + Parameters + ---------- + interaction : discord.Interaction + Modal Interaction + """ + message = self.message_input.value.strip() + + if not message: + embed = discord.Embed( + title="❌ Fehler", + description="Die Welcome Message darf nicht leer sein.", + color=discord.Color.red() + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + success = await self.cog.db.update_welcome_settings(interaction.guild.id, welcome_message=message) + self.cog.invalidate_cache(interaction.guild.id) + + if success: + # Vorschau erstellen + preview = self.cog.replace_placeholders(message, interaction.user, interaction.guild) + + container = Container() + container.add_text( + "# ✅ Welcome Message gesetzt" + ) + container.add_separator() + container.add_text( + "## 💬 Neue Message\n\n" + f"```{message[:500]}{'...' if len(message) > 500 else ''}```" + ) + container.add_separator() + container.add_text( + "## 👀 Vorschau (mit deinen Daten)\n\n" + f"{preview[:500] + ("..." if len(preview) > 500 else "")}\n\n" + "-# 💡 Tipp: Verwende `/welcome test` für eine vollständige Vorschau oder `/welcome placeholders` für alle verfügbaren Optionen." + ) + view = discord.ui.View(container, timeout=None) + else: + container = Container() + container.add_text( + "# ❌ Fehler\nDie Welcome Message konnte nicht gesetzt werden." + ) + view = discord.ui.View(container, timeout=None) + await interaction.response.send_message(view=view) + + modal = WelcomeMessageModal(self, current_message) + await ctx.send_modal(modal) + + @welcome.command(name="toggle", description="Schaltet das Welcome System ein/aus") + @commands.has_permissions(manage_guild=True) + async def toggle_welcome(self, ctx): + """ + Schaltet das Welcome System ein oder aus. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + """ + new_state = await self.db.toggle_welcome(ctx.guild.id) + self.invalidate_cache(ctx.guild.id) + + if new_state is None: + container = Container() + container.add_text( + "# ❌ Fehler\nEs sind noch keine Welcome Einstellungen vorhanden. Setze zuerst einen Channel." + ) + view = discord.ui.View(container, timeout=None) + else: + status = "aktiviert" if new_state else "deaktiviert" + container = Container() + container.add_text( + f"# ✅ Welcome System {status}" + ) + container.add_separator() + container.add_text( + f"Das Welcome System wurde **{status}**." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="embed", description="Aktiviert/Deaktiviert Embed Modus") + @commands.has_permissions(manage_guild=True) + async def toggle_embed(self, ctx, enabled: bool): + """ + Aktiviert oder deaktiviert Embed Welcome Messages. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + enabled : bool + True für Embed-Modus, False für normale Nachrichten + """ + success = await self.db.update_welcome_settings(ctx.guild.id, embed_enabled=enabled) + self.invalidate_cache(ctx.guild.id) + + if success: + status = "aktiviert" if enabled else "deaktiviert" + container = Container( + f"# ✅ Embed Modus {status}" + ) + container.add_separator() + container.add_text( + f"Welcome Messages werden nun {'als Embed' if enabled else 'als normale Nachricht'} gesendet." + ) + view = discord.ui.View(container, timeout=None) + else: + container = Container() + container.add_text( + "# ❌ Fehler\nDer Embed Modus konnte nicht geändert werden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="autorole", description="Setzt eine Rolle die automatisch vergeben wird") + @commands.has_permissions(manage_roles=True) + async def set_auto_role(self, ctx, role: discord.Role = None): + """ + Setzt eine Rolle die bei Join automatisch vergeben wird. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + role : discord.Role, optional + Rolle zum automatischen Vergeben (None zum Entfernen) + + Notes + ----- + Prüft automatisch die Rollen-Hierarchie. + """ + if role is None: + # Auto-Role entfernen + success = await self.db.update_welcome_settings(ctx.guild.id, auto_role_id=None) + self.invalidate_cache(ctx.guild.id) + + container = Container() + container.add_text( + "# ✅ Auto-Role entfernt" + ) + container.add_separator() + container.add_text( + "Neue Mitglieder erhalten keine automatische Rolle mehr." + ) + view = discord.ui.View(container, timeout=None) + + else: + # Rolle validieren + if role >= ctx.guild.me.top_role: + container = Container() + container.add_text( + "# ❌ Fehler\nDiese Rolle ist höher als meine höchste Rolle. Ich kann sie nicht vergeben." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + return + + success = await self.db.update_welcome_settings(ctx.guild.id, auto_role_id=role.id) + self.invalidate_cache(ctx.guild.id) + + if success: + container = Container() + container.add_text( + "# ✅ Auto-Role gesetzt" + ) + container.add_separator() + container.add_text( + f"Neue Mitglieder erhalten automatisch die Rolle {role.mention}." + ) + view = discord.ui.View(container, timeout=None) + else: + container = Container() + container.add_text( + "# ❌ Fehler\nDie Auto-Role konnte nicht gesetzt werden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="dm", description="Aktiviert/Konfiguriert private Willkommensnachrichten") + @commands.has_permissions(manage_guild=True) + async def setup_join_dm(self, ctx, enabled: bool, *, message: str = None): + """ + Konfiguriert private Willkommensnachrichten. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + enabled : bool + True zum Aktivieren, False zum Deaktivieren + message : str, optional + Custom DM Message (verwendet Standard wenn nicht angegeben) + """ + settings = {'join_dm_enabled': enabled} + if message and enabled: + settings['join_dm_message'] = message + + success = await self.db.update_welcome_settings(ctx.guild.id, **settings) + self.invalidate_cache(ctx.guild.id) + + if success: + if enabled: + if message: + description = f"Private Welcome Messages aktiviert!\n**Nachricht:** {message[:500]}{'...' if len(message) > 500 else ''}" + else: + description = "Private Welcome Messages aktiviert! (Standard-Nachricht wird verwendet)" + else: + description = "Private Welcome Messages deaktiviert." + + container = Container() + container.add_text( + "# ✅ DM Einstellungen aktualisiert" + ) + container.add_separator() + container.add_text( + f"{description}" + ) + view = discord.ui.View(container, timeout=None) + else: + container = Container() + container.add_text( + "# ❌ Fehler\nDie DM Einstellungen konnten nicht aktualisiert werden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="template", description="Lädt eine Vorlage") + @commands.has_permissions(manage_guild=True) + async def load_template(self, ctx, template_name: str): + """ + Lädt eine vordefinierte Vorlage. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + template_name : str + Name der Vorlage (basic, fancy, minimal, detailed) + + Notes + ----- + Verfügbare Vorlagen: + - basic: Einfache Text-Nachricht + - fancy: Embed mit Thumbnail und Farbe + - minimal: Minimalistischer Text + - detailed: Detailliertes Embed mit vielen Infos + """ + templates = { + "basic": { + "welcome_message": "Willkommen %mention% auf **%servername%**! 🎉", + "embed_enabled": False, + "template_name": "basic" + }, + "fancy": { + "welcome_message": None, + "embed_enabled": True, + "embed_title": "Willkommen auf %servername%! 🎉", + "embed_description": "Hey %user%! Du bist unser **%membercount%.** Mitglied!\n\nViel Spaß auf unserem Server! 🚀", + "embed_color": "#ff6b6b", + "embed_thumbnail": True, + "embed_footer": "Beigetreten am %joindate%", + "template_name": "fancy" + }, + "minimal": { + "welcome_message": "%user% ist dem Server beigetreten.", + "embed_enabled": False, + "template_name": "minimal" + }, + "detailed": { + "welcome_message": None, + "embed_enabled": True, + "embed_title": "🎊 Neues Mitglied!", + "embed_description": "**%mention%** ist **%servername%** beigetreten!\n\n👤 **Username:** %username%\n📅 **Account erstellt:** %createddate%\n📊 **Mitglied Nr.:** %membercount%\n⏰ **Beigetreten um:** %jointime%", + "embed_color": "#00d4ff", + "embed_thumbnail": True, + "embed_footer": "%servername% • %membercount% Mitglieder", + "template_name": "detailed" + } + } + + if template_name not in templates: + available = ", ".join(templates.keys()) + embed = discord.Embed( + title="❌ Unbekannte Vorlage", + description=f"**Verfügbare Vorlagen:** {available}", + color=discord.Color.red() + ) + await ctx.respond(embed=embed) + return + + template = templates[template_name] + success = await self.db.update_welcome_settings(ctx.guild.id, **template) + self.invalidate_cache(ctx.guild.id) + + if success: + embed = discord.Embed( + title=f"✅ Vorlage '{template_name}' geladen", + description="Die Welcome-Konfiguration wurde aktualisiert.", + color=discord.Color.green() + ) + + # Vorschau anzeigen + if template_name == "basic": + embed.add_field(name="Vorschau", value="Willkommen @User auf **Servername**! 🎉", inline=False) + elif template_name == "minimal": + embed.add_field(name="Vorschau", value="Username ist dem Server beigetreten.", inline=False) + else: + embed.add_field(name="Typ", value="Embed-Nachricht", inline=False) + else: + embed = discord.Embed( + title="❌ Fehler", + description="Die Vorlage konnte nicht geladen werden.", + color=discord.Color.red() + ) + + await ctx.respond(embed=embed) + + @welcome.command(name="config", description="Zeigt die aktuelle Konfiguration") + @commands.has_permissions(manage_messages=True) + async def show_config(self, ctx): + """ + Zeigt die aktuelle Welcome Konfiguration. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Zeigt alle konfigurierten Einstellungen übersichtlich an. + """ + settings = await self.get_cached_settings(ctx.guild.id) + + if not settings: + container = Container() + container.add_text( + "# ❌ Keine Konfiguration gefunden\nEs sind noch keine Welcome Einstellungen vorhanden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + return + + channel = self.bot.get_channel(settings.get('channel_id')) if settings.get('channel_id') else None + auto_role = ctx.guild.get_role(settings.get('auto_role_id')) if settings.get('auto_role_id') else None + container = Container() + container.add_text( + "# ⚙️ Welcome System Konfiguration" + ) + container.add_separator() + container.add_text( + "## 📊 Status\n" + f"{'✅ Aktiviert' if settings.get('enabled') else '❌ Deaktiviert'}" + ) + + container.add_text( + "## 📢 Channel\n" + f"{channel.mention if channel else '❌ Nicht gesetzt'}" + ) + + container.add_text( + "## 🎨 Embed Modus\n" + f"{'✅ Aktiviert' if settings.get('embed_enabled') else '❌ Deaktiviert'}" + ) + + container.add_text( + "## 🏷️ Auto-Role\n" + f"{auto_role.mention if auto_role else '❌ Rolle nicht gefunden'}" + ) + + if settings.get('join_dm_enabled'): + container.add_text( + "## 💌 Private Nachricht\n✅ Aktiviert" + ) + + if settings.get('template_name'): + container.add_text( + "## 📋 Vorlage\n" + f"{settings.get('template_name').title()}" + ) + + message = settings.get('welcome_message', 'Nicht gesetzt') + if len(message) > 100: + message = message[:100] + "..." + container.add_text( + "## 💬 Welcome Message\n" + f"{message}" + ) + + if settings.get('delete_after', 0) > 0: + container.add_text( + "## 🗑️ Auto-Delete\n" + f"{settings.get('delete_after')} Sekunden" + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view) + + @welcome.command(name="test", description="Testet die Welcome Message") + @commands.has_permissions(manage_messages=True) + async def test_welcome(self, ctx): + """ + Testet die Welcome Message mit dem aktuellen User. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Simuliert einen Member Join mit den aktuellen Einstellungen. + Zeigt eine Vorschau ohne tatsächlich eine Welcome Message zu senden. + """ + settings = await self.get_cached_settings(ctx.guild.id) + + if not settings: + container = Container() + container.add_text( + "# ❌ Fehler\nEs sind noch keine Welcome Einstellungen vorhanden." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + return + + if not settings.get('channel_id'): + container = Container() + container.add_text( + "# ❌ Fehler\nEs ist kein Welcome Channel gesetzt." + ) + view = discord.ui.View(container, timeout=None) + await ctx.respond(view=view, ephemeral=True) + return + + # Simuliere Member Join Event + member = ctx.author + welcome_message = settings.get('welcome_message', 'Willkommen %mention% auf **%servername%**! 🎉') + processed_message = self.replace_placeholders(welcome_message, member, ctx.guild) + + embed = discord.Embed( + title="🧪 Welcome Message Test", + color=discord.Color.blue() + ) + + if settings.get('embed_enabled'): + embed.add_field( + name="Typ", + value="Embed-Nachricht", + inline=True + ) + + test_embed_title = settings.get('embed_title', 'Kein Titel') + if test_embed_title: + test_embed_title = self.replace_placeholders(test_embed_title, member, ctx.guild) + embed.add_field(name="Embed Titel", value=test_embed_title, inline=False) + + test_embed_desc = settings.get('embed_description', processed_message) + if test_embed_desc: + test_embed_desc = self.replace_placeholders(test_embed_desc, member, ctx.guild) + embed.add_field(name="Embed Beschreibung", value=test_embed_desc[:500] + ("..." if len(test_embed_desc) > 500 else ""), inline=False) + else: + embed.add_field( + name="Typ", + value="Normale Nachricht", + inline=True + ) + embed.add_field( + name="Vorschau", + value=processed_message[:500] + ("..." if len(processed_message) > 500 else ""), + inline=False + ) + + # Zusätzliche Infos + if settings.get('auto_role_id'): + auto_role = ctx.guild.get_role(settings.get('auto_role_id')) + embed.add_field( + name="🏷️ Auto-Role", + value=auto_role.mention if auto_role else "❌ Rolle nicht gefunden", + inline=True + ) + + if settings.get('join_dm_enabled'): + embed.add_field( + name="💌 Private Nachricht", + value="✅ Würde gesendet werden", + inline=True + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @welcome.command(name="placeholders", description="Zeigt alle verfügbaren Placeholder") + async def show_placeholders(self, ctx): + """ + Zeigt alle verfügbaren Placeholder. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Liste aller unterstützten Placeholder mit Beschreibungen. + """ + embed = discord.Embed( + title="📝 Verfügbare Placeholder", + description="Diese Placeholder können in Welcome Messages verwendet werden:", + color=discord.Color.blue() + ) + + embed.add_field( + name="👤 User Informationen", + value=( + "`%user%` - Username (Display Name)\n" + "`%username%` - Echter Username\n" + "`%mention%` - User erwähnen (@User)\n" + "`%tag%` - User#1234\n" + "`%userid%` - User ID\n" + "`%discriminator%` - User Discriminator" + ), + inline=False + ) + + embed.add_field( + name="🏠 Server Informationen", + value=( + "`%servername%` - Servername\n" + "`%server%` - Servername (Alternative)\n" + "`%guild%` - Servername (Alternative)\n" + "`%serverid%` - Server ID\n" + "`%membercount%` - Mitgliederanzahl\n" + "`%onlinemembers%` - Online Mitglieder" + ), + inline=False + ) + + embed.add_field( + name="⏰ Zeit & Datum", + value=( + "`%joindate%` - Beitrittsdatum (DD.MM.YYYY)\n" + "`%jointime%` - Beitrittszeit (HH:MM)\n" + "`%createddate%` - Account Erstellung (DD.MM.YYYY)\n" + "`%createdtime%` - Account Erstellung (HH:MM)\n" + "`%accountage%` - Account Alter in Tagen" + ), + inline=False + ) + + embed.add_field( + name="🎭 Erweiterte Informationen", + value=( + "`%roles%` - Alle Rollen (außer @everyone)\n" + "`%rolecount%` - Anzahl der Rollen\n" + "`%highestrole%` - Höchste Rolle\n" + "`%avatar%` - Avatar URL\n" + "`%defaultavatar%` - Standard Avatar URL" + ), + inline=False + ) + + embed.add_field( + name="📊 Server Statistiken", + value=( + "`%textchannels%` - Anzahl Textchannels\n" + "`%voicechannels%` - Anzahl Voicechannels\n" + "`%categories%` - Anzahl Kategorien\n" + "`%emojis%` - Anzahl Emojis" + ), + inline=False + ) + + embed.set_footer(text="Beispiel: Willkommen %mention%! Du bist Mitglied #%membercount% auf %servername%") + + await ctx.respond(embed=embed, ephemeral=True) + + @welcome.command(name="export", description="Exportiert die Welcome Konfiguration") + @commands.has_permissions(administrator=True) + async def export_config(self, ctx): + """ + Exportiert die aktuelle Konfiguration als JSON-Datei. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Erstellt eine JSON-Datei mit allen Einstellungen. + Sensible Daten (IDs, Timestamps) werden entfernt. + """ + settings = await self.get_cached_settings(ctx.guild.id) + if not settings: + embed = discord.Embed( + title="❌ Keine Konfiguration zum Exportieren", + description="Es sind noch keine Welcome Einstellungen vorhanden.", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + try: + # Sensible Daten entfernen + export_data = {k: v for k, v in settings.items() + if k not in ['guild_id', 'created_at', 'updated_at']} + + # JSON Export erstellen + config_json = json.dumps(export_data, indent=2, ensure_ascii=False) + + # Als Datei senden + file_content = f"# Welcome System Export für {ctx.guild.name}\n# Exportiert am {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n{config_json}" + file = discord.File( + io.StringIO(file_content), + filename=f"welcome_config_{ctx.guild.name.replace(' ', '_')}.json" + ) + + embed = discord.Embed( + title="📄 Konfiguration exportiert", + description="Die aktuelle Welcome-Konfiguration wurde als Datei exportiert.", + color=discord.Color.green() + ) + + await ctx.respond(embed=embed, file=file, ephemeral=True) + + except Exception as e: + logger.error(f"Export Fehler: {e}") + embed = discord.Embed( + title="❌ Export fehlgeschlagen", + description="Es ist ein Fehler beim Exportieren aufgetreten.", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @welcome.command(name="stats", description="Zeigt Welcome Statistiken") + @commands.has_permissions(manage_messages=True) + async def show_stats(self, ctx): + """ + Zeigt Welcome Statistiken für den Server. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Zeigt Statistiken für: + - Heute + - Diese Woche (letzte 7 Tage) + - Gesamt (seit Aktivierung) + """ + try: + await self.db.migrate_database() + + # Statistiken aktivieren falls noch nicht geschehen + settings = await self.get_cached_settings(ctx.guild.id) + if settings and not settings.get('welcome_stats_enabled'): + await self.db.update_welcome_settings(ctx.guild.id, welcome_stats_enabled=True) + self.invalidate_cache(ctx.guild.id) + + # Aktuelle Statistiken aus der DB holen + try: + async with aiosqlite.connect(self.db.db_path) as conn: + # Heute + today = datetime.now().strftime('%Y-%m-%d') + cursor = await conn.execute( + 'SELECT joins, leaves FROM welcome_stats WHERE guild_id = ? AND date = ?', + (ctx.guild.id, today) + ) + today_stats = await cursor.fetchone() + + # Letzte 7 Tage + cursor = await conn.execute(''' + SELECT SUM(joins) as total_joins, SUM(leaves) as total_leaves + FROM welcome_stats + WHERE guild_id = ? AND date >= date('now', '-7 days') + ''', (ctx.guild.id,)) + week_stats = await cursor.fetchone() + + # Gesamt + cursor = await conn.execute(''' + SELECT SUM(joins) as total_joins, SUM(leaves) as total_leaves + FROM welcome_stats + WHERE guild_id = ? + ''', (ctx.guild.id,)) + total_stats = await cursor.fetchone() + + embed = discord.Embed( + title="📊 Welcome Statistiken", + description=f"Statistiken für **{ctx.guild.name}**", + color=discord.Color.blue() + ) + + # Heute + today_joins = today_stats[0] if today_stats else 0 + today_leaves = today_stats[1] if today_stats else 0 + embed.add_field( + name="📅 Heute", + value=f"👋 **Beigetreten:** {today_joins}\n🚪 **Verlassen:** {today_leaves}", + inline=True + ) + + # Diese Woche + week_joins = week_stats[0] if week_stats and week_stats[0] else 0 + week_leaves = week_stats[1] if week_stats and week_stats[1] else 0 + embed.add_field( + name="📅 Diese Woche", + value=f"👋 **Beigetreten:** {week_joins}\n🚪 **Verlassen:** {week_leaves}", + inline=True + ) + + # Gesamt + total_joins = total_stats[0] if total_stats and total_stats[0] else 0 + total_leaves = total_stats[1] if total_stats and total_stats[1] else 0 + embed.add_field( + name="📊 Gesamt", + value=f"👋 **Beigetreten:** {total_joins}\n🚪 **Verlassen:** {total_leaves}", + inline=True + ) + + # Aktuelle Server Info + embed.add_field( + name="ℹ️ Server Info", + value=f"👥 **Aktuelle Mitglieder:** {ctx.guild.member_count}\n📈 **Netto Wachstum:** {total_joins - total_leaves}", + inline=False + ) + + embed.set_footer(text="Statistiken werden seit der Aktivierung des Systems gesammelt") + + except Exception as e: + logger.error(f"Stats DB Error: {e}") + embed = discord.Embed( + title="📊 Welcome Statistiken", + description="Statistiken werden ab sofort gesammelt und beim nächsten Aufruf angezeigt.", + color=discord.Color.blue() + ) + embed.add_field( + name="ℹ️ Server Info", + value=f"👥 **Aktuelle Mitglieder:** {ctx.guild.member_count}", + inline=False + ) + + await ctx.respond(embed=embed) + + except Exception as e: + logger.error(f"Stats Command Error: {e}") + embed = discord.Embed( + title="❌ Fehler", + description="Statistiken konnten nicht geladen werden.", + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @welcome.command(name="reset", description="Setzt alle Welcome Einstellungen zurück") + @commands.has_permissions(administrator=True) + async def reset_welcome(self, ctx): + """ + Setzt alle Welcome Einstellungen zurück. + + Parameters + ---------- + ctx : discord.ApplicationContext + Slash Command Context + + Notes + ----- + Zeigt eine Bestätigungsabfrage vor dem Löschen. + Diese Aktion kann nicht rückgängig gemacht werden. + """ + + # Bestätigungs-View + class ConfirmView(discord.ui.View): + """ + Bestätigungs-View für Reset. + + Attributes + ---------- + confirmed : bool + Ob der Reset bestätigt wurde + """ + + def __init__(self): + super().__init__(timeout=30) + self.confirmed = False + + @discord.ui.button(label="✅ Ja, zurücksetzen", style=discord.ButtonStyle.danger) + async def confirm_button(self, button: discord.ui.Button, interaction: discord.Interaction): + """ + Bestätigung des Resets. + + Parameters + ---------- + button : discord.ui.Button + Geklickter Button + interaction : discord.Interaction + Button Interaction + """ + self.confirmed = True + self.stop() + + success = await ctx.cog.db.delete_welcome_settings(ctx.guild.id) + ctx.cog.invalidate_cache(ctx.guild.id) + + if success: + embed = discord.Embed( + title="✅ Einstellungen zurückgesetzt", + description="Alle Welcome Einstellungen wurden erfolgreich gelöscht.", + color=discord.Color.green() + ) + else: + embed = discord.Embed( + title="❌ Fehler", + description="Die Einstellungen konnten nicht zurückgesetzt werden.", + color=discord.Color.red() + ) + + await interaction.response.edit_message(embed=embed, view=None) + + @discord.ui.button(label="❌ Abbrechen", style=discord.ButtonStyle.secondary) + async def cancel_button(self, button: discord.ui.Button, interaction: discord.Interaction): + """ + Abbruch des Resets. + + Parameters + ---------- + button : discord.ui.Button + Geklickter Button + interaction : discord.Interaction + Button Interaction + """ + self.stop() + + embed = discord.Embed( + title="❌ Abgebrochen", + description="Die Einstellungen wurden nicht zurückgesetzt.", + color=discord.Color.orange() + ) + + await interaction.response.edit_message(embed=embed, view=None) + + embed = discord.Embed( + title="⚠️ Einstellungen zurücksetzen", + description="Bist du sicher, dass du **alle** Welcome Einstellungen löschen möchtest?\n\n**Diese Aktion kann nicht rückgängig gemacht werden!**", + color=discord.Color.orange() + ) + + view = ConfirmView() + await ctx.respond(embed=embed, view=view, ephemeral=True) + + # Event Listeners für Statistiken + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + """ + Tracking für Member Leaves. + + Parameters + ---------- + member : discord.Member + Mitglied das den Server verlassen hat + + Notes + ----- + Aktualisiert die Statistiken wenn aktiviert. + """ + try: + settings = await self.get_cached_settings(member.guild.id) + if settings and settings.get('welcome_stats_enabled'): + await self.db.update_welcome_stats(member.guild.id, leaves=1) + except Exception as e: + logger.error(f"Leave Stats Error: {e}") + + +def setup(bot): + """ + Setup-Funktion für das Cog. + + Parameters + ---------- + bot : ezcord.Bot + Bot-Instanz + + Notes + ----- + Wird automatisch von discord.py beim Laden des Cogs aufgerufen. + """ + bot.add_cog(WelcomeSystem(bot)) \ No newline at end of file diff --git a/src/bot/cogs/management/autodelete.py b/src/bot/cogs/management/autodelete.py new file mode 100644 index 0000000..9ba365c --- /dev/null +++ b/src/bot/cogs/management/autodelete.py @@ -0,0 +1,309 @@ +from DevTools import AutoDeleteDB +import discord +from discord.ext import tasks +from discord.commands import SlashCommandGroup, Option +import ezcord +import asyncio +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +class AutoDelete(ezcord.Cog): + def __init__(self, bot): + self.bot = bot + self.delete_task.start() + self.processing_channels = set() # Verhindert doppelte Verarbeitung + + autodelete = SlashCommandGroup("autodelete", "Automatische Nachrichtenlöschung") + + @autodelete.command(name="setup", description="Richtet AutoDelete für einen Kanal ein.") + async def setup(self, ctx, + channel: Option(discord.TextChannel, "Kanal", required=True), + duration: Option(int, "Zeit in Sekunden (min: 60, max: 604800)", required=True), + exclude_pinned: Option(bool, "Angepinnte Nachrichten ausschließen", default=True), + exclude_bots: Option(bool, "Bot-Nachrichten ausschließen", default=False)): + + # Validierung + if duration < 60: + await ctx.respond("❌ Mindestdauer ist 60 Sekunden (1 Minute).", ephemeral=True) + return + if duration > 604800: + await ctx.respond("❌ Maximaldauer ist 604800 Sekunden (7 Tage).", ephemeral=True) + return + + # Permissions prüfen + if not channel.permissions_for(ctx.guild.me).manage_messages: + await ctx.respond("❌ Ich habe keine Berechtigung, Nachrichten in diesem Kanal zu löschen.", ephemeral=True) + return + + db = AutoDeleteDB() + db.add_autodelete(channel.id, duration, exclude_pinned, exclude_bots) + + duration_str = self._format_duration(duration) + await ctx.respond( + f"✅ AutoDelete für {channel.mention} wurde aktiviert!\n" + f"📅 Dauer: {duration_str}\n" + f"📌 Angepinnte Nachrichten: {'Ausgeschlossen' if exclude_pinned else 'Eingeschlossen'}\n" + f"🤖 Bot-Nachrichten: {'Ausgeschlossen' if exclude_bots else 'Eingeschlossen'}", + ephemeral=True + ) + + @autodelete.command(name="list", description="Zeigt alle aktiven AutoDelete-Kanäle.") + async def list(self, ctx): + db = AutoDeleteDB() + channels = db.get_all() + if not channels: + await ctx.respond("❌ Keine AutoDelete-Kanäle gefunden.", ephemeral=True) + return + + embed = discord.Embed( + title="🗑️ Aktive AutoDelete-Kanäle", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + + for chan_id, duration, exclude_pinned, exclude_bots in channels: + channel = self.bot.get_channel(chan_id) + if channel: + duration_str = self._format_duration(duration) + settings = [] + if exclude_pinned: + settings.append("📌 Angepinnte ausgeschlossen") + if exclude_bots: + settings.append("🤖 Bots ausgeschlossen") + + settings_str = "\n".join(settings) if settings else "Keine besonderen Einstellungen" + + embed.add_field( + name=f"#{channel.name}", + value=f"⏱️ {duration_str}\n{settings_str}", + inline=True + ) + else: + embed.add_field( + name="❌ Unbekannter Kanal", + value=f"ID: {chan_id}\n⏱️ {self._format_duration(duration)}", + inline=True + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @autodelete.command(name="remove", description="Entfernt AutoDelete von einem Kanal.") + async def remove(self, ctx, + channel: Option(discord.TextChannel, "Kanal", required=True)): + db = AutoDeleteDB() + if db.get_autodelete(channel.id): + db.remove_autodelete(channel.id) + await ctx.respond(f"🗑️ AutoDelete für {channel.mention} wurde entfernt.", ephemeral=True) + else: + await ctx.respond(f"❌ AutoDelete war für {channel.mention} nicht aktiviert.", ephemeral=True) + + @autodelete.command(name="stats", description="Zeigt Statistiken für einen AutoDelete-Kanal.") + async def stats(self, ctx, + channel: Option(discord.TextChannel, "Kanal", required=True)): + db = AutoDeleteDB() + config = db.get_autodelete_full(channel.id) + if not config: + await ctx.respond(f"❌ AutoDelete ist für {channel.mention} nicht aktiviert.", ephemeral=True) + return + + duration, exclude_pinned, exclude_bots = config + stats = db.get_stats(channel.id) + + embed = discord.Embed( + title=f"📊 AutoDelete Statistiken - #{channel.name}", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + + embed.add_field(name="⏱️ Löschzeit", value=self._format_duration(duration), inline=True) + embed.add_field(name="📌 Angepinnte", value="Ausgeschlossen" if exclude_pinned else "Eingeschlossen", + inline=True) + embed.add_field(name="🤖 Bots", value="Ausgeschlossen" if exclude_bots else "Eingeschlossen", inline=True) + + if stats: + embed.add_field(name="🗑️ Gelöschte Nachrichten", value=str(stats['deleted_count']), inline=True) + embed.add_field(name="❌ Fehler", value=str(stats['error_count']), inline=True) + if stats['last_deletion']: + embed.add_field(name="🕒 Letzte Löschung", value=f"", inline=True) + + await ctx.respond(embed=embed, ephemeral=True) + + @autodelete.command(name="test", description="Testet die AutoDelete-Funktion für einen Kanal.") + async def test(self, ctx, + channel: Option(discord.TextChannel, "Kanal", required=True)): + db = AutoDeleteDB() + config = db.get_autodelete_full(channel.id) + if not config: + await ctx.respond(f"❌ AutoDelete ist für {channel.mention} nicht aktiviert.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + + try: + deleted_count = await self._process_channel_deletion(channel.id, test_mode=True) + await ctx.followup.send( + f"✅ Test erfolgreich!\n" + f"📝 {deleted_count} Nachrichten würden gelöscht werden.", + ephemeral=True + ) + except Exception as e: + await ctx.followup.send(f"❌ Test fehlgeschlagen: {str(e)}", ephemeral=True) + + @tasks.loop(seconds=30) # Erhöht auf 30 Sekunden für bessere Performance + async def delete_task(self): + try: + db = AutoDeleteDB() + channels = db.get_all() + + # Verarbeite Kanäle parallel, aber begrenzt + semaphore = asyncio.Semaphore(3) # Max 3 Kanäle gleichzeitig + tasks = [] + + for chan_id, duration, exclude_pinned, exclude_bots in channels: + if chan_id not in self.processing_channels: + task = self._process_channel_with_semaphore(semaphore, chan_id) + tasks.append(task) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + except Exception as e: + logger.error(f"Fehler im delete_task: {e}") + + async def _process_channel_with_semaphore(self, semaphore, channel_id): + async with semaphore: + await self._process_channel_deletion(channel_id) + + async def _process_channel_deletion(self, channel_id, test_mode=False): + if channel_id in self.processing_channels and not test_mode: + return 0 + + if not test_mode: + self.processing_channels.add(channel_id) + + try: + db = AutoDeleteDB() + config = db.get_autodelete_full(channel_id) + if not config: + return 0 + + duration, exclude_pinned, exclude_bots = config + + # Zeitplan-Prüfung + if not self._is_in_schedule(channel_id): + return 0 + + channel = self.bot.get_channel(channel_id) + if not channel: + return 0 + + deleted_count = 0 + error_count = 0 + cutoff_time = datetime.utcnow() - timedelta(seconds=duration) + + try: + messages_to_delete = [] + async for msg in channel.history(limit=200, oldest_first=True): + if msg.created_at >= cutoff_time: + break + + # Filterlogik + if exclude_pinned and msg.pinned: + continue + if exclude_bots and msg.author.bot: + continue + + # Whitelist-Prüfung + if self._check_whitelist(msg, channel_id): + continue + + messages_to_delete.append(msg) + + # Batch-Löschung für bessere Performance + if len(messages_to_delete) >= 10: + if not test_mode: + deleted, errors = await self._bulk_delete_messages(channel, messages_to_delete) + deleted_count += deleted + error_count += errors + else: + deleted_count += len(messages_to_delete) + messages_to_delete.clear() + + # Restliche Nachrichten löschen + if messages_to_delete: + if not test_mode: + deleted, errors = await self._bulk_delete_messages(channel, messages_to_delete) + deleted_count += deleted + error_count += errors + else: + deleted_count += len(messages_to_delete) + + # Statistiken aktualisieren + if not test_mode and (deleted_count > 0 or error_count > 0): + db.update_stats(channel_id, deleted_count, error_count) + + except discord.errors.Forbidden: + logger.warning(f"Keine Berechtigung für Kanal {channel_id}") + except Exception as e: + logger.error(f"Fehler beim Verarbeiten von Kanal {channel_id}: {e}") + if not test_mode: + db.update_stats(channel_id, 0, 1) + + return deleted_count + + finally: + if not test_mode: + self.processing_channels.discard(channel_id) + + async def _bulk_delete_messages(self, channel, messages): + deleted_count = 0 + error_count = 0 + + # Trenne alte und neue Nachrichten (Discord API Limitation) + old_messages = [] + new_messages = [] + two_weeks_ago = datetime.utcnow() - timedelta(days=14) + + for msg in messages: + if msg.created_at < two_weeks_ago: + old_messages.append(msg) + else: + new_messages.append(msg) + + # Bulk delete für neue Nachrichten + if new_messages: + try: + await channel.delete_messages(new_messages) + deleted_count += len(new_messages) + except Exception as e: + logger.error(f"Bulk delete Fehler: {e}") + + return deleted_count, error_count + + # Platzhalter für fehlende Methoden, um den Code lauffähig zu machen + def _format_duration(self, duration: int) -> str: + """Formatiert die Dauer in eine lesbare Zeichenkette (z.B. '1 Stunde').""" + if duration >= 86400 and duration % 86400 == 0: + return f"{duration // 86400} Tage" + if duration >= 3600 and duration % 3600 == 0: + return f"{duration // 3600} Stunden" + if duration >= 60 and duration % 60 == 0: + return f"{duration // 60} Minuten" + return f"{duration} Sekunden" + + def _is_in_schedule(self, channel_id: int) -> bool: + """Platzhalter: Prüft, ob der Kanal gerade gelöscht werden soll (immer True im Platzhalter).""" + # Da diese Methode in Ihrem Originalcode nicht definiert ist, aber aufgerufen wird, + # muss sie entweder in der DB/Config abrufbar sein oder als Platzhalter existieren. + # Wir lassen sie hier True zurückgeben, um die Löschlogik nicht zu blockieren. + return True + + def _check_whitelist(self, message: discord.Message, channel_id: int) -> bool: + """Platzhalter: Prüft, ob die Nachricht von der Löschung ausgenommen ist (immer False im Platzhalter).""" + return False + +def setup(bot): + bot.add_cog(AutoDelete(bot)) \ No newline at end of file diff --git a/src/bot/cogs/management/autorole.py b/src/bot/cogs/management/autorole.py new file mode 100644 index 0000000..3e3613c --- /dev/null +++ b/src/bot/cogs/management/autorole.py @@ -0,0 +1,273 @@ +import discord +from discord.ext import commands +from discord import option +from DevTools import AutoRoleDatabase +from mx_handler import TranslationHandler as TH + +class AutoRole(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.db = AutoRoleDatabase() + + async def cog_load(self): + """Wird aufgerufen, wenn der Cog geladen wird""" + await self.db.init_db() + + autorole = discord.SlashCommandGroup( + name="autorole", + description="Verwalte das Autorole-System", + default_member_permissions=discord.Permissions(administrator=True) + ) + + @autorole.command(name="add", description="Füge eine neue Autorole hinzu") + @option( + name="rolle", + description="Die Rolle, die vergeben werden soll", + type=discord.Role, + required=True + ) + async def autorole_add(self, ctx: discord.ApplicationContext, rolle: discord.Role): + """Fügt eine neue Autorole hinzu""" + + # Prüfe, ob der Bot die Rolle vergeben kann + if rolle.position >= ctx.guild.me.top_role.position: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_to_high.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_to_high.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if rolle.managed: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_managed.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_managed.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + # Füge die Autorole hinzu + autorole_id = await self.db.add_autorole(ctx.guild.id, rolle.id) + + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.add_success.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.add_success.desc", role=rolle.mention, autorole_id=autorole_id), + color=discord.Color.green() + ) + await ctx.respond(embed=embed) + + @autorole.command(name="remove", description="Entferne eine Autorole") + @option( + name="autorole_id", + description="Die ID der Autorole (z.B. 26-25-153)", + type=str, + required=True + ) + async def autorole_remove(self, ctx: discord.ApplicationContext, autorole_id: str): + """Entfernt eine Autorole anhand der ID""" + + config = await self.db.get_autorole(autorole_id) + + if not config: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.desc", autorole_id=autorole_id), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if config["guild_id"] != ctx.guild.id: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + await self.db.remove_autorole(autorole_id) + + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.remove_success.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.remove_success.desc", autorole_id=autorole_id), + color=discord.Color.green() + ) + await ctx.respond(embed=embed) + + @autorole.command(name="toggle", description="Aktiviere oder deaktiviere eine Autorole") + @option( + name="autorole_id", + description="Die ID der Autorole (z.B. 26-25-153)", + type=str, + required=True + ) + @option( + name="status", + description="Status der Autorole", + type=str, + choices=["aktivieren", "deaktivieren"], + required=True + ) + async def autorole_toggle(self, ctx: discord.ApplicationContext, autorole_id: str, status: str): + """Aktiviert oder deaktiviert eine Autorole""" + + config = await self.db.get_autorole(autorole_id) + + if not config: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.desc", autorole_id=autorole_id), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if config["guild_id"] != ctx.guild.id: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + enabled = status == "aktivieren" + await self.db.toggle_autorole(autorole_id, enabled) + + status_text = "enabled" if enabled else "disabled" + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, f"cog_autorole.messages.toggle_success.{status_text}_title"), + description=await TH.get_for_user(self.bot, ctx.author.id, f"cog_autorole.messages.toggle_success.{status_text}_desc", autorole_id=autorole_id), + color=discord.Color.green() + ) + await ctx.respond(embed=embed) + + @autorole.command(name="list", description="Zeige alle Autoroles auf diesem Server") + async def autorole_list(self, ctx: discord.ApplicationContext): + """Zeigt alle Autoroles für den Server""" + + autoroles = await self.db.get_all_autoroles(ctx.guild.id) + + if not autoroles: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.no_roles.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.no_roles.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.list.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.list.desc", guild_name=ctx.guild.name), + color=discord.Color.blue() + ) + + for ar in autoroles: + role = ctx.guild.get_role(ar["role_id"]) + if role: + status = "🟢 Aktiv" if ar["enabled"] else "🔴 Inaktiv" + embed.add_field( + name=f"ID: `{ar['autorole_id']}`", + value=f"**Rolle:** {role.mention}\n**Status:** {status}\n**Mitglieder:** {len(role.members)}", + inline=False + ) + else: + embed.add_field( + name=f"ID: `{ar['autorole_id']}`", + value=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.list.role_deleted"), + inline=False + ) + + await ctx.respond(embed=embed) + + @autorole.command(name="info", description="Zeige Details zu einer spezifischen Autorole") + @option( + name="autorole_id", + description="Die ID der Autorole (z.B. 26-25-153)", + type=str, + required=True + ) + async def autorole_info(self, ctx: discord.ApplicationContext, autorole_id: str): + """Zeigt Details zu einer spezifischen Autorole""" + + config = await self.db.get_autorole(autorole_id) + + if not config: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.not_found.desc", autorole_id=autorole_id), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + if config["guild_id"] != ctx.guild.id: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.wrong_guild.desc"), + color=discord.Color.red() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + role = ctx.guild.get_role(config["role_id"]) + + if not role: + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_deleted.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.error_types.role_deleted.desc", autorole_id=autorole_id), + color=discord.Color.orange() + ) + await ctx.respond(embed=embed, ephemeral=True) + return + + embed = discord.Embed( + title=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.info.title"), + description=await TH.get_for_user(self.bot, ctx.author.id, "cog_autorole.messages.info.desc", autorole_id=autorole_id), + color=discord.Color.blue() + ) + embed.add_field(name="Rolle", value=role.mention, inline=True) + embed.add_field(name="Status", value="🟢 Aktiviert" if config["enabled"] else "🔴 Deaktiviert", inline=True) + embed.add_field(name="Mitglieder mit dieser Rolle", value=str(len(role.members)), inline=True) + embed.add_field(name="Rollen-ID", value=f"`{role.id}`", inline=True) + embed.add_field(name="Autorole-ID", value=f"`{autorole_id}`", inline=True) + + await ctx.respond(embed=embed) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """Event: Wird ausgelöst, wenn ein neues Mitglied dem Server beitritt""" + + role_ids = await self.db.get_enabled_autoroles(member.guild.id) + + if not role_ids: + return + + roles_to_add = [] + + for role_id in role_ids: + role = member.guild.get_role(role_id) + if role and role.position < member.guild.me.top_role.position: + roles_to_add.append(role) + + if not roles_to_add: + return + + try: + audit_reason = TH.get("de", "cog_autorole.system.audit_reason") + await member.add_roles(*roles_to_add, reason=audit_reason) + + role_names = ", ".join([r.name for r in roles_to_add]) + log_msg = TH.get("de", "cog_autorole.system.console_log", role_names=role_names, member_name=member.name) + print(log_msg) + except discord.Forbidden: + print(TH.get("de", "cog_autorole.system.error_forbidden")) + except discord.HTTPException as e: + print(TH.get("de", "cog_autorole.system.error_http", error=str(e))) + +def setup(bot): + bot.add_cog(AutoRole(bot)) \ No newline at end of file diff --git a/src/bot/cogs/moderation/antispam.py b/src/bot/cogs/moderation/antispam.py new file mode 100644 index 0000000..5bdce7f --- /dev/null +++ b/src/bot/cogs/moderation/antispam.py @@ -0,0 +1,435 @@ +# Copyright (c) 2025 OPPRO.NET Network +from collections import defaultdict +import asyncio +import discord +from discord import SlashCommandGroup +import ezcord +import datetime +from datetime import timedelta + + +from DevTools import AntiSpamDatabase as SpamDB + +antispam = SlashCommandGroup("antispam") +class AntiSpam(ezcord.Cog): + + def __init__(self, bot: ezcord.Bot): + self.bot = bot + self.db = SpamDB() + # Track user message timestamps per guild + self.user_messages = defaultdict(lambda: defaultdict(list)) + # Track users currently in timeout to prevent duplicate actions + self.users_in_timeout = set() + + @ezcord.Cog.listener() + async def on_message(self, message): + """Monitor messages for spam detection.""" + # Ignore bot messages and DMs + if message.author.bot or not message.guild: + return + + # Check if user is whitelisted + if self.is_whitelisted(message.guild.id, message.author.id): + return + + # Get spam settings for this guild + settings = self.db.get_spam_settings(message.guild.id) + if not settings: + # If no settings are configured, don't process spam detection + return + + # Check if log channel is set + if not settings.get('log_channel_id'): + return + + # Record this message timestamp + user_id = message.author.id + guild_id = message.guild.id + current_time = datetime.now() + + # Add current message to tracking + self.user_messages[guild_id][user_id].append(current_time) + + # Clean old messages outside the time frame + time_threshold = current_time - timedelta(seconds=settings['time_frame']) + self.user_messages[guild_id][user_id] = [ + timestamp for timestamp in self.user_messages[guild_id][user_id] + if timestamp > time_threshold + ] + + # Check if user exceeded message limit + message_count = len(self.user_messages[guild_id][user_id]) + if message_count > settings['max_messages']: + await self.handle_spam_violation(message, settings) + + async def handle_spam_violation(self, message, settings): + """Handle a user who violated spam limits.""" + user = message.author + guild = message.guild + + # Prevent duplicate actions for the same user + user_timeout_key = f"{guild.id}_{user.id}" + if user_timeout_key in self.users_in_timeout: + return + + self.users_in_timeout.add(user_timeout_key) + + try: + # Log the spam incident + self.db.log_spam(guild.id, user.id, message.content[:100]) # Limit message length + + # Delete recent messages from this user + await self.delete_recent_messages(message.channel, user, limit=settings['max_messages']) + + # Apply timeout (5 minutes) + timeout_duration = timedelta(minutes=5) + timeout_applied = False + + try: + await user.timeout_for(timeout_duration, reason="Anti-Spam: Zu viele Nachrichten") + timeout_applied = True + except discord.Forbidden: + pass # Continue to log even if timeout fails + + # Send log to designated channel + await self.send_spam_log(guild, user, message, settings, timeout_applied) + + # Send warning message in channel + embed = discord.Embed( + title=f"{emoji_forbidden} × Anti-Spam Warnung", + description=f"{user.mention} wurde wegen zu vieler Nachrichten {'stumm geschaltet' if timeout_applied else 'verwarnt'}.", + color=ERROR_COLOR + ) + embed.add_field( + name="Limit überschritten", + value=f"Maximal {settings['max_messages']} Nachrichten in {settings['time_frame']} Sekunden erlaubt", + inline=False + ) + await message.channel.send(embed=embed, delete_after=10) + + # Clear user's message tracking after violation + if guild.id in self.user_messages and user.id in self.user_messages[guild.id]: + self.user_messages[guild_id][user.id].clear() + + # Remove from timeout tracking after delay + await asyncio.sleep(300) # 5 minutes + self.users_in_timeout.discard(user_timeout_key) + + except Exception as e: + print(f"Error handling spam violation: {e}") + self.users_in_timeout.discard(user_timeout_key) + + async def send_spam_log(self, guild, user, message, settings, timeout_applied): + """Send spam log to designated log channel.""" + log_channel_id = settings.get('log_channel_id') + if not log_channel_id: + return + + log_channel = guild.get_channel(log_channel_id) + if not log_channel: + return + + try: + embed = discord.Embed( + title=f"{emoji_warn} × Anti-Spam Verstoß", + color=discord.Color.red(), + timestamp=datetime.now() + ) + embed.add_field( + name=f"{emoji_member} × Benutzer", + value=f"{user.mention} ({user.id})", + inline=True + ) + embed.add_field( + name=f"{emoji_channel} × Kanal", + value=message.channel.mention, + inline=True + ) + embed.add_field( + name=f"{emoji_moderator} × Aktion", + value="Timeout (5 Min)" if timeout_applied else "Warnung", + inline=True + ) + embed.add_field( + name=f"{emoji_statistics} × Limit", + value=f"{settings['max_messages']} Nachrichten in {settings['time_frame']}s", + inline=True + ) + embed.add_field( + name=f"{emoji_annoattention} × Nachricht (Vorschau)", + value=f"```{message.content[:100]}{'...' if len(message.content) > 100 else ''}```", + inline=False + ) + embed.set_footer(text=f"User ID: {user.id}") + embed.set_thumbnail(url=user.display_avatar.url) + + await log_channel.send(embed=embed) + except Exception as e: + print(f"Error sending spam log: {e}") + + async def delete_recent_messages(self, channel, user, limit=5): + """Delete recent messages from a user.""" + try: + messages_to_delete = [] + async for msg in channel.history(limit=50): # Check last 50 messages + if msg.author == user and len(messages_to_delete) < limit: + messages_to_delete.append(msg) + if len(messages_to_delete) >= limit: + break + + for msg in messages_to_delete: + try: + await msg.delete() + except discord.NotFound: + pass # Message already deleted + except discord.Forbidden: + break # No permission to delete + + except Exception as e: + print(f"Error deleting messages: {e}") + + @antispam.command(name="setup", description="Richte das Anti-Spam-System ein.") + async def setup_antispam(self, ctx, log_channel: discord.TextChannel, max_messages: int = 5, time_frame: int = 10): + """Richte das Anti-Spam-System mit einem Log-Channel ein.""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond(f"{emoji_no} × Du benötigst die 'Server verwalten' Berechtigung für diesen Befehl.", ephemeral=True) + return + + if max_messages < 1 or max_messages > 50: + await ctx.respond(f"{emoji_no} × Maximale Nachrichten müssen zwischen 1 und 50 liegen.", ephemeral=True) + return + + if time_frame < 1 or time_frame > 300: + await ctx.respond(f"{emoji_no} × Zeitrahmen muss zwischen 1 und 300 Sekunden liegen.", ephemeral=True) + return + + # Check if bot can send messages to log channel + if not log_channel.permissions_for(ctx.guild.me).send_messages: + await ctx.respond(f"{emoji_no} × Ich habe keine Berechtigung, Nachrichten in den angegebenen Log-Channel zu senden.", + ephemeral=True) + return + + self.db.set_spam_settings(ctx.guild.id, max_messages, time_frame, log_channel.id) + + embed = discord.Embed( + title=f"{emoji_yes} × Anti-Spam-System eingerichtet", + color=discord.Color.green() + ) + embed.add_field( + name=f"{emoji_channel} × Log-Channel", + value=log_channel.mention, + inline=True + ) + embed.add_field( + name=f"{emoji_annoattention} × Nachrichtenlimit", + value=f"{max_messages} Nachrichten", + inline=True + ) + embed.add_field( + name=f"{emoji_statistics} × Zeitrahmen", + value=f"{time_frame} Sekunden", + inline=True + ) + embed.add_field( + name=f"{emoji_owner} × Status", + value="🟢 Aktiv", + inline=False + ) + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="set", description="Ändere Anti-Spam-Parameter.") + async def set_parameters(self, ctx, max_messages: int = None, time_frame: int = None): + """Ändere die Anti-Spam-Parameter (Log-Channel bleibt unverändert).""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond(f"{emoji_no} × Du benötigst die 'Server verwalten' Berechtigung für diesen Befehl.", ephemeral=True) + return + + # Get current settings + current_settings = self.db.get_spam_settings(ctx.guild.id) + if not current_settings: + await ctx.respond(f"{emoji_no} × Anti-Spam-System wurde noch nicht eingerichtet. Verwende `/antispam setup` zuerst.", + ephemeral=True) + return + + # Use current values if not provided + new_max_messages = max_messages if max_messages is not None else current_settings['max_messages'] + new_time_frame = time_frame if time_frame is not None else current_settings['time_frame'] + + if new_max_messages < 5 or new_max_messages > 50: + await ctx.respond(f"{emoji_no} × Maximale Nachrichten müssen zwischen 5 und 50 liegen.", ephemeral=True) + return + + if new_time_frame < 5 or new_time_frame > 300: + await ctx.respond(f"{emoji_no} × Zeitrahmen muss zwischen 5 und 300 Sekunden liegen.", ephemeral=True) + return + + self.db.set_spam_settings(ctx.guild.id, new_max_messages, new_time_frame, current_settings['log_channel_id']) + + embed = discord.Embed( + title=f"{emoji_owner} × Anti-Spam Einstellungen aktualisiert", + description=f"Maximal **{new_max_messages}** Nachrichten in **{new_time_frame}** Sekunden erlaubt.", + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="log-channel", description="Ändere den Log-Channel.") + async def set_log_channel(self, ctx, log_channel: discord.TextChannel): + """Ändere den Log-Channel für Anti-Spam.""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond(f"{emoji_no} × Du benötigst die 'Server verwalten' Berechtigung für diesen Befehl.", ephemeral=True) + return + + # Check if bot can send messages to log channel + if not log_channel.permissions_for(ctx.guild.me).send_messages: + await ctx.respond(f"{emoji_no} × Ich habe keine Berechtigung, Nachrichten in den angegebenen Log-Channel zu senden.", + ephemeral=True) + return + + self.db.set_log_channel(ctx.guild.id, log_channel.id) + + embed = discord.Embed( + title=f"{emoji_owner} × Log-Channel aktualisiert", + description=f"Anti-Spam-Logs werden nun in {log_channel.mention} gesendet.", + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="view", description="Zeige aktuelle Anti-Spam-Einstellungen an.") + async def view_settings(self, ctx): + """Zeigt die aktuellen Anti-Spam-Einstellungen an.""" + settings = self.db.get_spam_settings(ctx.guild.id) + + if settings and settings.get('log_channel_id'): + log_channel = ctx.guild.get_channel(settings['log_channel_id']) + log_channel_display = log_channel.mention if log_channel else f"{emoji_warn} × Channel nicht gefunden" + + embed = discord.Embed( + title=f"{emoji_owner} × Anti-Spam Einstellungen", + color=discord.Color.blue() + ) + embed.add_field( + name=f"{emoji_channel} × Log-Channel", + value=log_channel_display, + inline=True + ) + embed.add_field( + name=f"{emoji_annoattention} × Nachrichtenlimit", + value=f"{settings['max_messages']} Nachrichten", + inline=True + ) + embed.add_field( + name=f"{emoji_statistics} × Zeitrahmen", + value=f"{settings['time_frame']} Sekunden", + inline=True + ) + embed.add_field( + name=f"{emoji_owner} × Status", + value="🟢 Aktiv", + inline=False + ) + else: + embed = discord.Embed( + title=f"{emoji_owner} × Anti-Spam Einstellungen", + description=f"{emoji_no} × **Anti-Spam-System nicht eingerichtet**\n\nVerwende `/antispam setup` um das System zu konfigurieren.", + color=discord.Color.red() + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="logs", description="Zeige Anti-Spam-Logs an.") + async def view_logs(self, ctx, limit: int = 10): + """Zeigt die Anti-Spam-Protokolle an.""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond(f"{emoji_no} × Du benötigst die 'Server verwalten' Berechtigung für diesen Befehl.", ephemeral=True) + return + + logs = self.db.get_spam_logs(ctx.guild.id, limit) + + if logs: + embed = discord.Embed( + title=f"{emoji_statistics} × Anti-Spam Protokolle", + color=discord.Color.red() + ) + + log_text = "" + for i, log in enumerate(logs, 1): + user_id, message_preview, timestamp = log + # Try to get user mention, fallback to ID + try: + user = self.bot.get_user(user_id) + user_display = user.mention if user else f"<@{user_id}>" + except: + user_display = f"User ID: {user_id}" + + log_text += f"**{i}.** {user_display}\n" + log_text += f"📝 `{message_preview[:50]}{'...' if len(message_preview) > 50 else ''}`\n" + log_text += f"🕒 {timestamp}\n\n" + + embed.description = log_text + embed.set_footer(text=f"Zeige die letzten {len(logs)} Einträge") + else: + embed = discord.Embed( + title=f"{emoji_statistics} × Anti-Spam Protokolle", + description="Für diesen Server wurden keine Anti-Spam-Logs gefunden.", + color=discord.Color.green() + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="clear", description="Lösche alle Anti-Spam-Logs für diesen Server.") + async def clear_logs(self, ctx): + """Löscht alle Anti-Spam-Protokolle für den Server.""" + if not ctx.author.guild_permissions.administrator: + await ctx.respond(f"{emoji_no} × Du benötigst Administrator-Rechte für diesen Befehl.", ephemeral=True) + return + + self.db.clear_spam_logs(ctx.guild.id) + + embed = discord.Embed( + title=f"{emoji_yes} × Protokolle gelöscht", + description="Alle Anti-Spam-Protokolle für diesen Server wurden gelöscht.", + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="whitelist", description="Füge einen Benutzer zur Whitelist hinzu.") + async def add_whitelist(self, ctx, user: discord.Member): + """Fügt einen Benutzer zur Anti-Spam Whitelist hinzu.""" + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond(f"{emoji_no} × Du benötigst die 'Server verwalten' Berechtigung für diesen Befehl.", ephemeral=True) + return + + self.db.add_to_whitelist(ctx.guild.id, user.id) + + embed = discord.Embed( + title=f"{emoji_yes} × Zur Whitelist hinzugefügt", + description=f"{user.mention} wurde zur Anti-Spam Whitelist hinzugefügt.", + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @antispam.command(name="disable", description="Deaktiviere das Anti-Spam-System.") + async def disable_antispam(self, ctx): + """Deaktiviert das Anti-Spam-System für diesen Server.""" + if not ctx.author.guild_permissions.administrator: + await ctx.respond(f"{emoji_no} × Du benötigst Administrator-Rechte für diesen Befehl.", ephemeral=True) + return + + # Remove settings to disable the system + with self.db.conn: + self.db.conn.execute('DELETE FROM spam_settings WHERE guild_id = ?', (ctx.guild.id,)) + + embed = discord.Embed( + title=f"{emoji_delete} × Anti-Spam-System deaktiviert", + description="Das Anti-Spam-System wurde für diesen Server deaktiviert.\nVerwende `/antispam setup` um es wieder zu aktivieren.", + color=discord.Color.orange() + ) + await ctx.respond(embed=embed, ephemeral=True) + + def is_whitelisted(self, guild_id, user_id): + """Check if user is whitelisted.""" + return self.db.is_whitelisted(guild_id, user_id) + + +def setup(bot: ezcord.Bot): + bot.add_cog(AntiSpam(bot)) \ No newline at end of file diff --git a/src/bot/cogs/moderation/moderation.py b/src/bot/cogs/moderation/moderation.py new file mode 100644 index 0000000..de7e7cc --- /dev/null +++ b/src/bot/cogs/moderation/moderation.py @@ -0,0 +1,550 @@ +# Copyright (c) 2025 OPPRO.NET Network +# ─────────────────────────────────────────────── +# >> Imports +# ─────────────────────────────────────────────── +import asyncio +import re +from datetime import datetime, timezone +from typing import Optional, Dict, List +import logging + +import discord +import ezcord +from discord import slash_command, option +import timedelta +from discord.ui import Container +from discord import SlashCommandGroup +# ─────────────────────────────────────────────── +# >> Cogs +# ─────────────────────────────────────────────── +class moderationCog(ezcord.Cog): + """Erweiterte Moderations-Cog mit verbesserter Sicherheit und Fehlerbehandlung""" + + def __init__(self, bot): + self.bot = bot + self.max_timeout_days = 28 + self._active_votes: Dict[int, Dict] = {} + self.logger = logging.getLogger(__name__) + moderation = SlashCommandGroup("mod") + + + def _has_permission(self, member: discord.Member, permission: str) -> bool: + """Überprüft ob ein Member eine bestimmte Berechtigung hat""" + return getattr(member.guild_permissions, permission, False) + + def _can_moderate_member(self, moderator: discord.Member, target: discord.Member) -> tuple[bool, str]: + """Erweiterte Überprüfung ob ein Moderator ein Ziel-Mitglied moderieren kann""" + if target.id == target.guild.owner_id: + return False, "Der Server-Owner kann nicht moderiert werden." + if moderator.id == target.id: + return False, "Du kannst dich nicht selbst moderieren." + if target.id == self.bot.user.id: + return False, "Ich kann mich nicht selbst moderieren." + if target.bot and not moderator.guild_permissions.administrator: + return False, "Nur Administratoren können Bots moderieren." + if moderator.id != target.guild.owner_id: + if moderator.top_role <= target.top_role: + return False, "Du kannst keine Mitglieder mit gleicher oder höherer Rolle moderieren." + bot_member = target.guild.get_member(self.bot.user.id) + if bot_member and bot_member.top_role <= target.top_role: + return False, "Meine Rolle ist nicht hoch genug, um dieses Mitglied zu moderieren." + return True, "" + + def _parse_duration(self, duration_str: str) -> Optional[timedelta]: + """Erweiterte Dauer-Parsing mit mehr Formaten und besserer Validierung""" + duration_str = duration_str.lower().strip() + pattern = r'(\d+)([smhdw])' + matches = re.findall(pattern, duration_str) + if not matches: + return None + total_seconds = 0 + for amount_str, unit in matches: + try: + amount = int(amount_str) + except ValueError: + return None + if amount < 0: + return None + if unit == 's': + total_seconds += amount + elif unit == 'm': + total_seconds += amount * 60 + elif unit == 'h': + total_seconds += amount * 3600 + elif unit == 'd': + total_seconds += amount * 86400 + elif unit == 'w': + total_seconds += amount * 604800 + else: + return None + if total_seconds < 1: + return None + max_seconds = self.max_timeout_days * 86400 + if total_seconds > max_seconds: + return None + return timedelta(seconds=total_seconds) + + def _format_duration(self, duration: timedelta) -> str: + """Formatiert eine timedelta in einen lesbaren String""" + total_seconds = int(duration.total_seconds()) + weeks = total_seconds // 604800 + days = (total_seconds % 604800) // 86400 + hours = (total_seconds % 86400) // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + parts = [] + if weeks: parts.append(f"{weeks}w") + if days: parts.append(f"{days}d") + if hours: parts.append(f"{hours}h") + if minutes: parts.append(f"{minutes}m") + if seconds: parts.append(f"{seconds}s") + return " ".join(parts) if parts else "0s" + + def _create_moderation_embed(self, action: str, moderator: discord.Member, target: discord.Member, + reason: str, duration: str = None, additional_info: str = None) -> discord.Embed: + """Erstellt ein einheitliches Moderations-Embed""" + color_map = { + "Bann": discord.Color.dark_red(), + "Kick": discord.Color.red(), + "Timeout": discord.Color.orange(), + "Timeout aufgehoben": discord.Color.green(), + "Slowmode aktiviert": discord.Color.blue(), + "Slowmode deaktiviert": discord.Color.green(), + } + embed = discord.Embed( + title=f"{emoji_yes} × {action} erfolgreich", + color=color_map.get(action, SUCCESS_COLOR), + timestamp=datetime.now(timezone.utc) + ) + embed.set_author(name=AUTHOR) + if target: + embed.add_field(name=f"{emoji_member} × Ziel", value=f"{target.mention} ({target})", inline=True) + embed.add_field(name=f"{emoji_staff} × Moderator", value=moderator.mention, inline=True) + if duration: + embed.add_field(name=f"{emoji_slowmode} × Dauer", value=duration, inline=True) + embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + if additional_info: + embed.add_field(name=f"{emoji_summary} × Zusätzlich", value=additional_info, inline=False) + if target: + embed.set_footer(text=f"User ID: {target.id}") + else: + embed.set_footer(text=FLOOTER) + return embed + + def _create_error_embed(self, title: str, description: str, additional_info: str = None) -> discord.Embed: + """Erstellt ein einheitliches Error-Embed""" + embed = discord.Embed( + title=f"{emoji_no} {title}", + description=description, + color=ERROR_COLOR, + timestamp=datetime.now(timezone.utc) + ) + embed.set_author(name=AUTHOR) + if additional_info: + embed.add_field(name=f"{emoji_summary} × Details", value=additional_info, inline=False) + embed.set_footer(text=FLOOTER) + return embed + + @moderation.command(name="ban", description="Bannt ein Mitglied vom Server") + @option("member", discord.Member, description="Das zu bannende Mitglied") + @option("reason", str, description="Grund für den Bann", max_length=500, required=False) + @option("notify_user", bool, description="User per DM benachrichtigen?", required=False, default=True) + async def ban(self, ctx: discord.ApplicationContext, member: discord.Member, + reason: str = "Kein Grund angegeben", notify_user: bool = True): + await ctx.defer(ephemeral=True) + try: + if not self._has_permission(ctx.author, BAN): + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Mitglieder bannen` Berechtigung.") + return await ctx.followup.send(embed=embed) + if not self._has_permission(ctx.guild.me, BAN): + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Mitglieder bannen` Berechtigung.") + return await ctx.followup.send(embed=embed) + can_moderate, error_msg = self._can_moderate_member(ctx.author, member) + if not can_moderate: + embed = self._create_error_embed("Moderation nicht möglich", error_msg) + return await ctx.followup.send(embed=embed) + notification_sent = False + if notify_user: + try: + dm_embed = discord.Embed( + title=f"{emoji_warn} × Du wurdest gebannt", + color=ERROR_COLOR, + description=f"Du wurdest von **{ctx.guild.name}** gebannt." + ) + dm_embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + dm_embed.add_field(name=f"{emoji_staff} × Moderator", value=str(ctx.author), inline=True) + dm_embed.set_footer(text="Bei Fragen wende dich an die Serverleitung.") + await member.send(embed=dm_embed) + notification_sent = True + except discord.Forbidden: + pass + ban_reason = f"{reason} | Moderator: {ctx.author} ({ctx.author.id})" + await member.ban(reason=ban_reason) + additional_info = [] + if notification_sent: + additional_info.append(f"{emoji_yes} × User per DM benachrichtigt") + elif notify_user: + additional_info.append(f"{emoji_no} × DM-Benachrichtigung fehlgeschlagen") + embed = self._create_moderation_embed("Bann", ctx.author, member, reason, additional_info="\n".join(additional_info) if additional_info else None) + await ctx.followup.send(embed=embed) + self.logger.info(f"User {member} ({member.id}) banned by {ctx.author} ({ctx.author.id}): {reason}") + except discord.Forbidden: + embed = self._create_error_embed("Berechtigung verweigert", f"Mir fehlen die Berechtigungen, um {member.mention} zu bannen.", "Stelle sicher, dass meine Rolle höher als die des Ziels ist.") + await ctx.followup.send(embed=embed) + except discord.HTTPException as e: + embed = self._create_error_embed("Discord-Fehler", f"Fehler beim Bannen: {str(e)}") + await ctx.followup.send(embed=embed) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}") + await ctx.followup.send(embed=embed) + self.logger.error(f"Unexpected error in ban command: {e}", exc_info=True) + + @moderation.command(name="kick", description="Kickt ein Mitglied vom Server") + @option("member", discord.Member, description="Das zu kickende Mitglied") + @option("reason", str, description="Grund für den Kick", max_length=500, required=False) + @option("notify_user", bool, description="User per DM benachrichtigen?", required=False, default=True) + async def kick(self, ctx: discord.ApplicationContext, member: discord.Member, + reason: str = "Kein Grund angegeben", notify_user: bool = True): + await ctx.defer(ephemeral=True) + try: + if not self._has_permission(ctx.author, KICK): + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Mitglieder kicken` Berechtigung.") + return await ctx.followup.send(embed=embed) + if not self._has_permission(ctx.guild.me, KICK): + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Mitglieder kicken` Berechtigung.") + return await ctx.followup.send(embed=embed) + can_moderate, error_msg = self._can_moderate_member(ctx.author, member) + if not can_moderate: + embed = self._create_error_embed("Moderation nicht möglich", error_msg) + return await ctx.followup.send(embed=embed) + notification_sent = False + if notify_user: + try: + dm_embed = discord.Embed( + title=f"{emoji_warn} × Du wurdest gekickt", + color=ERROR_COLOR, + description=f"Du wurdest von **{ctx.guild.name}** gekickt." + ) + dm_embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + dm_embed.add_field(name=f"{emoji_staff} × Moderator", value=str(ctx.author), inline=True) + dm_embed.set_footer(text="Du kannst dem Server erneut beitreten.") + await member.send(embed=dm_embed) + notification_sent = True + except discord.Forbidden: + pass + kick_reason = f"{reason} | Moderator: {ctx.author} ({ctx.author.id})" + await member.kick(reason=kick_reason) + additional_info = None + if notification_sent: + additional_info = f"{emoji_yes} × User per DM benachrichtigt" + elif notify_user: + additional_info = f"{emoji_no} × DM-Benachrichtigung fehlgeschlagen" + embed = self._create_moderation_embed("Kick", ctx.author, member, reason, additional_info=additional_info) + await ctx.followup.send(embed=embed) + self.logger.info(f"User {member} ({member.id}) kicked by {ctx.author} ({ctx.author.id}): {reason}") + except discord.Forbidden: + embed = self._create_error_embed("Berechtigung verweigert", f"Mir fehlen die Berechtigungen, um {member.mention} zu kicken.", "Stelle sicher, dass meine Rolle höher als die des Ziels ist.") + await ctx.followup.send(embed=embed) + except discord.HTTPException as e: + embed = self._create_error_embed("Discord-Fehler", f"Fehler beim Kicken: {str(e)}") + await ctx.followup.send(embed=embed) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}") + await ctx.followup.send(embed=embed) + self.logger.error(f"Unexpected error in kick command: {e}", exc_info=True) + + @moderation.command(name="timeout", description="Versetzt ein Mitglied in Timeout") + @option("member", discord.Member, description="Das zu mutende Mitglied") + @option("duration", str, description="Dauer (z.B. 10m, 1h30m, 2d, 1w)") + @option("reason", str, description="Grund für den Timeout", max_length=500, required=False) + @option("notify_user", bool, description="User per DM benachrichtigen?", required=False, default=True) + async def timeout(self, ctx: discord.ApplicationContext, member: discord.Member, + duration: str, reason: str = "Kein Grund angegeben", notify_user: bool = True): + await ctx.defer(ephemeral=True) + try: + if not self._has_permission(ctx.author, MODERATE): + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Mitglieder moderieren` Berechtigung.") + return await ctx.followup.send(embed=embed) + if not self._has_permission(ctx.guild.me, MODERATE): + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Mitglieder moderieren` Berechtigung.") + return await ctx.followup.send(embed=embed) + can_moderate, error_msg = self._can_moderate_member(ctx.author, member) + if not can_moderate: + embed = self._create_error_embed("Moderation nicht möglich", error_msg) + return await ctx.followup.send(embed=embed) + parsed_duration = self._parse_duration(duration) + if parsed_duration is None: + embed = self._create_error_embed("Ungültige Dauer", f"Konnte '{duration}' nicht als gültige Dauer erkennen.", f"Beispiele: `10m`, `1h30m`, `2d`, `1w`\nMaximum: {self.max_timeout_days} Tage") + return await ctx.followup.send(embed=embed) + if member.communication_disabled_until and member.communication_disabled_until > datetime.now(timezone.utc): + current_timeout = member.communication_disabled_until + embed = self._create_error_embed("User bereits in Timeout", f"{member.mention} ist bereits bis {discord.utils.format_dt(current_timeout, 'F')} in Timeout.", "Verwende `/moderation untimeout` um den aktuellen Timeout zu beenden.") + return await ctx.followup.send(embed=embed) + notification_sent = False + if notify_user: + try: + dm_embed = discord.Embed( + title=f"{emoji_warn} × Du wurdest in Timeout versetzt", + color=ERROR_COLOR, + description=f"Du wurdest auf **{ctx.guild.name}** in Timeout versetzt." + ) + dm_embed.add_field(name=f"{emoji_slowmode} × Dauer", value=self._format_duration(parsed_duration), inline=True) + dm_embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + dm_embed.add_field(name=f"{emoji_staff} × Moderator", value=str(ctx.author), inline=True) + end_time = datetime.now(timezone.utc) + parsed_duration + dm_embed.add_field(name="🕐 Ende", value=discord.utils.format_dt(end_time, 'F'), inline=True) + dm_embed.set_footer(text="Bitte beachte die Serverregeln.") + await member.send(embed=dm_embed) + notification_sent = True + except discord.Forbidden: + pass + timeout_reason = f"{reason} | Moderator: {ctx.author} ({ctx.author.id})" + await member.timeout_for(parsed_duration, reason=timeout_reason) + additional_info = None + if notification_sent: + additional_info = f"{emoji_yes} × User per DM benachrichtigt" + elif notify_user: + additional_info = f"{emoji_no} × DM-Benachrichtigung fehlgeschlagen" + embed = self._create_moderation_embed("Timeout", ctx.author, member, reason, duration=self._format_duration(parsed_duration), additional_info=additional_info) + end_time = datetime.now(timezone.utc) + parsed_duration + embed.add_field(name="🕐 Ende", value=discord.utils.format_dt(end_time, 'F'), inline=False) + await ctx.followup.send(embed=embed) + self.logger.info(f"User {member} ({member.id}) timed out by {ctx.author} ({ctx.author.id}) for {duration}: {reason}") + except discord.Forbidden: + embed = self._create_error_embed("Berechtigung verweigert", f"Mir fehlen die Berechtigungen, um {member.mention} zu timeouten.", "Stelle sicher, dass meine Rolle höher als die des Ziels ist.") + await ctx.followup.send(embed=embed) + except discord.HTTPException as e: + embed = self._create_error_embed("Discord-Fehler", f"Fehler beim Timeout: {str(e)}") + await ctx.followup.send(embed=embed) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}") + await ctx.followup.send(embed=embed) + self.logger.error(f"Unexpected error in timeout command: {e}", exc_info=True) + + @moderation.command(name="untimeout", description="Hebt einen Timeout auf") + @option("member", discord.Member, description="Das Mitglied, dessen Timeout aufgehoben werden soll") + @option("reason", str, description="Grund für die Aufhebung", max_length=500, required=False) + @option("notify_user", bool, description="User per DM benachrichtigen?", required=False, default=True) + async def untimeout(self, ctx: discord.ApplicationContext, member: discord.Member, + reason: str = "Kein Grund angegeben", notify_user: bool = True): + await ctx.defer(ephemeral=True) + try: + if not self._has_permission(ctx.author, MODERATE): + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Mitglieder moderieren` Berechtigung.") + return await ctx.followup.send(embed=embed) + if not self._has_permission(ctx.guild.me, MODERATE): + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Mitglieder moderieren` Berechtigung.") + return await ctx.followup.send(embed=embed) + if (member.communication_disabled_until is None or member.communication_disabled_until <= datetime.now(timezone.utc)): + embed = self._create_error_embed("Kein aktiver Timeout", f"{member.mention} ist derzeit nicht in Timeout.") + return await ctx.followup.send(embed=embed) + notification_sent = False + if notify_user: + try: + dm_embed = discord.Embed( + title=f"{emoji_yes} × Dein Timeout wurde aufgehoben", + color=SUCCESS_COLOR, + description=f"Dein Timeout auf **{ctx.guild.name}** wurde vorzeitig aufgehoben." + ) + dm_embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + dm_embed.add_field(name=f"{emoji_staff} × Moderator", value=str(ctx.author), inline=True) + dm_embed.set_footer(text="Bitte beachte weiterhin die Serverregeln.") + await member.send(embed=dm_embed) + notification_sent = True + except discord.Forbidden: + pass + untimeout_reason = f"{reason} | Moderator: {ctx.author} ({ctx.author.id})" + await member.remove_timeout(reason=untimeout_reason) + additional_info = None + if notification_sent: + additional_info = f"{emoji_yes} × User per DM benachrichtigt" + elif notify_user: + additional_info = f"{emoji_no} × DM-Benachrichtigung fehlgeschlagen" + embed = self._create_moderation_embed("Timeout aufgehoben", ctx.author, member, reason, additional_info=additional_info) + await ctx.followup.send(embed=embed) + self.logger.info(f"Timeout removed from {member} ({member.id}) by {ctx.author} ({ctx.author.id}): {reason}") + except discord.Forbidden: + embed = self._create_error_embed("Berechtigung verweigert", f"Mir fehlen die Berechtigungen, um den Timeout von {member.mention} aufzuheben.") + await ctx.followup.send(embed=embed) + except discord.HTTPException as e: + embed = self._create_error_embed("Discord-Fehler", f"Fehler beim Aufheben des Timeouts: {str(e)}") + await ctx.followup.send(embed=embed) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}") + await ctx.followup.send(embed=embed) + self.logger.error(f"Unexpected error in untimeout command: {e}", exc_info=True) + + @moderation.command(name="slowmode", description="Setzt den Slowmode für den aktuellen Channel") + @option("duration", str, description="Dauer (z.B. 10s, 5m, 1h) oder '0' zum Deaktivieren", default="0") + @option("reason", str, description="Grund für den Slowmode", max_length=500, required=False) + async def slowmode(self, ctx: discord.ApplicationContext, duration: str = "0", reason: str = "Kein Grund angegeben"): + await ctx.defer(ephemeral=True) + try: + if not ctx.author.guild_permissions.manage_channels: + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Kanäle verwalten` Berechtigung.") + return await ctx.followup.send(embed=embed) + if not ctx.guild.me.guild_permissions.manage_channels: + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Kanäle verwalten` Berechtigung.") + return await ctx.followup.send(embed=embed) + if duration == "0": + seconds = 0 + else: + parsed_duration = self._parse_duration(duration) + if parsed_duration is None: + embed = self._create_error_embed("Ungültige Dauer", f"Konnte '{duration}' nicht als gültige Dauer erkennen.", "Beispiele: `10s`, `5m`, `1h` oder `0` zum Deaktivieren") + return await ctx.followup.send(embed=embed) + seconds = int(parsed_duration.total_seconds()) + if seconds < 0 or seconds > 21600: + embed = self._create_error_embed("Ungültiger Zeitraum", f"Slowmode muss zwischen 0 Sekunden und 6 Stunden liegen.", f"Eingabe: {seconds} Sekunden") + return await ctx.followup.send(embed=embed) + old_slowmode = ctx.channel.slowmode_delay + slowmode_reason = f"{reason} | Moderator: {ctx.author} ({ctx.author.id})" + await ctx.channel.edit(slowmode_delay=seconds, reason=slowmode_reason) + if seconds == 0: + action = "Slowmode deaktiviert" + additional_info = f"Vorheriger Slowmode: {old_slowmode}s" if old_slowmode > 0 else None + else: + action = "Slowmode aktiviert" + additional_info = f"{emoji_slowmode} Slowmode auf {seconds} Sekunden gesetzt" + embed = self._create_moderation_embed(action, ctx.author, None, reason, duration=f"{seconds} Sekunden" if seconds > 0 else "Deaktiviert", additional_info=additional_info) + embed.add_field(name="📢 × Kanal", value=ctx.channel.mention, inline=True) + await ctx.followup.send(embed=embed) + self.logger.info(f"Slowmode set to {seconds}s in {ctx.channel} by {ctx.author} ({ctx.author.id}): {reason}") + except discord.Forbidden: + embed = self._create_error_embed("Berechtigung verweigert", "Mir fehlen die Berechtigungen, um den Slowmode zu setzen.") + await ctx.followup.send(embed=embed) + except discord.HTTPException as e: + embed = self._create_error_embed("Discord-Fehler", f"Fehler beim Setzen des Slowmodes: {str(e)}") + await ctx.followup.send(embed=embed) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}") + await ctx.followup.send(embed=embed) + self.logger.error(f"Unexpected error in slowmode command: {e}", exc_info=True) + + @moderation.command(name="votekick", description="Startet eine Votekick-Abstimmung für ein Mitglied") + @option("member", discord.Member, description="Das zu kickende Mitglied") + @option("reason", str, description="Grund für den Kick", max_length=500, required=False) + @option("duration", int, description="Abstimmungsdauer in Minuten (Standard: 5)", min_value=1, max_value=30, required=False) + async def votekick(self, ctx: discord.ApplicationContext, member: discord.Member, reason: str = "Kein Grund angegeben", duration: int = 5): + await ctx.defer() + try: + if not self._has_permission(ctx.author, KICK): + embed = self._create_error_embed("Keine Berechtigung", "Du benötigst die `Mitglieder kicken` Berechtigung.") + return await ctx.followup.send(embed=embed, ephemeral=True) + if not self._has_permission(ctx.guild.me, KICK): + embed = self._create_error_embed("Bot-Berechtigung fehlt", "Mir fehlt die `Mitglieder kicken` Berechtigung.") + return await ctx.followup.send(embed=embed, ephemeral=True) + can_moderate, error_msg = self._can_moderate_member(ctx.author, member) + if not can_moderate: + embed = self._create_error_embed("Moderation nicht möglich", error_msg) + return await ctx.followup.send(embed=embed, ephemeral=True) + if member.id in self._active_votes: + embed = self._create_error_embed("Abstimmung bereits aktiv", f"Es läuft bereits eine Abstimmung für {member.mention}.") + return await ctx.followup.send(embed=embed, ephemeral=True) + end_time = datetime.now(timezone.utc) + timedelta(minutes=duration) + embed = discord.Embed( + title=f"🗳️ × Votekick für {member.display_name}", + description=f"{ctx.author.mention} möchte {member.mention} kicken.\n\n" + f"**{emoji_summary} Grund:** {reason}\n\n" + f"Reagiere mit {emoji_yes} zum Kicken oder {emoji_no} zum Ablehnen.\n" + f"**🕐 Ende:** {discord.utils.format_dt(end_time, 'R')}", + color=discord.Color.orange(), + timestamp=datetime.now(timezone.utc) + ) + embed.set_author(name=f"Gestartet von {ctx.author}", icon_url=ctx.author.display_avatar.url) + embed.add_field(name=f"{emoji_member} × Ziel", value=f"{member.mention}", inline=True) + embed.add_field(name=f"{emoji_slowmode} × Dauer", value=f"{duration} Minuten", inline=True) + embed.set_footer(text=f"Votekick ID: {member.id}") + message = await ctx.followup.send(embed=embed) + await message.add_reaction(emoji_yes) + await message.add_reaction(emoji_no) + self._active_votes[member.id] = { + 'message': message, + 'initiator': ctx.author, + 'target': member, + 'reason': reason, + 'end_time': end_time, + 'guild': ctx.guild + } + asyncio.create_task(self._handle_votekick(member.id, duration * 60)) + except Exception as e: + embed = self._create_error_embed("Unerwarteter Fehler", f"Fehler bei der Votekick-Abstimmung: {str(e)}") + try: + await ctx.followup.send(embed=embed, ephemeral=True) + except: + await ctx.respond(embed=embed, ephemeral=True) + self.logger.error(f"Unexpected error in votekick command: {e}", exc_info=True) + + async def _handle_votekick(self, member_id: int, duration_seconds: int): + """Verwaltet eine Votekick-Abstimmung""" + await asyncio.sleep(duration_seconds) + if member_id not in self._active_votes: + return + vote_data = self._active_votes[member_id] + message = vote_data['message'] + target = vote_data['target'] + initiator = vote_data['initiator'] + reason = vote_data['reason'] + guild = vote_data['guild'] + try: + message = await message.channel.fetch_message(message.id) + yes_count = 0 + no_count = 0 + voters = set() + for reaction in message.reactions: + if str(reaction.emoji) == emoji_yes: + async for user in reaction.users(): + if not user.bot and user.id not in voters: + yes_count += 1 + voters.add(user.id) + elif str(reaction.emoji) == emoji_no: + async for user in reaction.users(): + if not user.bot and user.id not in voters: + no_count += 1 + voters.add(user.id) + total_members = len([m for m in guild.members if not m.bot]) + required_votes = max(3, total_members // 4) + if yes_count >= required_votes and yes_count > no_count: + try: + kick_reason = f"Votekick | Grund: {reason} | Initiator: {initiator} | Ja: {yes_count}, Nein: {no_count}" + await target.kick(reason=kick_reason) + result_embed = discord.Embed( + title=f"{emoji_yes} Votekick erfolgreich", + description=f"{emoji_member} {target.mention} wurde gekickt.", + color=discord.Color.green(), + timestamp=datetime.now(timezone.utc) + ) + result_embed.add_field(name="📊 × Ergebnis", value=f"{emoji_yes} Ja: {yes_count} | {emoji_no} Nein: {no_count}\n{emoji_summary} Benötigt: {required_votes}", inline=False) + self.logger.info(f"Votekick successful: {target} ({target.id}) kicked with {yes_count} votes") + except discord.Forbidden: + result_embed = discord.Embed( + title=f"{emoji_no} × Votekick fehlgeschlagen", + description=f"Berechtigung fehlt, um {target.mention} zu kicken.", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc) + ) + except discord.HTTPException as e: + result_embed = discord.Embed( + title=f"{emoji_no} × Votekick fehlgeschlagen", + description=f"Fehler beim Kicken: {str(e)}", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc) + ) + else: + result_embed = discord.Embed( + title=f"{emoji_no} × Votekick abgelehnt", + description=f"{emoji_member} {target.mention} wurde nicht gekickt.", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc) + ) + result_embed.add_field(name="📊 × Ergebnis", value=f"{emoji_yes} Ja: {yes_count} | {emoji_no} Nein: {no_count}\n{emoji_summary} Benötigt: {required_votes}", inline=False) + await message.edit(embed=result_embed, view=None) + except Exception as e: + self.logger.error(f"Error handling votekick result: {e}", exc_info=True) + finally: + if member_id in self._active_votes: + del self._active_votes[member_id] + + +def setup(bot): + bot.add_cog(moderationCog(bot)) \ No newline at end of file diff --git a/src/bot/cogs/moderation/notes.py b/src/bot/cogs/moderation/notes.py new file mode 100644 index 0000000..0aeae4b --- /dev/null +++ b/src/bot/cogs/moderation/notes.py @@ -0,0 +1,70 @@ +# Copyright (c) 2025 OPPRO.NET Network +# ─────────────────────────────────────────────── +# >> Imports +# ─────────────────────────────────────────────── +import discord +from discord import SlashCommandGroup +import datetime +import ezcord +from DevTools import NotesDatabase + +notes = SlashCommandGroup("notes") +# ─────────────────────────────────────────────── +# >> Cog +# ─────────────────────────────────────────────── +class NotesCog(ezcord.Cog, group="moderation"): + + def __init__(self, bot): + self.bot = bot + self.db = NotesDatabase("data") + + @notes.command(name="add", description="📝 Speichere eine Notiz für einen User") + async def add( + self, + ctx: discord.ApplicationContext, + user: discord.Member, + *, + content: str + ): + if not content: + return await ctx.respond("Bitte gib den Inhalt der Notiz an.", ephemeral=True) + + timestamp = datetime.datetime.now().strftime("%d.%m.%Y %H:%M") + self.db.add_note(ctx.guild.id, user.id, ctx.author.id, ctx.author.name, content, timestamp) + await ctx.respond(f"Notiz für {user.mention} gespeichert.", ephemeral=True) + + @notes.command(name="list", description="📜 Zeige alle Notizen eines Users an") + async def list(self, ctx: discord.ApplicationContext, user: discord.Member): + notes = self.db.get_notes(ctx.guild.id, user.id) + + if not notes: + return await ctx.respond(f"{emoji_no} {emoji_user}{user.mention} hat keine Notizen.", ephemeral=True) + + embed = discord.Embed(title=f"Notizen für {user.name}", color=discord.Color.blurple()) + for note in notes: + embed.add_field( + name=f"ID: {note['id']} – von {note['author_name']} am {note['timestamp']}", + value=note['content'], + inline=False + ) + + await ctx.respond(embed=embed, ephemeral=True) + + @notes.command(name="delete", description="🗑️ Lösche eine Notiz eines Users") + async def delete(self, ctx: discord.ApplicationContext, user: discord.Member, note_id: int): + notes = self.db.get_notes(ctx.guild.id, user.id) + if not notes: + return await ctx.respond(f"User {user} (ID: {user.id}) hat keine Notizen.", ephemeral=True) + + note_ids = [note['id'] for note in notes] + if note_id not in note_ids: + return await ctx.respond(f"{emoji_no} Notiz mit ID {note_id} existiert nicht für User {user}.", ephemeral=True) + + if self.db.delete_note(note_id): + await ctx.respond(f"{emoji_yes} Notiz mit ID {note_id} von User {user} wurde gelöscht.", ephemeral=True) + else: + await ctx.respond(f"{emoji_no} Fehler beim Löschen der Notiz mit ID {note_id}.", ephemeral=True) + + +def setup(bot): + bot.add_cog(NotesCog(bot)) diff --git a/src/bot/cogs/moderation/warn.py b/src/bot/cogs/moderation/warn.py new file mode 100644 index 0000000..c56dd7f --- /dev/null +++ b/src/bot/cogs/moderation/warn.py @@ -0,0 +1,557 @@ +# Copyright (c) 2025 OPPRO.NET Network +# ─────────────────────────────────────────────── +# >> Imports +# ─────────────────────────────────────────────── +from DevTools import WarnDatabase +import discord +from discord import slash_command, Option +import os +import datetime +import ezcord +import asyncio +from typing import Optional + + +# ─────────────────────────────────────────────── +# >> Cogs +# ─────────────────────────────────────────────── +class WarnSystem(ezcord.Cog, group="moderation"): + """Erweiterte Warn-System Cog mit verbesserter Funktionalität""" + + def __init__(self, bot): + self.bot = bot + base_path = os.path.dirname(__file__) + self.db = WarnDatabase(base_path) + # Cache für bessere Performance + self._warn_cache = {} + + def _has_moderate_permissions(self, member: discord.Member) -> bool: + """Überprüft ob ein Member Moderationsrechte hat""" + return ( + member.guild_permissions.kick_members or + member.guild_permissions.ban_members or + member.guild_permissions.manage_messages or + member.guild_permissions.moderate_members + ) + + def _can_warn_member(self, moderator: discord.Member, target: discord.Member) -> tuple[bool, str]: + """Überprüft ob ein Moderator ein Ziel-Mitglied verwarnen kann""" + + # Server-Owner kann nicht verwarnt werden + if target.id == target.guild.owner_id: + return False, "Der Server Owner kann nicht verwarnt werden." + + # Selbst-Verwarnung verhindern + if moderator.id == target.id: + return False, "Du kannst dich nicht selbst verwarnen." + + # Bot kann nicht verwarnt werden + if target.bot: + return False, "Du kannst keine Bots verwarnen." + + # Rollen-Hierarchie prüfen (außer bei Owner) + if (moderator.top_role <= target.top_role and + moderator.id != target.guild.owner_id): + return False, "Du kannst keine Mitglieder mit gleicher oder höherer Rolle verwarnen." + + return True, "" + + def _create_warn_embed(self, action: str, moderator: discord.Member, + target: discord.Member, reason: str, + timestamp: str, warn_id: int = None) -> discord.Embed: + """Erstellt ein einheitliches Warn-Embed""" + + if action == "warn": + embed = discord.Embed( + title=f"{emoji_warn} Warnung erteilt", + color=SUCCESS_COLOR, + description=f"{target.mention} wurde erfolgreich verwarnt." + ) + elif action == "unwarn": + embed = discord.Embed( + title=f"{emoji_yes} Warnung entfernt", + color=SUCCESS_COLOR, + description=f"Warnung wurde erfolgreich entfernt." + ) + else: + embed = discord.Embed( + title=f" {action}", + color=SUCCESS_COLOR + ) + + embed.set_author(name=AUTHOR) + + if action == "warn": + embed.add_field(name=f"{emoji_member} × Verwarnter User", value=target.mention, inline=True) + embed.add_field(name=f"{emoji_staff} × Verwarnt von", value=moderator.mention, inline=True) + embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + embed.add_field(name=f"{emoji_slowmode} × Zeitstempel", value=timestamp, inline=False) + embed.set_footer(text="Powered by ManagerX") + + elif action == "unwarn": + embed.add_field(name=f" Entfernt von", value=moderator.mention, inline=True) + if warn_id: + embed.add_field(name=f" Warnung ID", value=f"`{warn_id}`", inline=True) + embed.set_footer(text=FLOOTER) + + return embed + + def _create_error_embed(self, title: str, message: str) -> discord.Embed: + """Erstellt ein einheitliches Error-Embed""" + embed = discord.Embed(title=title, color=ERROR_COLOR) + embed.set_author(name=AUTHOR) + embed.add_field(name=f"{emoji_no} {title}", value=message, inline=False) + embed.set_footer(text=FLOOTER) + return embed + + @slash_command(name="warn", description="Warnen Sie einen Benutzer") + async def warn( + self, + ctx, + user: Option(discord.Member, "User to warn"), + reason: Option(str, "Reason for the warning", max_length=500) + ): + try: + # Berechtigung prüfen + if not self._has_moderate_permissions(ctx.author): + embed = self._create_error_embed( + "Keine Berechtigung", + "Du benötigst Moderationsrechte, um Mitglieder zu verwarnen." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Kann Mitglied verwarnt werden? + can_warn, error_msg = self._can_warn_member(ctx.author, user) + if not can_warn: + embed = self._create_error_embed("Verwarnung nicht möglich", error_msg) + return await ctx.respond(embed=embed, ephemeral=True) + + # Warn-Daten erstellen + timestamp = datetime.datetime.utcnow().strftime("%d.%m.%Y %H:%M") + + # In Datenbank speichern + try: + self.db.add_warning(ctx.guild.id, user.id, ctx.author.id, reason, timestamp) + + # Cache invalidieren + cache_key = f"{ctx.guild.id}_{user.id}" + if cache_key in self._warn_cache: + del self._warn_cache[cache_key] + + except Exception as e: + embed = self._create_error_embed( + "Datenbankfehler", + f"Fehler beim Speichern der Warnung: {str(e)}" + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Erfolgs-Embed + success_embed = self._create_warn_embed("warn", ctx.author, user, reason, timestamp) + await ctx.respond(embed=success_embed, ephemeral=True) + + # Optional: DM an verwarnten User senden + try: + dm_embed = discord.Embed( + title=f"{emoji_warn} Du wurdest verwarnt", + color=ERROR_COLOR, + description=f"Du wurdest auf **{ctx.guild.name}** verwarnt." + ) + dm_embed.add_field(name=f"{emoji_summary} × Grund", value=reason, inline=False) + dm_embed.add_field(name=f"{emoji_staff} × Moderator", value=str(ctx.author), inline=True) + dm_embed.add_field(name=f"{emoji_slowmode} × Zeitpunkt", value=timestamp, inline=True) + dm_embed.set_footer(text="Powered by ManagerX") + + await user.send(embed=dm_embed) + except discord.Forbidden: + # User hat DMs deaktiviert - ignorieren + pass + + except Exception as e: + embed = self._create_error_embed( + "Unerwarteter Fehler", + f"Ein unerwarteter Fehler ist aufgetreten: {str(e)}" + ) + await ctx.respond(embed=embed, ephemeral=True) + + @slash_command(name="warnings", description="Zeigt die Verwarnungen eines Users an") + async def warnings( + self, + ctx, + user: Option(discord.Member, "User whose warnings to show", required=False) + ): + try: + # Wenn kein User angegeben, eigene Warnungen zeigen + target_user = user if user else ctx.author + + # Cache prüfen + cache_key = f"{ctx.guild.id}_{target_user.id}" + + if cache_key in self._warn_cache: + results = self._warn_cache[cache_key] + else: + # Warnungen aus Datenbank laden + results = self.db.get_warnings(ctx.guild.id, target_user.id) + self._warn_cache[cache_key] = results + + # Überprüfung ob User Warnungen einsehen darf + if target_user != ctx.author and not self._has_moderate_permissions(ctx.author): + embed = self._create_error_embed( + "Keine Berechtigung", + "Du kannst nur deine eigenen Warnungen einsehen." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + if not results: + # Keine Warnungen vorhanden + no_warnings_embed = discord.Embed( + title=f"{emoji_circleinfo} Keine Verwarnungen", + color=SUCCESS_COLOR, + description=f"{target_user.mention} hat keine Verwarnungen." + ) + no_warnings_embed.set_author(name=AUTHOR) + no_warnings_embed.set_footer(text=FLOOTER) + return await ctx.respond(embed=no_warnings_embed, ephemeral=True) + + # Warnungen-Liste aufteilen falls zu viele (max 10 pro Seite) + warnings_per_page = 10 + total_warnings = len(results) + total_pages = (total_warnings + warnings_per_page - 1) // warnings_per_page + + if total_pages == 1: + # Alle Warnungen auf einer Seite + warn_list = "\n".join([ + f"**ID `{warn_id}`** | {timestamp}\n└ **Grund:** {reason[:100]}{'...' if len(reason) > 100 else ''}" + for warn_id, reason, timestamp in results[:warnings_per_page] + ]) + + warnings_embed = discord.Embed( + title=f"{emoji_warn} Verwarnungen für {target_user.display_name}", + color=ERROR_COLOR, + description=warn_list + ) + warnings_embed.set_author(name=AUTHOR) + warnings_embed.add_field(name=f"{emoji_member} User", value=target_user.mention, inline=True) + warnings_embed.add_field(name=f"{emoji_summary} Anzahl Verwarnungen", value=str(total_warnings), inline=True) + warnings_embed.set_footer(text=FLOOTER) + + await ctx.respond(embed=warnings_embed, ephemeral=True) + else: + # Mehrere Seiten - ersten 10 zeigen mit Navigation + await self._send_paginated_warnings(ctx, target_user, results, 0) + + except Exception as e: + embed = self._create_error_embed( + "Unerwarteter Fehler", + f"Fehler beim Laden der Warnungen: {str(e)}" + ) + await ctx.respond(embed=embed, ephemeral=True) + + async def _send_paginated_warnings(self, ctx, target_user: discord.Member, + warnings: list, page: int = 0): + """Sendet paginierte Warnungen mit Navigation""" + warnings_per_page = 10 + total_pages = (len(warnings) + warnings_per_page - 1) // warnings_per_page + + start_idx = page * warnings_per_page + end_idx = min(start_idx + warnings_per_page, len(warnings)) + page_warnings = warnings[start_idx:end_idx] + + warn_list = "\n".join([ + f"**ID `{warn_id}`** | {timestamp}\n└ **Grund:** {reason[:100]}{'...' if len(reason) > 100 else ''}" + for warn_id, reason, timestamp in page_warnings + ]) + + embed = discord.Embed( + title=f"{emoji_warn} Verwarnungen für {target_user.display_name}", + color=ERROR_COLOR, + description=warn_list + ) + embed.set_author(name=AUTHOR) + embed.add_field(name=f"{emoji_member} User", value=target_user.mention, inline=True) + embed.add_field(name=f"{emoji_summary} Anzahl Verwarnungen", value=str(len(warnings)), inline=True) + embed.set_footer(text=f"Seite {page + 1}/{total_pages} • {FLOOTER}") + + # View für Navigation erstellen + view = WarningsView(self, target_user, warnings, page, total_pages) + + if ctx.response.is_done(): + await ctx.followup.send(embed=embed, view=view, ephemeral=True) + else: + await ctx.respond(embed=embed, view=view, ephemeral=True) + + @slash_command(name="unwarn", description="Löscht eine Verwarnung mit ID") + async def unwarn( + self, + ctx, + warn_id: Option(int, "Die ID der Verwarnung", min_value=1) + ): + try: + # Berechtigung prüfen + if not self._has_moderate_permissions(ctx.author): + embed = self._create_error_embed( + "Keine Berechtigung", + "Du benötigst Moderationsrechte, um Verwarnungen zu löschen." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Warnung suchen + result = self.db.get_warning_by_id(warn_id) + if not result: + embed = self._create_error_embed( + "Verwarnung nicht gefunden", + f"Keine Verwarnung mit der ID `{warn_id}` gefunden." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Überprüfen ob Warnung zu diesem Server gehört + warn_guild_id = result[1] # guild_id ist der zweite Wert + if warn_guild_id != ctx.guild.id: + embed = self._create_error_embed( + "Verwarnung nicht gefunden", + f"Keine Verwarnung mit der ID `{warn_id}` in diesem Server gefunden." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Warnung löschen + success = self.db.delete_warning(warn_id) + if not success: + embed = self._create_error_embed( + "Löschfehler", + f"Fehler beim Löschen der Verwarnung `{warn_id}`." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Cache invalidieren + user_id = result[2] # user_id ist der dritte Wert + cache_key = f"{ctx.guild.id}_{user_id}" + if cache_key in self._warn_cache: + del self._warn_cache[cache_key] + + # Erfolgs-Embed + removal_embed = self._create_warn_embed("unwarn", ctx.author, None, None, None, warn_id) + await ctx.respond(embed=removal_embed, ephemeral=True) + + except Exception as e: + embed = self._create_error_embed( + "Unerwarteter Fehler", + f"Fehler beim Löschen der Verwarnung: {str(e)}" + ) + await ctx.respond(embed=embed, ephemeral=True) + + @slash_command(name="clearwarns", description="Löscht alle Verwarnungen eines Users") + async def clearwarns( + self, + ctx, + user: Option(discord.Member, "User dessen Warnungen gelöscht werden sollen"), + reason: Option(str, "Grund für das Löschen", required=False, default="Kein Grund angegeben") + ): + try: + # Nur Administratoren können alle Warnungen löschen + if not ctx.author.guild_permissions.administrator: + embed = self._create_error_embed( + "Keine Berechtigung", + "Du benötigst Administrator-Rechte, um alle Warnungen zu löschen." + ) + return await ctx.respond(embed=embed, ephemeral=True) + + # Aktuelle Warnungen zählen + warn_count = self.db.get_warning_count(ctx.guild.id, user.id) + + if warn_count == 0: + embed = discord.Embed( + title=f"{emoji_summary} Keine Verwarnungen", + color=SUCCESS_COLOR, + description=f"{user.mention} hat keine Verwarnungen zum Löschen." + ) + embed.set_author(name=AUTHOR) + return await ctx.respond(embed=embed, ephemeral=True) + + # Bestätigung anfordern + confirm_embed = discord.Embed( + title=f"{emoji_warn} Bestätigung erforderlich", + color=ERROR_COLOR, + description=f"Möchtest du wirklich **{warn_count}** Warnungen von {user.mention} löschen?\n\n**Grund:** {reason}" + ) + confirm_embed.set_footer(text="Diese Aktion kann nicht rückgängig gemacht werden! × Powered by ManagerX") + + view = ClearWarningsConfirmView(self, user, ctx.author, reason, warn_count) + await ctx.respond(embed=confirm_embed, view=view, ephemeral=True) + + except Exception as e: + embed = self._create_error_embed( + "Unerwarteter Fehler", + f"Fehler beim Vorbereiten der Löschung: {str(e)}" + ) + await ctx.respond(embed=embed, ephemeral=True) + + async def clear_all_user_warnings(self, guild_id: int, user_id: int) -> int: + """Löscht alle Warnungen eines Users und gibt die Anzahl zurück""" + try: + # Alle Warn-IDs für den User holen + warnings = self.db.get_warnings(guild_id, user_id) + deleted_count = 0 + + for warn_id, _, _ in warnings: + if self.db.delete_warning(warn_id): + deleted_count += 1 + + # Cache invalidieren + cache_key = f"{guild_id}_{user_id}" + if cache_key in self._warn_cache: + del self._warn_cache[cache_key] + + return deleted_count + + except Exception as e: + print(f"Fehler beim Löschen aller Warnungen: {e}") + return 0 + + +class WarningsView(discord.ui.View): + """View für die Navigation durch paginierte Warnungen""" + + def __init__(self, cog, target_user: discord.Member, warnings: list, current_page: int, total_pages: int): + super().__init__(timeout=300) # 5 Minuten Timeout + self.cog = cog + self.target_user = target_user + self.warnings = warnings + self.current_page = current_page + self.total_pages = total_pages + + # Buttons aktivieren/deaktivieren + self.previous_button.disabled = current_page == 0 + self.next_button.disabled = current_page >= total_pages - 1 + + @discord.ui.button(label="◀ Vorherige", style=discord.ButtonStyle.secondary, disabled=True) + async def previous_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if self.current_page > 0: + self.current_page -= 1 + await self._update_page(interaction) + + @discord.ui.button(label="Nächste ▶", style=discord.ButtonStyle.secondary) + async def next_button(self, button: discord.ui.Button, interaction: discord.Interaction): + if self.current_page < self.total_pages - 1: + self.current_page += 1 + await self._update_page(interaction) + + async def _update_page(self, interaction: discord.Interaction): + """Aktualisiert die angezeigte Seite""" + warnings_per_page = 10 + start_idx = self.current_page * warnings_per_page + end_idx = min(start_idx + warnings_per_page, len(self.warnings)) + page_warnings = self.warnings[start_idx:end_idx] + + warn_list = "\n".join([ + f"**ID `{warn_id}`** | {timestamp}\n└ **Grund:** {reason[:100]}{'...' if len(reason) > 100 else ''}" + for warn_id, reason, timestamp in page_warnings + ]) + + embed = discord.Embed( + title=f"{emoji_warn} Verwarnungen für {self.target_user.display_name}", + color=ERROR_COLOR, + description=warn_list + ) + embed.set_author(name=AUTHOR) + embed.add_field(name=f"{emoji_member} User", value=self.target_user.mention, inline=True) + embed.add_field(name=f"{emoji_summary} Anzahl Verwarnungen", value=str(len(self.warnings)), inline=True) + embed.set_footer(text=f"Seite {self.current_page + 1}/{self.total_pages} • {FLOOTER}") + + # Buttons aktualisieren + self.previous_button.disabled = self.current_page == 0 + self.next_button.disabled = self.current_page >= self.total_pages - 1 + + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + """Deaktiviert alle Buttons nach Timeout""" + for item in self.children: + item.disabled = True + + +class ClearWarningsConfirmView(discord.ui.View): + """View für die Bestätigung beim Löschen aller Warnungen""" + + def __init__(self, cog, target_user: discord.Member, moderator: discord.Member, reason: str, warn_count: int): + super().__init__(timeout=60) # 1 Minute Timeout + self.cog = cog + self.target_user = target_user + self.moderator = moderator + self.reason = reason + self.warn_count = warn_count + + @discord.ui.button(label="✅ Bestätigen", style=discord.ButtonStyle.danger) + async def confirm_button(self, button: discord.ui.Button, interaction: discord.Interaction): + # Überprüfen ob der richtige User geantwortet hat + if interaction.user.id != self.moderator.id: + await interaction.response.send_message( + "❌ Nur der ursprüngliche Moderator kann diese Aktion bestätigen.", + ephemeral=True + ) + return + + try: + # Alle Warnungen löschen + deleted_count = await self.cog.clear_all_user_warnings( + interaction.guild.id, self.target_user.id + ) + + if deleted_count > 0: + success_embed = discord.Embed( + title=f"{emoji_yes} Warnungen gelöscht", + color=SUCCESS_COLOR, + description=f"**{deleted_count}** Warnungen von {self.target_user.mention} wurden gelöscht." + ) + success_embed.add_field(name="Grund", value=self.reason, inline=False) + success_embed.add_field(name="Moderator", value=self.moderator.mention, inline=True) + success_embed.set_footer(text=FLOOTER) + else: + success_embed = discord.Embed( + title=f"{emoji_no} Keine Warnungen gelöscht", + color=ERROR_COLOR, + description="Es konnten keine Warnungen gelöscht werden." + ) + + # View deaktivieren + for item in self.children: + item.disabled = True + + await interaction.response.edit_message(embed=success_embed, view=self) + + except Exception as e: + error_embed = discord.Embed( + title=ERROR_TITLE, + color=ERROR_COLOR, + description=f"Fehler beim Löschen: {str(e)}" + ) + await interaction.response.edit_message(embed=error_embed, view=None) + + @discord.ui.button(label="❌ Abbrechen", style=discord.ButtonStyle.secondary) + async def cancel_button(self, button: discord.ui.Button, interaction: discord.Interaction): + # Überprüfen ob der richtige User geantwortet hat + if interaction.user.id != self.moderator.id: + await interaction.response.send_message( + "❌ Nur der ursprüngliche Moderator kann diese Aktion abbrechen.", + ephemeral=True + ) + return + + cancel_embed = discord.Embed( + title=f"{emoji_yes} Abgebrochen", + color=SUCCESS_COLOR, + description="Das Löschen der Warnungen wurde abgebrochen." + ) + + # View deaktivieren + for item in self.children: + item.disabled = True + + await interaction.response.edit_message(embed=cancel_embed, view=self) + + async def on_timeout(self): + """Deaktiviert alle Buttons nach Timeout""" + for item in self.children: + item.disabled = True + + +def setup(bot): + bot.add_cog(WarnSystem(bot)) \ No newline at end of file diff --git a/src/bot/cogs/user/settings.py b/src/bot/cogs/user/settings.py new file mode 100644 index 0000000..381ff7b --- /dev/null +++ b/src/bot/cogs/user/settings.py @@ -0,0 +1,108 @@ +import discord +from discord.ext import commands +from discord import SlashCommandGroup +import ezcord + +from mx_handler import TranslationHandler + + +class Settings(ezcord.Cog): + """Cog for setting user language preferences.""" + + user = SlashCommandGroup("user", "User settings commands") + + language = user.create_subgroup( + "language") + + AVAILABLE_LANGUAGES = { + "de": "Deutsch 🇩🇪", + "en": "English 🇬🇧" + } + + @language.command( + name="set", + description="Set your preferred language for bot messages." + ) + @discord.option( + "language", + description="Choose a language", + choices=[ + discord.OptionChoice(name=name, value=code) + for code, name in AVAILABLE_LANGUAGES.items() + ], + required=True + ) + async def set_language(self, ctx: discord.ApplicationContext, language: str): + """ + Set the user's preferred language. + + Args: + ctx: Discord application context + language: Selected language code + """ + # Save language preference + self.bot.settings_db.set_user_language(ctx.author.id, language) + + # Get display name for the selected language + lang_name = self.AVAILABLE_LANGUAGES.get(language, language) + + # Load response message using TranslationHandler + response_text = await TranslationHandler.get_async( + language, + "cog_settings.language.message.language_set", + default="Language has been set to {language}.", + language=lang_name + ) + + await ctx.respond(response_text, ephemeral=True) + + + @language.command() + async def get(self, ctx: discord.ApplicationContext): + """ + Get the user's current preferred language. + + Args: + ctx: Discord application context + """ + # Retrieve user's language preference + language = self.bot.settings_db.get_user_language(ctx.author.id) + + if not language: + response_text = await TranslationHandler.get_async( + "en", + "cog_settings.language.error_types.language_not_set", + default="You have not set a preferred language yet." + ) + else: + lang_name = self.AVAILABLE_LANGUAGES.get(language, language) + response_text = await TranslationHandler.get_async( + language, + "cog_settings.language.message.current_language", + default="Your current preferred language is {language}.", + language=lang_name + ) + + await ctx.respond(response_text, ephemeral=True) + + @language.command( + name="list", + description="List all available languages." + ) + + async def list_languages(self, ctx: discord.ApplicationContext): + """ + List all available languages. + + Args: + ctx: Discord application context + """ + languages_list = "\n".join( + f"{code}: {name}" for code, name in self.AVAILABLE_LANGUAGES.items() + ) + response_text = f"**Available Languages:**\n{languages_list}" + await ctx.respond(response_text, ephemeral=True) + +def setup(bot): + """Setup function to add the cog to the bot.""" + bot.add_cog(Settings(bot)) \ No newline at end of file diff --git a/src/bot/cogs/user/stats.py b/src/bot/cogs/user/stats.py new file mode 100644 index 0000000..99c35a6 --- /dev/null +++ b/src/bot/cogs/user/stats.py @@ -0,0 +1,598 @@ +# Copyright (c) 2025 OPPRO.NET Network +import discord +from discord.ext import commands, tasks +from discord import SlashCommandGroup +import logging +from typing import Optional +from DevTools import StatsDB +import asyncio +from datetime import datetime, timedelta +import math + + +logger = logging.getLogger(__name__) + + +class EnhancedStatsCog(commands.Cog): + """ + Enhanced Discord Cog for tracking user statistics with global level system. + Provides comprehensive tracking of messages, voice activity, and user progression. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.db = StatsDB() + self.cleanup_task.start() + logger.info("Enhanced StatsCog initialized") + + stats = SlashCommandGroup("stats", "Statistiken") + + def cog_unload(self): + """Called when the cog is unloaded.""" + self.cleanup_task.cancel() + self.db.close() + logger.info("Enhanced StatsCog unloaded") + + @tasks.loop(hours=24) + async def cleanup_task(self): + """Daily cleanup of old data.""" + await self.db.cleanup_old_data(days=90) + + @cleanup_task.before_loop + async def before_cleanup(self): + await self.bot.wait_until_ready() + + @commands.Cog.listener() + async def on_ready(self): + """Called when the bot is ready and connected to Discord.""" + logger.info(f"Enhanced StatsCog ready - Bot connected as {self.bot.user}") + + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, + after: discord.VoiceState): + """Track voice channel activity with enhanced features.""" + if member.bot: + return + + try: + user_id = member.id + guild_id = member.guild.id + + # User left a voice channel + if before.channel and not after.channel: + await self.db.end_voice_session(user_id, before.channel.id) + logger.debug(f"User {member.display_name} left voice channel {before.channel.name}") + + # User joined a voice channel + elif not before.channel and after.channel: + await self.db.start_voice_session(user_id, guild_id, after.channel.id) + logger.debug(f"User {member.display_name} joined voice channel {after.channel.name}") + + # User switched voice channels + elif before.channel and after.channel and before.channel.id != after.channel.id: + await self.db.end_voice_session(user_id, before.channel.id) + await self.db.start_voice_session(user_id, guild_id, after.channel.id) + logger.debug(f"User {member.display_name} switched from {before.channel.name} to {after.channel.name}") + + except Exception as e: + logger.error(f"Error handling voice state update for {member.display_name}: {e}") + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + """Track messages with enhanced analysis.""" + if message.author.bot or not message.guild: + return + + try: + # Analyze message content + word_count = len(message.content.split()) if message.content else 0 + has_attachment = len(message.attachments) > 0 + message_type = 'text' + + if message.attachments: + message_type = 'attachment' + elif message.embeds: + message_type = 'embed' + elif message.stickers: + message_type = 'sticker' + + await self.db.log_message( + user_id=message.author.id, + guild_id=message.guild.id, + channel_id=message.channel.id, + message_id=message.id, + word_count=word_count, + has_attachment=has_attachment, + message_type=message_type + ) + + logger.debug(f"Logged enhanced message {message.id} from {message.author.display_name}") + + except Exception as e: + logger.error(f"Error logging enhanced message from {message.author.display_name}: {e}") + + @stats.command( + name="statsistics", + description="Zeige deine Aktivitätsstatistiken an" + ) + async def stats_command( + self, + ctx: discord.ApplicationContext, + zeitraum: discord.Option( + str, + description="Zeitraum für die Statistiken", + choices=["24h", "7d", "30d"], + required=False, + default="24h" + ), + user: discord.Option( + discord.Member, + description="Statistiken eines anderen Users anzeigen (optional)", + required=False + ) + ): + """Enhanced stats command with more detailed information.""" + await ctx.defer() + + try: + target_user = user if user else ctx.author + time_periods = { + "24h": (24, "24 Stunden"), + "7d": (24 * 7, "7 Tagen"), + "30d": (24 * 30, "30 Tagen") + } + + hours, period_name = time_periods[zeitraum] + + # Get regular stats + message_count, voice_minutes = await self.db.get_user_stats( + target_user.id, hours, ctx.guild.id + ) + + # Get global user info + global_info = await self.db.get_global_user_info(target_user.id) + + # Format voice time + voice_hours = int(voice_minutes // 60) + voice_mins = int(voice_minutes % 60) + voice_time_str = f"{voice_hours}h {voice_mins}m" if voice_hours > 0 else f"{voice_mins}m" + + # Create main embed + embed = discord.Embed( + title=f"📊 {'Deine' if target_user == ctx.author else f'{target_user.display_name}s'} Statistiken", + description=f"Aktivität der letzten {period_name}", + color=discord.Color.blue() + ) + + # Local server stats + embed.add_field( + name="📅 Server Aktivität", + value=f"💬 **{message_count}** Nachrichten\n🎤 **{voice_time_str}** Voice-Zeit", + inline=True + ) + + # Global stats if available + if global_info: + level = global_info['level'] + xp_progress = global_info['xp_progress'] + xp_needed = global_info['xp_needed'] + progress_bar = self._create_progress_bar(xp_progress, xp_needed) + + embed.add_field( + name="🌍 Global Level", + value=f"**Level {level}** {self._get_level_emoji(level)}\n{progress_bar}\n`{int(xp_progress)}/{int(xp_needed)} XP`", + inline=True + ) + + # Global totals + total_voice_hours = int(global_info['total_voice_minutes'] // 60) + embed.add_field( + name="🏆 Global Totals", + value=f"📨 **{global_info['total_messages']:,}** Nachrichten\n" + f"🎤 **{total_voice_hours:,}** Stunden Voice\n" + f"🔥 **{global_info['daily_streak']}** Tage Streak", + inline=True + ) + + embed.set_thumbnail(url=target_user.display_avatar.url) + embed.set_footer(text=f"Angefragt von {ctx.author.display_name}") + + await ctx.followup.send(embed=embed) + + except Exception as e: + logger.error(f"Error executing enhanced stats command: {e}") + error_embed = discord.Embed( + title="❌ Fehler", + description="Es gab einen Fehler beim Abrufen der Statistiken.", + color=discord.Color.red() + ) + await ctx.followup.send(embed=error_embed, ephemeral=True) + + @stats.command( + name="globalstats", + description="Zeige deine globalen Level-Statistiken über alle Server an" + ) + async def global_stats_command( + self, + ctx: discord.ApplicationContext, + user: discord.Option( + discord.Member, + description="Global Stats eines anderen Users anzeigen", + required=False + ) + ): + """Show detailed global statistics and achievements.""" + await ctx.defer() + + try: + target_user = user if user else ctx.author + global_info = await self.db.get_global_user_info(target_user.id) + + if not global_info: + embed = discord.Embed( + title="📊 Keine Daten", + description=f"{'Du hast' if target_user == ctx.author else f'{target_user.display_name} hat'} noch keine globalen Statistiken.", + color=discord.Color.orange() + ) + await ctx.followup.send(embed=embed) + return + + level = global_info['level'] + xp = global_info['xp'] + xp_progress = global_info['xp_progress'] + xp_needed = global_info['xp_needed'] + + # Create embed + embed = discord.Embed( + title=f"🌍 {'Deine' if target_user == ctx.author else f'{target_user.display_name}s'} Globalen Stats", + description=f"Level-System über alle Server", + color=self._get_level_color(level) + ) + + # Level info + progress_bar = self._create_progress_bar(xp_progress, xp_needed) + level_emoji = self._get_level_emoji(level) + + embed.add_field( + name=f"{level_emoji} Level & XP", + value=f"**Level {level}**\n{progress_bar}\n`{int(xp_progress):,} / {int(xp_needed):,} XP`\n`Total: {int(xp):,} XP`", + inline=False + ) + + # Activity stats + total_voice_hours = int(global_info['total_voice_minutes'] // 60) + days_since_joined = (datetime.now() - datetime.fromisoformat(global_info['first_seen'])).days + 1 + avg_messages_per_day = global_info['total_messages'] / days_since_joined + + embed.add_field( + name="📈 Aktivitäts-Statistiken", + value=f"📨 **{global_info['total_messages']:,}** Nachrichten gesamt\n" + f"🎤 **{total_voice_hours:,}** Stunden in Voice\n" + f"🏢 **{global_info['total_servers']}** Server aktiv\n" + f"📊 **{avg_messages_per_day:.1f}** Nachrichten/Tag", + inline=True + ) + + # Streak info + embed.add_field( + name="🔥 Streak Statistiken", + value=f"🔥 **{global_info['daily_streak']}** Tage aktuell\n" + f"🏆 **{global_info['best_streak']}** Tage Rekord\n" + f"📅 Dabei seit **{days_since_joined}** Tagen", + inline=True + ) + + # Recent achievements + achievements = global_info['achievements'][-3:] # Last 3 achievements + if achievements: + achievement_text = "\n".join( + [f"{ach.get('icon', '🏆')} {ach.get('name', 'Unknown')}" for ach in achievements]) + embed.add_field( + name="🏆 Neueste Erfolge", + value=achievement_text, + inline=True + ) + + embed.set_thumbnail(url=target_user.display_avatar.url) + embed.set_footer(text=f"Angefragt von {ctx.author.display_name} • Globales Level-System") + + await ctx.followup.send(embed=embed) + + except Exception as e: + logger.error(f"Error executing global stats command: {e}") + error_embed = discord.Embed( + title="❌ Fehler", + description="Es gab einen Fehler beim Abrufen der globalen Statistiken.", + color=discord.Color.red() + ) + await ctx.followup.send(embed=error_embed, ephemeral=True) + + @stats.command( + name="leaderboard", + description="Zeige die Top-User Rangliste an" + ) + async def leaderboard_command( + self, + ctx: discord.ApplicationContext, + typ: discord.Option( + str, + description="Art der Rangliste", + choices=["global", "server"], + required=False, + default="server" + ), + limit: discord.Option( + int, + description="Anzahl der angezeigten User (max 20)", + min_value=5, + max_value=20, + required=False, + default=10 + ) + ): + """Show leaderboard for global or server stats.""" + await ctx.defer() + + try: + if typ == "global": + leaderboard_data = await self.db.get_leaderboard(limit) + title = "🌍 Globale Rangliste" + description = "Top User nach globalem Level & XP" + else: + leaderboard_data = await self.db.get_leaderboard(limit, ctx.guild.id) + title = f"🏢 {ctx.guild.name} Rangliste" + description = "Top User der letzten 30 Tage" + + if not leaderboard_data: + embed = discord.Embed( + title="📊 Keine Daten", + description="Keine Ranglisten-Daten verfügbar.", + color=discord.Color.orange() + ) + await ctx.followup.send(embed=embed) + return + + embed = discord.Embed( + title=title, + description=description, + color=discord.Color.gold() + ) + + leaderboard_text = "" + for i, data in enumerate(leaderboard_data, 1): + try: + user = self.bot.get_user(data[0]) + username = user.display_name if user else "Unbekannter User" + + # Position emoji + if i == 1: + pos_emoji = "🥇" + elif i == 2: + pos_emoji = "🥈" + elif i == 3: + pos_emoji = "🥉" + else: + pos_emoji = f"{i}." + + if typ == "global": + # Global leaderboard format: user_id, level, xp, messages, voice + level, xp, messages, voice = data[1], data[2], data[3], data[4] + level_emoji = self._get_level_emoji(level) + leaderboard_text += f"{pos_emoji} **{username}** {level_emoji}\n" + leaderboard_text += f" Level {level} • {int(xp):,} XP\n\n" + else: + # Server leaderboard format: user_id, messages, words + messages, words = data[1], data[2] + leaderboard_text += f"{pos_emoji} **{username}**\n" + leaderboard_text += f" {messages:,} Nachrichten • {words:,} Wörter\n\n" + + except Exception as e: + logger.error(f"Error processing leaderboard entry: {e}") + continue + + if leaderboard_text: + embed.description = leaderboard_text + else: + embed.description = "Fehler beim Laden der Rangliste." + + embed.set_footer(text=f"Angefragt von {ctx.author.display_name}") + await ctx.followup.send(embed=embed) + + except Exception as e: + logger.error(f"Error executing leaderboard command: {e}") + error_embed = discord.Embed( + title="❌ Fehler", + description="Es gab einen Fehler beim Laden der Rangliste.", + color=discord.Color.red() + ) + await ctx.followup.send(embed=error_embed, ephemeral=True) + + @stats.command( + name="achievements", + description="Zeige deine freigeschalteten Erfolge an" + ) + async def achievements_command( + self, + ctx: discord.ApplicationContext, + user: discord.Option( + discord.Member, + description="Erfolge eines anderen Users anzeigen", + required=False + ) + ): + """Show user achievements.""" + await ctx.defer() + + try: + target_user = user if user else ctx.author + global_info = await self.db.get_global_user_info(target_user.id) + + if not global_info: + embed = discord.Embed( + title="🏆 Keine Erfolge", + description=f"{'Du hast' if target_user == ctx.author else f'{target_user.display_name} hat'} noch keine Erfolge freigeschaltet.", + color=discord.Color.orange() + ) + await ctx.followup.send(embed=embed) + return + + achievements = global_info.get('achievements', []) + + if not achievements: + embed = discord.Embed( + title="🏆 Noch keine Erfolge", + description=f"{'Du hast' if target_user == ctx.author else f'{target_user.display_name} hat'} noch keine Erfolge freigeschaltet.\nWerde aktiver um Erfolge zu sammeln!", + color=discord.Color.blue() + ) + await ctx.followup.send(embed=embed) + return + + embed = discord.Embed( + title=f"🏆 {'Deine' if target_user == ctx.author else f'{target_user.display_name}s'} Erfolge", + description=f"**{len(achievements)}** Erfolge freigeschaltet", + color=discord.Color.gold() + ) + + # Group achievements by category or show all + achievement_text = "" + for ach in achievements: + icon = ach.get('icon', '🏆') + name = ach.get('name', 'Unbekannter Erfolg') + desc = ach.get('description', 'Keine Beschreibung') + unlocked = ach.get('unlocked_at', 'Unbekannt') + + achievement_text += f"{icon} **{name}**\n" + achievement_text += f" {desc}\n" + if unlocked != 'Unbekannt': + try: + unlock_date = datetime.fromisoformat(unlocked).strftime("%d.%m.%Y") + achievement_text += f" Freigeschaltet: {unlock_date}\n" + except: + pass + achievement_text += "\n" + + # Split into multiple fields if too long + if len(achievement_text) > 1024: + # Split achievements into chunks + chunks = [achievements[i:i + 5] for i in range(0, len(achievements), 5)] + for i, chunk in enumerate(chunks): + field_text = "" + for ach in chunk: + icon = ach.get('icon', '🏆') + name = ach.get('name', 'Unbekannter Erfolg') + field_text += f"{icon} **{name}**\n" + + embed.add_field( + name=f"Erfolge {i * 5 + 1}-{min((i + 1) * 5, len(achievements))}", + value=field_text, + inline=True + ) + else: + embed.description = achievement_text + + embed.set_thumbnail(url=target_user.display_avatar.url) + embed.set_footer(text=f"Angefragt von {ctx.author.display_name}") + + await ctx.followup.send(embed=embed) + + except Exception as e: + logger.error(f"Error executing achievements command: {e}") + error_embed = discord.Embed( + title="❌ Fehler", + description="Es gab einen Fehler beim Laden der Erfolge.", + color=discord.Color.red() + ) + await ctx.followup.send(embed=error_embed, ephemeral=True) + + @stats.command( + name="stats_info", + description="Informationen über das erweiterte Statistik-System" + ) + async def stats_info_command(self, ctx: discord.ApplicationContext): + """Provide information about the enhanced statistics system.""" + embed = discord.Embed( + title="ℹ️ Erweitertes Statistik-System", + description="Informationen über das Activity-Tracking & Level-System", + color=discord.Color.green() + ) + + embed.add_field( + name="📊 Was wird getrackt?", + value="• **Server-spezifisch:** Nachrichten & Voice-Zeit\n" + "• **Global:** Level, XP, Gesamtaktivität\n" + "• **Erweitert:** Wortanzahl, Anhänge, Streaks", + inline=False + ) + + embed.add_field( + name="🌍 Globales Level-System", + value="• **XP-Quellen:** Nachrichten (+1-6 XP), Voice-Chat (+0.5 XP/min)\n" + "• **Level:** Basiert auf Gesamt-XP über alle Server\n" + "• **Erfolge:** Automatisch für Meilensteine freigeschaltet", + inline=False + ) + + embed.add_field( + name="🏆 Verfügbare Kommandos", + value="• `/stats` - Server Aktivitäts-Statistiken\n" + "• `/globalstats` - Globale Level & Erfolge\n" + "• `/leaderboard` - Ranglisten (global/server)\n" + "• `/achievements` - Freigeschaltete Erfolge", + inline=False + ) + + embed.add_field( + name="🔒 Datenschutz", + value="• Nur Metadaten werden gespeichert (keine Inhalte)\n" + "• Automatische Bereinigung alter Daten nach 90 Tagen\n" + "• [Vollständige Datenschutzerklärung](https://medicopter117.github.io/ManagerX-Web/privacy.html)", + inline=False + ) + + embed.set_footer(text="Das globale Level-System funktioniert serverübergreifend!") + await ctx.respond(embed=embed, ephemeral=True) + + def _create_progress_bar(self, current: float, maximum: float, length: int = 10) -> str: + """Create a visual progress bar.""" + if maximum <= 0: + return "▓" * length + + filled = int((current / maximum) * length) + bar = "▓" * filled + "░" * (length - filled) + percentage = (current / maximum) * 100 + return f"{bar} {percentage:.1f}%" + + def _get_level_emoji(self, level: int) -> str: + """Get emoji based on user level.""" + if level >= 100: + return "👑" + elif level >= 50: + return "🏆" + elif level >= 25: + return "🏅" + elif level >= 10: + return "⭐" + elif level >= 5: + return "🌟" + else: + return "🔰" + + def _get_level_color(self, level: int) -> discord.Color: + """Get embed color based on user level.""" + if level >= 100: + return discord.Color.gold() + elif level >= 50: + return discord.Color.purple() + elif level >= 25: + return discord.Color.red() + elif level >= 10: + return discord.Color.orange() + elif level >= 5: + return discord.Color.green() + else: + return discord.Color.blue() + + +def setup(bot: commands.Bot): + """Setup function to add the enhanced cog to the bot.""" + bot.add_cog(EnhancedStatsCog(bot)) \ No newline at end of file diff --git a/src/bot/core/__init__.py b/src/bot/core/__init__.py index a269625..61d58e5 100644 --- a/src/bot/core/__init__.py +++ b/src/bot/core/__init__.py @@ -11,7 +11,6 @@ from .database import DatabaseManager from .dashboard import DashboardTask from .utils import print_logo, format_uptime, truncate_text -from .groups import * __all__ = [ 'ConfigLoader', diff --git a/translation/en_en.json b/translation/en_en.json new file mode 100644 index 0000000..f62542d --- /dev/null +++ b/translation/en_en.json @@ -0,0 +1,31 @@ +{ + "times": { + "min": "minute", + "sec": "second", + "hour": "hour", + "day": "day" + }, + "bot": { + "error_title": "⛔ ⨯ Error", + "error": "An **unexpected error** has occurred. {}\nI have informed my developer about this issue.", + "cooldown_title": "⌛ ⨯ Cooldown", + "cooldown": "Try again {}.", + "no_perms_title": "\uD83D\uDEA8 ⨯ Missing permissions", + "no_perms": "I'm missing the following permissions to execute this command.", + "no_user_perms": "You do not have permission to run this command." + }, + "help": { + "cmd_name": "help", + "cmd_description": "\uD83D\uDD25 Displays a list of all commands.", + "wrong_user": "This help command does not belong to you!", + "placeholder": "\uD83D\uDD30 › Choose a category", + "embed_title": "My commands", + "default_description": "All commands of category **{}**.", + "no_commands": "Oops, I couldn't find any commands that you have access to." + }, + "blacklist": { + "admin_group": "admin", + "no_perms": "You have been banned from using this bot. If you think this is a mistake, please contact my owner.", + "guild_error": "I left your server **{}** because you don't have permissions to use me. If you think this is a mistake, please contact my owner." + } +} \ No newline at end of file diff --git a/translation/ez_de.json b/translation/ez_de.json new file mode 100644 index 0000000..b4f48bb --- /dev/null +++ b/translation/ez_de.json @@ -0,0 +1,32 @@ + +{ + "times": { + "min": "Minute", + "sec": "Sekunde", + "hour": "Stunde", + "day": "Tag" + }, + "bot": { + "error_title": "⛔ ⨯ Error", + "error": "Ein **unbekannter Fehler** ist aufgetreten. {}\nIch habe meinen Entwickler über dieses Problem informiert.", + "cooldown_title": "⌛ ⨯ Cooldown", + "cooldown": "Versuche es {} erneut.", + "no_perms_title": "\uD83D\uDEA8 ⨯ Fehlende Rechte", + "no_perms": "Mir fehlen die folgenden Berechtigungen, um diesen Befehl auszuführen.", + "no_user_perms": "Du hast keine Rechte, um diesen Befehl auszuführen." + }, + "help": { + "cmd_name": "help", + "cmd_description": "\uD83D\uDD25 Zeigt eine Liste aller Befehle an.", + "wrong_user": "Dieser Help Command gehört dir nicht!", + "placeholder": "\uD83D\uDD30 › Wähle eine Kategorie", + "embed_title": "Meine Befehle", + "default_description": "Alle Befehle der Kategorie **{}**.", + "no_commands": "Ups, ich konnte keine Befehle finden, auf die du Zugriff hast." + }, + "blacklist": { + "admin_group": "admin", + "no_perms": "Du wurdest von der Nutzung dieses Bots ausgeschlossen. Wenn du denkst, dass dies ein Fehler ist, kontaktiere meinen Owner.", + "guild_error": "Ich habe deinen Server **{}** verlassen, da du keine Rechte hast, um mich zu nutzen. Wenn du denkst, dass dies ein Fehler ist, kontaktiere meinen Owner." + } +} \ No newline at end of file diff --git a/translation/messages/de.yaml b/translation/messages/de.yaml new file mode 100644 index 0000000..fc883c2 --- /dev/null +++ b/translation/messages/de.yaml @@ -0,0 +1,122 @@ +general: + + error_types: + no_permission: "Du hast keine Berechtigung, diesen Befehl auszuführen." + user_not_found: "Benutzer nicht gefunden." + bot_error: "Ein Fehler ist im Bot aufgetreten. Bitte versuche es später erneut." + option_missing: "Eine erforderliche Option fehlt." + bot_has_no_permission: "Der Bot hat nicht die erforderlichen Berechtigungen, um diesen Befehl auszuführen." + + +cog_settings: + language: + error_types: + unsupported_language: "Die angegebene Sprache wird nicht unterstützt." + same_language: "Die angegebene Sprache ist bereits eingestellt." + failed_set_language: "Fehler beim Einstellen der Sprache." + language_not_set: "Du hast noch keine bevorzugte Sprache eingestellt." + + + message: + language_set: "Die Sprache wurde auf {language} gesetzt." + current_language: "Deine aktuelle bevorzugte Sprache ist {language}." + +######################################################### +# Fun Cogs +######################################################### + +cog_4gewinnt: + error_types: + not_your_turn: "Es ist nicht dein Zug." + this_column_full: "Diese Spalte ist voll!" + is_opponent_bot: "Du kannst nicht gegen einen Bot spielen!" + is_opponent_self: "Du kannst nicht gegen dich selbst spielen!" + + + + win_types: + win: "Spiel vorbei! {winner} hat gewonnen!\n\n{board_str}" + draw: "Unentschieden!\n\n{board_str}" + + message: + start_game: "4 Gewinnt: {author_mention} (🔴) vs {opponent_mention} (🟡)\n{author_mention} fängt an!\n\n" + player_turn: "{view.current_player.mention} ist jetzt dran!\n\n{board_str}" + + + +cog_tictactoe: + error_types: + not_your_turn: "Es ist nicht dein Zug." + this_cell_taken: "Dieses Feld ist bereits belegt!" + is_opponent_bot: "Du kannst nicht gegen einen Bot spielen!" + is_opponent_self: "Du kannst nicht gegen dich selbst spielen!" + + + + win_types: + win: "Spiel vorbei! {winner} hat gewonnen!" + draw: "Unentschieden!" + + message: + start_game: "Tic Tac Toe: {author_mention} (X) gegen {opponent_mention} (O)\n{author_mention} fängt an!" + +cog_weather: + error_types: + city_not_found: "⚠️ Stadt nicht gefunden." + api_error: "❌ Fehler bei der Wetter-API." + + messages: + weather_report: "# 🌤️ Wetterbericht für {city}, {country}:\n\n" + temperature: "**🌡️ Temperatur:** {temperature}°C\n" + humidity: "**💧 Luftfeuchtigkeit:** {humidity}%\n" + wind_speed: "**💨 Windgeschwindigkeit:** {wind_speed} km/h ({wind_dir})\n" + condition: "**☁️ Wetterbedingungen:** {condition}\n" + visibility: "**🌫️ Sichtweite:** {visibility} km\n" + pressure: "**🧭 Luftdruck:** {pressure} hPa\n" + +cog_autorole: + error_types: + role_to_high: + title: "❌ Fehler" + desc: "Ich kann diese Rolle nicht vergeben, da sie höher oder gleich meiner höchsten Rolle ist!" + role_managed: + title: "❌ Fehler" + desc: "Diese Rolle wird von einer Integration verwaltet und kann nicht als Autorole hinzugefügt werden!" + not_found: + title: "❌ Autorole nicht gefunden" + desc: "Es existiert keine Autorole mit der ID `{autorole_id}`!" + wrong_guild: + title: "❌ Fehler" + desc: "Diese Autorole gehört nicht zu diesem Server!" + no_roles: + title: "❌ Keine Autoroles" + desc: "Es sind keine Autoroles für diesen Server eingerichtet!" + role_deleted: + title: "⚠️ Rolle nicht gefunden" + desc: "Die konfigurierte Rolle für `{autorole_id}` existiert nicht mehr!" + + messages: + add_success: + title: "✅ Autorole hinzugefügt" + desc: "Neue Mitglieder erhalten automatisch die Rolle {role}\n\n**Autorole-ID:** `{autorole_id}`" + remove_success: + title: "✅ Autorole entfernt" + desc: "Die Autorole `{autorole_id}` wurde erfolgreich entfernt!" + toggle_success: + enabled_title: "✅ Autorole aktiviert" + enabled_desc: "Die Autorole `{autorole_id}` wurde aktiviert!" + disabled_title: "✅ Autorole deaktiviert" + disabled_desc: "Die Autorole `{autorole_id}` wurde deaktiviert!" + list: + title: "📋 Autoroles Liste" + desc: "Alle Autoroles für **{guild_name}**" + role_deleted: "⚠️ **Rolle gelöscht**" + info: + title: "ℹ️ Autorole Information" + desc: "Details zur Autorole `{autorole_id}`" + + system: + audit_reason: "Autorole System" + console_log: "✅ Autoroles [{role_names}] wurden {member_name} zugewiesen" + error_forbidden: "❌ Keine Berechtigung, um Rollen zu vergeben" + error_http: "❌ Fehler beim Zuweisen der Rollen: {error}" \ No newline at end of file diff --git a/translation/messages/en.yaml b/translation/messages/en.yaml new file mode 100644 index 0000000..495906d --- /dev/null +++ b/translation/messages/en.yaml @@ -0,0 +1,119 @@ +general: + error_types: + no_permission: "You do not have permission to execute this command." + user_not_found: "User not found." + bot_error: "An error occurred within the bot. Please try again later." + option_missing: "A required option is missing." + bot_has_no_permission: "The bot does not have the necessary permissions to execute this command." + +cog_settings: + language: + error_types: + unsupported_language: "The specified language is not supported." + same_language: "The specified language is already set." + failed_set_language: "Failed to set the language." + language_not_set: "You have not set a preferred language yet." + + message: + language_set: "Language has been set to {language}." + current_language: "Your current preferred language is {language}." + +################################################################### +# +# Cog Folder: cogs/fun +# +#################################################################### +cog_4gewinnt: + error_types: + not_your_turn: "It's not your turn." + this_column_full: "This column is full!" + is_opponent_bot: "You cannot play against a bot!" + is_opponent_self: "You cannot play against yourself!" + + win_types: + win: "Game over! {winner} won!\n\n{board_str}" + draw: "It's a draw!\n\n{board_str}" + + message: + start_game: "Connect Four: {author_mention} (🔴) vs {opponent_mention} (🟡)\n{author_mention} starts!\n\n" + player_turn: "{view.current_player.mention}'s turn now!\n\n{board_str}" + +cog_tictactoe: + error_types: + not_your_turn: "It's not your turn." + this_cell_taken: "This cell is already taken!" + is_opponent_bot: "You cannot play against a bot!" + is_opponent_self: "You cannot play against yourself!" + + win_types: + win: "Game over! {winner} won!" + draw: "It's a draw!" + + message: + # NEU: Nur einfache Schlüssel verwenden + start_game: "Tic Tac Toe: {author_mention} (X) vs {opponent_mention} (O)\n{author_mention} starts!" + +cog_weather: + error_types: + city_not_found: "⚠️ City not found." + api_error: "❌ Error with the weather API." + + messages: + weather_report: "# 🌤️ Weather report for {city}, {country}:\n\n" + temperature: "**🌡️ Temperature:** {temperature}°C\n" + humidity: "**💧 Humidity:** {humidity}%\n" + wind_speed: "**💨 Wind:** {wind_speed} km/h ({wind_dir})\n" + condition: "**☁️ Condition:** {condition}\n" + visibility: "**🌫️ Visibility:** {visibility} km\n" + pressure: "**🧭 Pressure:** {pressure} hPa\n" + +######################################################### +# Server Management +######################################################### + +cog_autorole: + error_types: + role_to_high: + title: "❌ Error" + desc: "I cannot assign this role because it is higher than or equal to my highest role!" + role_managed: + title: "❌ Error" + desc: "This role is managed by an integration and cannot be added as an autorole!" + not_found: + title: "❌ Autorole Not Found" + desc: "There is no autorole with the ID `{autorole_id}`!" + wrong_guild: + title: "❌ Error" + desc: "This autorole does not belong to this server!" + no_roles: + title: "❌ No Autoroles" + desc: "No autoroles have been set up for this server!" + role_deleted: + title: "⚠️ Role Not Found" + desc: "The configured role for `{autorole_id}` no longer exists!" + + messages: + add_success: + title: "✅ Autorole Added" + desc: "New members will now automatically receive the role {role}\n\n**Autorole-ID:** `{autorole_id}`" + remove_success: + title: "✅ Autorole Removed" + desc: "The autorole `{autorole_id}` was successfully removed!" + toggle_success: + enabled_title: "✅ Autorole Enabled" + enabled_desc: "The autorole `{autorole_id}` has been enabled!" + disabled_title: "✅ Autorole Disabled" + disabled_desc: "The autorole `{autorole_id}` has been disabled!" + list: + title: "📋 Autoroles List" + desc: "All autoroles for **{guild_name}**" + role_deleted: "⚠️ **Role deleted**" + info: + title: "ℹ️ Autorole Information" + desc: "Details for autorole `{autorole_id}`" + + system: + audit_reason: "Autorole System" + console_log: "✅ Autoroles [{role_names}] were assigned to {member_name}" + error_forbidden: "❌ Missing permissions to assign roles" + error_http: "❌ Error while assigning roles: {error}" \ No newline at end of file