From 66421137a3afaded0ad05af7c477cf94d2711e13 Mon Sep 17 00:00:00 2001 From: Medicopter117 Date: Tue, 31 Mar 2026 17:50:04 +0200 Subject: [PATCH 1/2] feat: implement modular bot architecture with core services, cogs, and GitHub CI/CD workflows --- .github/workflows/codeql.yml | 2 +- .github/workflows/deploy.yml | 48 +++++++ .github/workflows/label.yml | 2 +- .github/workflows/static.yml | 2 +- config/config.yaml | 173 ++++++++++++++++--------- config/example.env | 2 +- main.py | 57 +++++--- public/robots.txt | 2 +- src/bot/cogs/bot/about.py | 11 +- src/bot/cogs/bot/admin.py | 14 +- src/bot/cogs/bot/botjoinevent.py | 3 +- src/bot/cogs/bot/server_join_alert.py | 3 +- src/bot/cogs/bot/server_leave_alert.py | 3 +- src/bot/cogs/guild/globalchat.py | 31 +++-- src/bot/cogs/guild/levelsystem.py | 26 ++-- src/bot/cogs/management/autodelete.py | 2 + src/bot/cogs/moderation/moderation.py | 13 +- src/bot/cogs/user/stats.py | 5 +- src/bot/core/bot_setup.py | 21 +-- src/bot/core/cog_manager.py | 87 +++++-------- src/bot/core/config.py | 151 ++++++++++++--------- 21 files changed, 411 insertions(+), 247 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9211d63..fdb5f06 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,7 +9,7 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL Advanced" +name: "CodeQL" on: push: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a1a66e6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Pterodactyl Auto-Deploy + +on: + push: + branches: + - main + +jobs: + deploy: + name: Deploy to Pterodactyl + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Deploy via SFTP + uses: appleboy/scp-action@master + with: + host: ${{ secrets.PTERO_HOST }} + port: ${{ secrets.PTERO_PORT }} + username: ${{ secrets.PTERO_USER }} + password: ${{ secrets.PTERO_PASSWORD }} + source: "./*" + target: "/" + # List of files/folders to EXCLUDE from deployment + # .env and data/ are never pushed to keep secrets and DB safe + rm: false # Set to true if you want to delete files on server that are not in repo (CAUTION: might delete data/ if not careful) + strip_components: 0 + overwrite: true + exclude: | + .env + .env.* + .git/ + .github/ + data/ + logs/ + __pycache__/ + *.pyc + *.sqlite3 + *.db + .idea/ + .vscode/ + build/ + dist/ + *.log + node_modules/ + DevTools/ + tests/ diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index ccfdb88..56be458 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -1,6 +1,6 @@ # .github/workflows/label.yml -name: Labeler +name: Labeling on: [pull_request] jobs: diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index c1867a5..1a89f90 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -1,5 +1,5 @@ # Workflow für ManagerX React/TSX Deployment -name: Deploy static content to Pages +name: Webseite on: push: diff --git a/config/config.yaml b/config/config.yaml index dd96522..2b9f255 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,88 +1,143 @@ -# ManagerX Bot Configuration -# Diese Datei steuert die Funktionen des Bots +# ManagerX Configuration +# ====================== -# Ob der Bot überhaupt aktiviert ist -enabled: true +# Bot Basics +bot: + enabled: true + name: "ManagerX" + version: "2.0.0" + language: "de" + prefix: "!mx " + maintenance_mode: false + status_text: "ManagerX v{version}" + status_type: "watching" # watching, playing, listening, streaming -# Version des Bots (wird in der Presence angezeigt) -version: "2.0.0" +# Security & Permissions +security: + allowed_ids: [1427994077332373554] + bot_owners: [1093555256689959005, 1427994077332373554] -# Features aktivieren/deaktivieren -features: - # Update-Checker aktivieren - update_checker: true +# UI & Branding +ui: + colors: + primary: [46, 204, 113] # Emerald Green + success: [46, 204, 113] + error: [231, 76, 60] # Alizarin Red + warning: [241, 196, 15] # Sunflower Yellow + info: [52, 152, 219] # Peter River Blue + prestige: [255, 105, 180] # Hot Pink + footer_text: "ManagerX • Empowering your Community" - # Bot-Status in Presence anzeigen - bot_status: true +# API & Webserver +api: + host: "0.0.0.0" + port: 8040 + log_level: "error" + cors_origins: ["*"] + +# Translation Settings +translation: + path: "translation/messages" + default_lang: "de" + fallback_langs: ["en", "de"] + +# Module: Leveling System +leveling: + default_min_xp: 10 + default_max_xp: 20 + default_cooldown: 30 + prestige_min_level: 50 + cleanup_interval_hours: 1 + leaderboard_max_users: 50 + +# Module: Moderation +moderation: + max_warnings: 5 + mute_role_name: "Muted" + antispam_threshold: 5 + default_timeout_minutes: 5 + log_colors: + ban: [255, 0, 0] + kick: [231, 76, 60] + timeout: [241, 196, 15] + untimeout: [46, 204, 113] + slowmode_on: [52, 152, 219] + slowmode_off: [46, 204, 113] + +# Module: GlobalChat +global_chat: + rate_limit_messages: 15 + rate_limit_seconds: 60 + cache_duration: 180 + cleanup_days: 30 + max_message_length: 1900 + default_color: "#5865F2" + max_file_size_mb: 25 + cleanup_interval_hours: 12 + nsfw_keywords: ['nsfw', 'porn', 'sex', 'xxx', 'nude', 'hentai', 'dick', 'pussy', 'cock', 'tits', 'ass', 'fuck'] - # Cogs (Module) aktivieren/deaktivieren +# Logging & Storage +logging: + server_leave_channel: 1429164270435700849 + server_join_channel: 1429163147687886889 + audit_log_file: "data/admin_audit.json" + blacklist_file: "data/blacklist.json" + cogs_path: "src/bot/cogs" + data_path: "data" + +# Links +links: + website: "https://managerx-bot.de" + support: "https://discord.gg/9T28DWup3g" + invite: "https://discord.gg/9T28DWup3g" + github: "https://github.com/ManagerX-Development/ManagerX" + topgg: "https://top.gg/bot/1368201272624287754" + +# Performance & Cache +performance: + cache_timeout: 300 + db_cleanup_days: 30 + +# Database & Backup +database: + backup_enabled: true + backup_interval_hours: 24 + max_retention: 7 + backup_path: "data/backups" + +# Intervals (in hours unless specified) +intervals: + leveling_cleanup: 1 + global_chat_cleanup: 12 + stats_update: 24 + autodelete_check_seconds: 30 + +# Feature Toggles & Cogs +features: + bot_status: true cogs: - # Spaß-Commands fun: gewinnt: true tictactoe: true weather: true - # Wikipedia-Modul (vollständiges Package mit 8 Submodulen) - # Wenn aktiviert: Lädt alle Wikipedia-Features (Suche, Zufall, Multi-Suche, etc.) - # Wenn deaktiviert: Wikipedia-Commands sind nicht verfügbar - wikipedia: true # ← Hier auf false setzen zum Deaktivieren - - # Informations-Commands + wikipedia: true information: botstatus: true serverinfo: true usermanagemt: true - - # Moderations-Features moderation: antispam: true moderation: true notes: true warningsystem: true - - # Server-Management server_management: autodelete: true + database_backup: true globalchat: true levelsystem: true logging: true stats: true tempvc: true welcome: true - - # Entwickler-Tools - dev_tools: - logging: true - emojis: true - - # Sonstige other: setlang: true - -# Bot-Verhalten und Limits -bot_behavior: - command_prefix: "!" # Fallback-Prefix für nicht-slash Commands - global_cooldown_seconds: 5 # Cooldown zwischen Commands pro User - max_messages_per_minute: 10 # Anti-Spam-Limit pro User - maintenance_mode: false # Wartungsmodus (deaktiviert alle Commands) - -# UI und Embeds -ui: - embed_color: "#00ff00" # Hex-Farbe für Embeds (z.B. grün) - footer_text: "ManagerX Bot" # Standard-Footer-Text - theme: "dark" # dark, light, custom - show_timestamps: true # Timestamps in Embeds anzeigen - -# Sicherheit und Permissions -security: - required_permissions: ["manage_messages", "kick_members"] # Erforderliche Permissions für Moderations-Commands - blacklist_servers: [] # Liste verbotener Server-IDs - whitelist_users: [] # Erlaubte User-IDs (für private Bots) - enable_command_logging: true # Commands loggen - -# Performance und Ressourcen -performance: - max_concurrent_tasks: 10 # Max. gleichzeitige Tasks - task_timeout_seconds: 30 # Timeout für langlaufende Tasks - memory_limit_mb: 512 # Memory-Limit für den Bot - enable_gc_optimization: true # Garbage Collection optimieren \ No newline at end of file diff --git a/config/example.env b/config/example.env index e404e7d..7d77fa2 100644 --- a/config/example.env +++ b/config/example.env @@ -6,7 +6,7 @@ DISCORD_CLIENT_ID=12345678900 DISCORD_CLIENT_SECRET=abc123 DISCORD_REDIRECT_URI=https:// -URL=https://my-super-discord-bot.com +URL=https://discord.com/oauth2/authorize? DASHBOARD_API_KEYS=abc123 diff --git a/main.py b/main.py index 46df1c9..618f6c1 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,9 @@ # ============================================================================= # SETUP # ============================================================================= +# ============================================================================= +# SETUP & CONFIG +# ============================================================================= BASEDIR = Path(__file__).resolve().parent load_dotenv(dotenv_path=BASEDIR / 'config' / '.env') @@ -39,16 +42,20 @@ from src.bot.core.dashboard import DashboardTask from src.bot.core.utils import print_logo -# API Routes für Dashboard +# Early config load (must happen before module-level usage) +config_loader = ConfigLoader(BASEDIR) +config = config_loader.load() + +# API Routes & Translation from src.api.dashboard.routes import set_bot_instance, dashboard_main_router, router_public from mx_handler import TranslationHandler colorama_init(autoreset=True) TranslationHandler.settings( - path="translation/messages", - default_lang="de", - fallback_langs=("en", "de"), + path=BotConfig.LANG_PATH, + default_lang=BotConfig.DEFAULT_LANG, + fallback_langs=BotConfig.FALLBACK_LANGS, logging=False, colored=False, log_level="DEBUG" @@ -70,7 +77,7 @@ # CORS aktivieren app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=BotConfig.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -81,11 +88,16 @@ app.include_router(router_public) async def start_webserver(): - """Startet den FastAPI Webserver auf Port 8040""" - config = Config(app=app, host="0.0.0.0", port=8040, log_level="error") - server = Server(config) + """Startet den FastAPI Webserver""" + server_config = Config( + app=app, + host=BotConfig.API_HOST, + port=BotConfig.API_PORT, + log_level=BotConfig.API_LOG_LEVEL + ) + server = Server(server_config) await server.serve() - logger.success("API", "FastAPI-Server läuft auf http://0.0.0.0:8040") + logger.success("API", f"FastAPI-Server läuft auf http://{BotConfig.API_HOST}:{BotConfig.API_PORT}") # ============================================================================= # MAIN EXECUTION @@ -94,11 +106,7 @@ async def start_webserver(): # Logo ausgeben print_logo() - # Konfiguration laden - logger.info("BOT", "Lade Konfiguration...") - config_loader = ConfigLoader(BASEDIR) - config = config_loader.load() - logger.success("BOT", "Konfiguration geladen") + logger.info("BOT", "Konfiguration bereits geladen (Early Load)") # Bot erstellen logger.info("BOT", "Initialisiere Bot...") @@ -134,7 +142,7 @@ async def on_ready(): dashboard.start() # Bot-Status - if config['features'].get('bot_status', True): + if BotConfig.features.get('bot_status', True): await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.watching, @@ -151,6 +159,23 @@ async def on_ready(): logger.info("LIMITS", f"Discord-API Slots belegt: {len(root_slots)} / 100") # --- LIMIT CHECK ENDE --- + @bot.before_invoke + async def maintenance_check(ctx: discord.ApplicationContext): + """Global check for maintenance mode.""" + if BotConfig.bot.maintenance_mode: + # Owners are exempt + if ctx.author.id in BotConfig.security.bot_owners: + return + + embed = discord.Embed( + title="🔧 Wartungsmodus", + description="Der Bot befindet sich aktuell im Wartungsmodus.\nBitte versuche es später erneut.", + color=discord.Color.from_rgb(*BotConfig.ui.colors.warning) + ) + embed.add_field(name="Support", value=BotConfig.links.support) + await ctx.respond(embed=embed, ephemeral=True) + raise commands.CheckFailure("Maintenance Mode Active") + @bot.event async def on_application_command_completion(ctx: discord.ApplicationContext): """Track command usage across all guilds.""" @@ -174,7 +199,7 @@ async def on_ready(self): # Cogs laden logger.info("BOT", "Lade Cogs...") - cog_manager = CogManager(config['cogs']) + cog_manager = CogManager(BotConfig.features.get('cogs', {})) ignored = cog_manager.get_ignored_cogs() bot.load_cogs( diff --git a/public/robots.txt b/public/robots.txt index daf47b6..4a7f1b8 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://managerx.bot/sitemap.xml +Sitemap: https://managerx-bot.de/sitemap.xml diff --git a/src/bot/cogs/bot/about.py b/src/bot/cogs/bot/about.py index 758f20f..4c9e617 100644 --- a/src/bot/cogs/bot/about.py +++ b/src/bot/cogs/bot/about.py @@ -33,8 +33,9 @@ async def about(self, ctx: discord.ApplicationContext): server_count = len(self.bot.guilds) member_count = sum(g.member_count for g in self.bot.guilds) + from src.bot.core.config import BotConfig # Create Container - container = discord.ui.Container(color=discord.Color.red()) + container = discord.ui.Container(color=discord.Color.from_rgb(*BotConfig.ui.colors.info)) # Header title = await TranslationHandler.get_for_user(self.bot, ctx.author.id, "cog_about.messages.title") @@ -113,12 +114,16 @@ async def about(self, ctx: discord.ApplicationContext): # Footer footer = await TranslationHandler.get_for_user(self.bot, ctx.author.id, "cog_about.messages.footer", year=datetime.now().year, - version="2.0.0" + version=BotConfig.bot.version ) container.add_text(footer) - # Send View + # Buttons view = discord.ui.DesignerView(container, timeout=0) + view.add_item(discord.ui.Button(label="Website", url=BotConfig.links.website, style=discord.ButtonStyle.link)) + view.add_item(discord.ui.Button(label="Invite", url=BotConfig.links.invite, style=discord.ButtonStyle.link)) + view.add_item(discord.ui.Button(label="Support", url=BotConfig.links.support, style=discord.ButtonStyle.link)) + await ctx.respond(view=view) def setup(bot): diff --git a/src/bot/cogs/bot/admin.py b/src/bot/cogs/bot/admin.py index 09b36cc..49bd716 100644 --- a/src/bot/cogs/bot/admin.py +++ b/src/bot/cogs/bot/admin.py @@ -2,6 +2,7 @@ from discord import SlashCommandGroup, Option import ezcord from discord.ui import Container, View, Button, Modal, InputText +from src.bot.core.config import BotConfig import sys import os import psutil @@ -15,11 +16,10 @@ from typing import Optional, List import time -ALLOWED_IDS = [1427994077332373554] - -# Audit Log Storage -AUDIT_LOG_FILE = Path("data/admin_audit.json") -BLACKLIST_FILE = Path("data/blacklist.json") +# Configuration (Centralized) +ALLOWED_IDS = BotConfig.ALLOWED_IDS +AUDIT_LOG_FILE = BotConfig.AUDIT_LOG_FILE +BLACKLIST_FILE = BotConfig.BLACKLIST_FILE @@ -257,8 +257,8 @@ class admin(ezcord.Cog, hidden=True): def __init__(self, bot): self.bot = bot self.start_time = datetime.now() - self.cogs_path = Path("src/bot/cogs") - self.data_path = Path("data") + self.cogs_path = BotConfig.COGS_PATH + self.data_path = BotConfig.DATA_PATH self.data_path.mkdir(exist_ok=True) # Lade Blacklist diff --git a/src/bot/cogs/bot/botjoinevent.py b/src/bot/cogs/bot/botjoinevent.py index 2040be4..b714651 100644 --- a/src/bot/cogs/bot/botjoinevent.py +++ b/src/bot/cogs/bot/botjoinevent.py @@ -1,6 +1,7 @@ import discord from discord.ui import Container # Dein ezcord/discord.ui Import import ezcord +from src.bot.core.config import BotConfig class BotJoinEvents(ezcord.Cog): def __init__(self, bot): @@ -33,7 +34,7 @@ async def on_guild_join(self, guild): container.add_separator() container.add_text("### 🔗 Links") - container.add_text("🔗 [Website](https://managerx-bot.de) × [Top.gg](https://top.gg/bot/1368201272624287754) × [Support](https://discord.gg/9T28DWup3g) × [GitHub](https://github.com/ManagerX-Development/ManagerX)") + container.add_text(f"🔗 [Website]({BotConfig.links.website}) × [Top.gg]({BotConfig.links.topgg}) × [Support]({BotConfig.links.support}) × [GitHub]({BotConfig.links.github})") # Die DesignerView für den Container erstellen view = discord.ui.DesignerView(container, timeout=0) diff --git a/src/bot/cogs/bot/server_join_alert.py b/src/bot/cogs/bot/server_join_alert.py index 088d266..d771fe9 100644 --- a/src/bot/cogs/bot/server_join_alert.py +++ b/src/bot/cogs/bot/server_join_alert.py @@ -1,6 +1,7 @@ import discord from discord.ui import Container, DesignerView import ezcord +from src.bot.core.config import BotConfig class JoinAlert(ezcord.Cog): def __init__(self, bot): @@ -28,7 +29,7 @@ async def on_guild_join(self, guild: discord.Guild): ) # Die Channel-ID von dir - log_channel_id = 1429163147687886889 + log_channel_id = BotConfig.JOIN_LOG_CHANNEL log_channel = self.bot.get_channel(log_channel_id) if log_channel: diff --git a/src/bot/cogs/bot/server_leave_alert.py b/src/bot/cogs/bot/server_leave_alert.py index b26a546..ed800b6 100644 --- a/src/bot/cogs/bot/server_leave_alert.py +++ b/src/bot/cogs/bot/server_leave_alert.py @@ -1,12 +1,13 @@ import discord from discord.ui import Container, DesignerView import ezcord +from src.bot.core.config import BotConfig class LeaveAlert(ezcord.Cog): def __init__(self, bot): self.bot = bot # Dein Log-Kanal für Abgänge - self.log_channel_id = 1429164270435700849 + self.log_channel_id = BotConfig.LEAVE_LOG_CHANNEL @discord.Cog.listener() async def on_guild_remove(self, guild: discord.Guild): diff --git a/src/bot/cogs/guild/globalchat.py b/src/bot/cogs/guild/globalchat.py index 4f5f03e..6118e51 100644 --- a/src/bot/cogs/guild/globalchat.py +++ b/src/bot/cogs/guild/globalchat.py @@ -16,6 +16,7 @@ import ezcord from collections import defaultdict from discord.ui import Container +from src.bot.core.config import BotConfig # Logger konfigurieren logger = logging.getLogger(__name__) @@ -23,34 +24,31 @@ 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' + # Bot Owner IDs + BOT_OWNERS = BotConfig.BOT_OWNERS + + # Rate Limits + RATE_LIMIT_MESSAGES = BotConfig.GC_RATE_LIMIT_MSGS + RATE_LIMIT_SECONDS = BotConfig.GC_RATE_LIMIT_SECS + CACHE_DURATION = BotConfig.GC_CACHE_DURATION + CLEANUP_DAYS = BotConfig.GC_CLEANUP_DAYS + DEFAULT_MAX_MESSAGE_LENGTH = BotConfig.GC_MAX_MSG_LEN + DEFAULT_EMBED_COLOR = BotConfig.GC_DEFAULT_COLOR + MAX_FILE_SIZE_MB = BotConfig.GC_MAX_FILE_SIZE - # Medien-Limits - MAX_FILE_SIZE_MB = 25 # Discord-Standard + MIN_MESSAGE_LENGTH = 0 # Erlaube Nachrichten ohne Text (nur Medien) 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' - ] + NSFW_KEYWORDS = BotConfig.GC_NSFW_KEYWORDS class MediaHandler: @@ -806,6 +804,7 @@ def __init__(self, bot): ) self._cached_channels = None # Wird bei der ersten Nachricht geladen self.sender = GlobalChatSender(self.bot, self.config, self.embed_builder) + self.cleanup_task.change_interval(hours=BotConfig.intervals.global_chat_cleanup_12) self.cleanup_task.start() @tasks.loop(hours=12) diff --git a/src/bot/cogs/guild/levelsystem.py b/src/bot/cogs/guild/levelsystem.py index 76662df..523522c 100644 --- a/src/bot/cogs/guild/levelsystem.py +++ b/src/bot/cogs/guild/levelsystem.py @@ -30,14 +30,14 @@ async def confirm_prestige(self, interaction: discord.Interaction, button: disco 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 + color=discord.Color.from_rgb(*self.config.ui.colors.prestige) ) 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 + color=discord.Color.from_rgb(*self.config.ui.colors.error) ) await interaction.response.edit_message(embed=embed, view=None) @@ -51,7 +51,7 @@ async def cancel_prestige(self, interaction: discord.Interaction, button: discor embed = discord.Embed( title="❌ Prestige abgebrochen", description="Das Prestige wurde abgebrochen.", - color=0x999999 + color=discord.Color.from_rgb(*self.config.ui.colors.info) ) await interaction.response.edit_message(embed=embed, view=None) @@ -59,10 +59,14 @@ async def cancel_prestige(self, interaction: discord.Interaction, button: discor class LevelSystem(ezcord.Cog): def __init__(self, bot): self.bot = bot + from src.bot.core.config import BotConfig + self.config = BotConfig self.db = LevelDatabase() self.xp_cooldowns = {} # User-ID -> Timestamp # Starte Background Tasks + self.cleanup_expired_boosts.change_interval(hours=self.config.INT_LVL_CLEANUP) + self.cleanup_temporary_roles.change_interval(hours=self.config.INT_LVL_CLEANUP) self.cleanup_expired_boosts.start() self.cleanup_temporary_roles.start() @@ -76,7 +80,7 @@ def cog_unload(self): xpboost = SlashCommandGroup("xpboost", "Verwalte XP-Boosts") levelconfig = SlashCommandGroup("levelconfig", "Konfiguriere das Levelsystem") - @tasks.loop(hours=1) + @tasks.loop(hours=1) # Wird über config.py beim Start geladen, statische Definition bleibt async def cleanup_expired_boosts(self): """Entfernt abgelaufene XP-Boosts""" # Hier würde die DB-Cleanup Logik implementiert werden @@ -90,13 +94,13 @@ async def cleanup_temporary_roles(self): 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 = discord.Embed(color=discord.Color.from_rgb(*self.config.ui.colors.success)) 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.color = discord.Color.from_rgb(*self.config.ui.colors.warning) embed.set_thumbnail(url=user.avatar.url if user.avatar else user.default_avatar.url) return embed @@ -124,8 +128,8 @@ async def on_message(self, message): current_time = time.time() # Guild-Konfiguration holen - config = self.db.get_guild_config(guild_id) - cooldown = config.get('cooldown', 30) + guild_config = self.db.get_guild_config(guild_id) + cooldown = guild_config.get('cooldown', self.config.leveling.default_cooldown) # XP-Cooldown prüfen if user_id in self.xp_cooldowns: @@ -136,8 +140,8 @@ async def on_message(self, message): 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) + min_xp = guild_config.get('min_xp', self.config.leveling.default_min_xp) + max_xp = guild_config.get('max_xp', self.config.leveling.default_max_xp) base_xp = random.randint(min_xp, max_xp) final_xp = int(base_xp * channel_multiplier) @@ -329,7 +333,7 @@ async def prestige(self, ctx): return user_stats = self.db.get_user_stats(ctx.author.id, ctx.guild.id) - min_level = config.get('prestige_min_level', 50) + min_level = config.get('prestige_min_level', self.config.leveling.prestige_min_level) if not user_stats or user_stats[1] < min_level: embed = discord.Embed( diff --git a/src/bot/cogs/management/autodelete.py b/src/bot/cogs/management/autodelete.py index c1061ff..45009d8 100644 --- a/src/bot/cogs/management/autodelete.py +++ b/src/bot/cogs/management/autodelete.py @@ -13,6 +13,8 @@ class AutoDelete(ezcord.Cog): def __init__(self, bot): self.bot = bot + from src.bot.core.config import BotConfig + self.delete_task.change_interval(seconds=BotConfig.intervals.autodelete_check_seconds) self.delete_task.start() self.processing_channels = set() # Verhindert doppelte Verarbeitung diff --git a/src/bot/cogs/moderation/moderation.py b/src/bot/cogs/moderation/moderation.py index 5daf98c..054abe2 100644 --- a/src/bot/cogs/moderation/moderation.py +++ b/src/bot/cogs/moderation/moderation.py @@ -128,13 +128,14 @@ def _format_duration(self, duration: timedelta) -> str: 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""" + from src.bot.core.config import BotConfig 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(), + "Bann": discord.Color.from_rgb(*BotConfig.moderation.log_colors.ban), + "Kick": discord.Color.from_rgb(*BotConfig.moderation.log_colors.kick), + "Timeout": discord.Color.from_rgb(*BotConfig.moderation.log_colors.timeout), + "Timeout aufgehoben": discord.Color.from_rgb(*BotConfig.moderation.log_colors.untimeout), + "Slowmode aktiviert": discord.Color.from_rgb(*BotConfig.moderation.log_colors.slowmode_on), + "Slowmode deaktiviert": discord.Color.from_rgb(*BotConfig.moderation.log_colors.slowmode_off), } embed = discord.Embed( title=f"{emoji_yes} × {action} erfolgreich", diff --git a/src/bot/cogs/user/stats.py b/src/bot/cogs/user/stats.py index cef5649..1c56e17 100644 --- a/src/bot/cogs/user/stats.py +++ b/src/bot/cogs/user/stats.py @@ -25,8 +25,11 @@ def __init__(self, bot: commands.Bot): if not hasattr(bot, "stats_db"): bot.stats_db = self.db self.level_db = LevelDatabase() + + from src.bot.core.config import BotConfig + self.cleanup_task.change_interval(hours=BotConfig.intervals.stats_update) self.cleanup_task.start() - self.monthly_reset_task.start() + self.monthly_reset_task.start() # Monthly remains monthly, but we could make it configurable too logger.info("Enhanced StatsCog initialized") stats = SlashCommandGroup("stats", "Statistiken") diff --git a/src/bot/core/bot_setup.py b/src/bot/core/bot_setup.py index bf96fff..08640a1 100644 --- a/src/bot/core/bot_setup.py +++ b/src/bot/core/bot_setup.py @@ -8,6 +8,7 @@ import discord import ezcord +from .config import BotConfig class BotSetup: """Verwaltet die Bot-Initialisierung""" @@ -31,22 +32,22 @@ def create_bot(self) -> ezcord.Bot: # Bot erstellen bot = ezcord.PrefixBot( intents=intents, - language="de", - command_prefix="!mx ", + language=BotConfig.LANGUAGE, + command_prefix=BotConfig.PREFIX, help_command=None ) # Ezcord Help Command aktivieren embed = discord.Embed( - title="Hello, I'm ManagerX!", # Placeholder emoji, will fall back to text if not found + title=f"Hello, I'm {BotConfig.NAME}!", description=( - "**The ultimate all-in-one Discord solution.**\n\n" - "> ManagerX simplifies server management and brings your community " + f"**The ultimate all-in-one Discord solution.**\n\n" + f"> {BotConfig.NAME} simplifies server management and brings your community " "together with engaging games and reliable tools.\n\n" "✨ **Getting Started**\n" "Use the menu below to explore all commands!" ), - color=discord.Color.from_rgb(46, 204, 113), # Fresh emerald green + color=discord.Color.from_rgb(*BotConfig.EMBED_COLOR), timestamp=discord.utils.utcnow() ) @@ -64,15 +65,15 @@ def create_bot(self) -> ezcord.Bot: embed.add_field( name="🔗 **Important Links**", value=( - "🌐 [**Website**](https://managerx-bot.de) • " - "🚑 [**Support**](https://discord.gg/9T28DWup3g) • " - "💻 [**GitHub**](https://github.com/ManagerX-Development/ManagerX)" + f"🌐 [**Website**]({BotConfig.WEBSITE}) • " + f"🚑 [**Support**]({BotConfig.SUPPORT}) • " + f"💻 [**GitHub**]({BotConfig.GITHUB})" ), inline=False ) # Check if we can set a thumbnail or image (safe fallback) - embed.set_footer(text="ManagerX • Empowering your Community", icon_url=None) + embed.set_footer(text=BotConfig.FOOTER_TEXT, icon_url=None) bot.add_help_command( embed=embed, diff --git a/src/bot/core/cog_manager.py b/src/bot/core/cog_manager.py index 6d5932c..d0573b7 100644 --- a/src/bot/core/cog_manager.py +++ b/src/bot/core/cog_manager.py @@ -8,76 +8,61 @@ from logger import logger, Category +import os +from pathlib import Path +from logger import logger, Category + class CogManager: - """Verwaltet Cog-Loading und Ignore-Liste""" + """Verwaltet Cog-Loading basierend auf dem Dateisystem und config.yaml""" # Hilfs-/Utility-Dateien, die keine Cogs sind UTILITY_FILES = [ - "autocomplete", - "cache", - "components", - "config", - "containers", - "utils", - "backend", - "emojis" + "autocomplete", "cache", "components", "config", + "containers", "utils", "backend", "emojis" ] - # Mapping: Config-Key -> Dateiname - COG_MAPPING = { - 'fun': { - 'gewinnt': 'gewinnt', - 'tictactoe': 'tictactoe', - 'weather': 'weather', - 'wikipedia': 'cog' - }, - 'information': { - 'botstatus': 'botstatus', - 'serverinfo': 'serverinfo', - 'usermanagemt': 'usermanagemt' - }, - 'moderation': { - 'antispam': 'antispam', - 'moderation': 'moderation', - 'notes': 'notes', - 'warningsystem': 'warningsystem' - }, - 'server_management': { - 'autodelete': 'autodelete', - 'globalchat': 'globalchat', - 'levelsystem': 'levelsystem', - 'logging': 'logging', - 'stats': 'stats', - 'tempvc': 'tempvc', - 'welcome': 'welcome' - }, - 'other': { - 'setlang': 'setlang' - } - } - - def __init__(self, cogs_config: dict): + def __init__(self, cogs_config: dict, cogs_base_path: Path = Path("src/bot/cogs")): self.cogs_config = cogs_config + self.cogs_base_path = cogs_base_path def get_ignored_cogs(self) -> list: """ - Erstellt Liste von zu ignorierenden Cogs basierend auf config.yaml. + Erstellt Liste von zu ignorierenden Cogs durch Scannen des Dateisystems. Returns: list: Dateinamen (ohne .py) der zu ignorierenden Cogs """ ignored = self.UTILITY_FILES.copy() - # Deaktivierte Cogs hinzufügen - for category, cogs in self.COG_MAPPING.items(): + # Scanne das Cogs-Verzeichnis + if not self.cogs_base_path.exists(): + logger.error(Category.BOT, f"Cogs-Verzeichnis nicht gefunden: {self.cogs_base_path}") + return ignored + + for root, _, files in os.walk(self.cogs_base_path): + category = Path(root).name + if category == "cogs": continue # Root-Ordner überspringen + category_config = self.cogs_config.get(category, {}) - for cog_key, file_name in cogs.items(): - if not category_config.get(cog_key, True): - ignored.append(file_name) - logger.info(Category.BOT, f"Cog '{file_name}' deaktiviert (config.yaml)") + for file in files: + if not file.endswith(".py") or file.startswith("__"): + continue + + cog_name = file[:-3] + if cog_name in self.UTILITY_FILES: + continue + + # Prüfe ob in der Config deaktiviert + # Falls keine Kategorie gefunden wurde oder der Cog nicht in der Config steht, + # wird er standardmäßig geladen (get(..., True)) + is_enabled = category_config.get(cog_name, True) + + if not is_enabled: + ignored.append(cog_name) + logger.info(Category.BOT, f"Cog '{cog_name}' deaktiviert via config.yaml (Kategorie: {category})") - return ignored + return list(set(ignored)) # Dubletten entfernen def is_cog_enabled(self, category: str, cog_name: str) -> bool: """ diff --git a/src/bot/core/config.py b/src/bot/core/config.py index 1240754..33c3851 100644 --- a/src/bot/core/config.py +++ b/src/bot/core/config.py @@ -1,74 +1,107 @@ """ -ManagerX - Configuration Loader -================================ +ManagerX - Dynamic Configuration (Zero-Mapping) +============================================== -Lädt und verwaltet die Bot-Konfiguration aus config.yaml +Lädt alles aus config.yaml ohne manuelles Mapping. +Zugriff via BotConfig.section.key (entspricht der YAML Struktur) """ -import os -import sys import yaml from pathlib import Path from colorama import Fore, Style -from dotenv import load_dotenv -base_path = Path(__file__).resolve().parent.parent.parent.parent -env_path = base_path / "config" / ".env" +import sys +import os + +class ConfigDict(dict): + """Ein Dictionary, das Punkt-Notation erlaubt (Config.section.key)""" + def __getattr__(self, name): + if name in self: + val = self[name] + if isinstance(val, dict): + return ConfigDict(val) + return val + # Fallback für verschachtelte Zugriffe auf nicht existierende Keys + return ConfigDict() -# Lade die .env Datei -load_dotenv(dotenv_path=env_path) + def __setattr__(self, name, value): + self[name] = value + + def get(self, key, default=None): + return super().get(key, default) + +class classproperty(object): + def __init__(self, fget): + self.fget = fget + def __get__(self, owner_self, owner_cls): + return self.fget(owner_cls) class BotConfig: - """Zentrale Konfigurationsklasse""" + """Zentrale Konfigurations-Schnittstelle""" + _data = ConfigDict() TOKEN = os.getenv("TOKEN") - VERSION = "2.0.0" - -class ConfigLoader: - """Lädt die Bot-Konfiguration aus config.yaml""" - - def __init__(self, basedir: Path): - self.basedir = basedir - self.config_path = basedir / 'config' / 'config.yaml' - def load(self) -> dict: - """ - Lädt die Konfigurationsdatei und gibt alle Einstellungen zurück. + @classmethod + def load(cls, basedir: Path): + """Lädt die config.yaml und initialisiert das _data Objekt""" + config_path = basedir / 'config' / 'config.yaml' - Returns: - dict: Vollständige Konfiguration - - Raises: - SystemExit: Bei kritischen Fehlern - """ try: - with open(self.config_path, 'r', encoding='utf-8') as f: - config = yaml.safe_load(f) - - # Bot deaktiviert? - if not config.get('enabled', True): - print(f"[{Fore.YELLOW}INFO{Style.RESET_ALL}] Bot ist in config.yaml deaktiviert. Beende...") - sys.exit(0) - - # Version übernehmen - BotConfig.VERSION = config.get('version', '2.0.0') - - # Strukturierte Rückgabe - return { - 'enabled': config.get('enabled', True), - 'version': BotConfig.VERSION, - 'features': config.get('features', {}), - 'bot_behavior': config.get('bot_behavior', {}), - 'ui': config.get('ui', {}), - 'security': config.get('security', {}), - 'performance': config.get('performance', {}), - 'cogs': config.get('features', {}).get('cogs', {}) - } - - except FileNotFoundError: - print(f"[{Fore.RED}ERROR{Style.RESET_ALL}] config.yaml nicht gefunden: {self.config_path}") - sys.exit(1) - except yaml.YAMLError as e: - print(f"[{Fore.RED}ERROR{Style.RESET_ALL}] YAML-Parsing-Fehler: {e}") - sys.exit(1) + with open(config_path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + cls._data = ConfigDict(data) + + # Grundlegende Prüfung + if not cls._data.get('bot', {}).get('enabled', True): + print(f"[{Fore.YELLOW}INFO{Style.RESET_ALL}] Bot ist in config.yaml deaktiviert. Beende...") + sys.exit(0) + + return data except Exception as e: - print(f"[{Fore.RED}ERROR{Style.RESET_ALL}] Fehler beim Laden der config.yaml: {e}") - sys.exit(1) \ No newline at end of file + print(f"[{Fore.RED}ERROR{Style.RESET_ALL}] Konfigurationsfehler: {e}") + sys.exit(1) + + def __getattr__(self, name): + return getattr(self._data, name) + + @classproperty + def bot(cls): return cls._data.get('bot', ConfigDict()) + @classproperty + def security(cls): return cls._data.get('security', ConfigDict()) + @classproperty + def ui(cls): return cls._data.get('ui', ConfigDict()) + @classproperty + def api(cls): return cls._data.get('api', ConfigDict()) + @classproperty + def leveling(cls): return cls._data.get('leveling', ConfigDict()) + @classproperty + def moderation(cls): return cls._data.get('moderation', ConfigDict()) + @classproperty + def global_chat(cls): return cls._data.get('global_chat', ConfigDict()) + @classproperty + def logging(cls): return cls._data.get('logging', ConfigDict()) + @classproperty + def links(cls): return cls._data.get('links', ConfigDict()) + @classproperty + def intervals(cls): return cls._data.get('intervals', ConfigDict()) + @classproperty + def features(cls): return cls._data.get('features', ConfigDict()) + @classproperty + def translation(cls): return cls._data.get('translation', ConfigDict()) + + # --- Legacy Aliases/Shortcuts (Minimale Liste für Pfade) --- + @classproperty + def VERSION(cls): return cls.bot.get('version', '2.0.0') + @classproperty + def PREFIX(cls): return cls.bot.get('prefix', '!mx ') + @classproperty + def LANGUAGE(cls): return cls.bot.get('language', 'de') + + @classproperty + def DATA_PATH(cls): return Path(cls.logging.get('data_path', 'data')) + @classproperty + def COGS_PATH(cls): return Path(cls.logging.get('cogs_path', 'src/bot/cogs')) + +# Alias für ConfigLoader +class ConfigLoader: + def __init__(self, basedir): self.basedir = basedir + def load(self): return BotConfig.load(self.basedir) \ No newline at end of file From 07cee1800e6e6bbe870c16591978b787dd2f9c86 Mon Sep 17 00:00:00 2001 From: Medicopter117 Date: Tue, 7 Apr 2026 14:23:19 +0200 Subject: [PATCH 2/2] feat: implement comprehensive dashboard infrastructure with user settings, database connectors, and web UI components --- config/config.yaml | 13 +- config/example.env | 9 +- main.py | 27 +- mxmariadb/mxmariadb/__init__.py | 22 + mxmariadb/mxmariadb/antispam_db.py | 297 ++++++ mxmariadb/mxmariadb/autodelete_db.py | 117 +++ mxmariadb/mxmariadb/autorole_db.py | 112 +++ mxmariadb/mxmariadb/connector.py | 84 ++ mxmariadb/mxmariadb/economy_db.py | 296 ++++++ mxmariadb/mxmariadb/globalchat_db.py | 399 ++++++++ mxmariadb/mxmariadb/levelsystem_db.py | 391 ++++++++ mxmariadb/mxmariadb/logging_db.py | 200 ++++ mxmariadb/mxmariadb/notes_db.py | 96 ++ mxmariadb/mxmariadb/profile_db.py | 372 +++++++ mxmariadb/mxmariadb/settings_db.py | 103 ++ mxmariadb/mxmariadb/stats_db.py | 485 +++++++++ mxmariadb/mxmariadb/vc_db.py | 171 ++++ mxmariadb/mxmariadb/warn_db.py | 87 ++ mxmariadb/mxmariadb/welcome_db.py | 192 ++++ src/bot/cogs/{ => cogs}/bot/about.py | 11 +- src/bot/cogs/{ => cogs}/bot/admin.py | 14 +- src/bot/cogs/{ => cogs}/bot/botjoinevent.py | 3 +- .../cogs/{ => cogs}/bot/server_join_alert.py | 3 +- .../cogs/{ => cogs}/bot/server_leave_alert.py | 3 +- src/bot/cogs/cogs/bot/status.py | 47 + src/bot/cogs/{ => cogs}/economy/gb_economy.py | 2 +- .../cogs/{ => cogs}/economy/gld_economy.py | 2 +- src/bot/cogs/{ => cogs}/fun/4gewinnt.py | 0 src/bot/cogs/{ => cogs}/fun/tictactoe.py | 0 src/bot/cogs/{ => cogs}/guild/globalchat.py | 930 ++++-------------- src/bot/cogs/{ => cogs}/guild/levelsystem.py | 28 +- .../cogs/{ => cogs}/guild/loggingsystem.py | 2 +- src/bot/cogs/{ => cogs}/guild/tempvc.py | 2 +- src/bot/cogs/{ => cogs}/guild/utility.py | 0 src/bot/cogs/{ => cogs}/guild/welcome.py | 4 +- .../cogs/{ => cogs}/legacy/secret_commands.py | 0 src/bot/cogs/cogs/management/autodelete.py | 103 ++ .../cogs/{ => cogs}/management/autorole.py | 2 +- .../cogs/{ => cogs}/moderation/antispam.py | 4 +- .../cogs/{ => cogs}/moderation/moderation.py | 13 +- src/bot/cogs/{ => cogs}/moderation/notes.py | 4 +- src/bot/cogs/{ => cogs}/moderation/warn.py | 5 +- src/bot/cogs/{ => cogs}/user/settings.py | 4 +- src/bot/cogs/{ => cogs}/user/stats.py | 18 +- src/bot/cogs/management/autodelete.py | 311 ------ src/bot/core/bot_setup.py | 18 +- src/bot/core/config.py | 110 +-- src/bot/core/utils.py | 2 +- src/web/components/AntiSpamSettings.tsx | 11 +- src/web/components/AuthProvider.tsx | 9 +- src/web/components/AutoDeleteSettings.tsx | 8 +- src/web/components/AutoRoleSettings.tsx | 8 +- src/web/components/GlobalChatSettings.tsx | 11 +- src/web/components/LevelSettings.tsx | 8 +- src/web/components/LoggingSettings.tsx | 9 +- src/web/components/OverviewSettings.tsx | 6 +- src/web/components/TempVCSettings.tsx | 8 +- src/web/components/WelcomeSettings.tsx | 11 +- src/web/dashboard/LoginPage.tsx | 5 +- src/web/dashboard/SettingsPage.tsx | 9 +- src/web/dashboard/UserSettingsPage.tsx | 8 +- favicon.ico => src/web/favicon.ico | Bin src/web/hooks/useStats.ts | 5 +- src/web/lib/api.ts | 2 + src/web/lib/legal.ts | 23 + src/web/pages/AuthCallback.tsx | 4 +- src/web/pages/Datenschutz.tsx | 29 +- src/web/pages/Impressum.tsx | 429 ++++---- src/web/pages/LeaderboardPage.tsx | 5 +- src/web/pages/Nutzungsbedingungen.tsx | 17 +- src/web/pages/Status.tsx | 9 +- 71 files changed, 4277 insertions(+), 1475 deletions(-) create mode 100644 mxmariadb/mxmariadb/__init__.py create mode 100644 mxmariadb/mxmariadb/antispam_db.py create mode 100644 mxmariadb/mxmariadb/autodelete_db.py create mode 100644 mxmariadb/mxmariadb/autorole_db.py create mode 100644 mxmariadb/mxmariadb/connector.py create mode 100644 mxmariadb/mxmariadb/economy_db.py create mode 100644 mxmariadb/mxmariadb/globalchat_db.py create mode 100644 mxmariadb/mxmariadb/levelsystem_db.py create mode 100644 mxmariadb/mxmariadb/logging_db.py create mode 100644 mxmariadb/mxmariadb/notes_db.py create mode 100644 mxmariadb/mxmariadb/profile_db.py create mode 100644 mxmariadb/mxmariadb/settings_db.py create mode 100644 mxmariadb/mxmariadb/stats_db.py create mode 100644 mxmariadb/mxmariadb/vc_db.py create mode 100644 mxmariadb/mxmariadb/warn_db.py create mode 100644 mxmariadb/mxmariadb/welcome_db.py rename src/bot/cogs/{ => cogs}/bot/about.py (89%) rename src/bot/cogs/{ => cogs}/bot/admin.py (99%) rename src/bot/cogs/{ => cogs}/bot/botjoinevent.py (88%) rename src/bot/cogs/{ => cogs}/bot/server_join_alert.py (93%) rename src/bot/cogs/{ => cogs}/bot/server_leave_alert.py (94%) create mode 100644 src/bot/cogs/cogs/bot/status.py rename src/bot/cogs/{ => cogs}/economy/gb_economy.py (99%) rename src/bot/cogs/{ => cogs}/economy/gld_economy.py (98%) rename src/bot/cogs/{ => cogs}/fun/4gewinnt.py (100%) rename src/bot/cogs/{ => cogs}/fun/tictactoe.py (100%) rename src/bot/cogs/{ => cogs}/guild/globalchat.py (60%) rename src/bot/cogs/{ => cogs}/guild/levelsystem.py (97%) rename src/bot/cogs/{ => cogs}/guild/loggingsystem.py (99%) rename src/bot/cogs/{ => cogs}/guild/tempvc.py (99%) rename src/bot/cogs/{ => cogs}/guild/utility.py (100%) rename src/bot/cogs/{ => cogs}/guild/welcome.py (99%) rename src/bot/cogs/{ => cogs}/legacy/secret_commands.py (100%) create mode 100644 src/bot/cogs/cogs/management/autodelete.py rename src/bot/cogs/{ => cogs}/management/autorole.py (99%) rename src/bot/cogs/{ => cogs}/moderation/antispam.py (99%) rename src/bot/cogs/{ => cogs}/moderation/moderation.py (98%) rename src/bot/cogs/{ => cogs}/moderation/notes.py (97%) rename src/bot/cogs/{ => cogs}/moderation/warn.py (99%) rename src/bot/cogs/{ => cogs}/user/settings.py (98%) rename src/bot/cogs/{ => cogs}/user/stats.py (98%) delete mode 100644 src/bot/cogs/management/autodelete.py rename favicon.ico => src/web/favicon.ico (100%) create mode 100644 src/web/lib/api.ts create mode 100644 src/web/lib/legal.ts diff --git a/config/config.yaml b/config/config.yaml index 2b9f255..ac07686 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -116,8 +116,17 @@ intervals: features: bot_status: true cogs: - fun: - gewinnt: true + management: + autodelete: true + autorole: true + database_backup: true + guild: + levelsystem: true + welcome: true + global_chat: true + loggingsystem: true + tempvc: true + utility: true tictactoe: true weather: true wikipedia: true diff --git a/config/example.env b/config/example.env index 7d77fa2..fad97a0 100644 --- a/config/example.env +++ b/config/example.env @@ -14,4 +14,11 @@ JWT_SECRET=abc123 DASHBOARD_URL=https://my-super-discord-bot.com VITE_API_URL=https://api.my-super-discord-bot.com -TOPGG_TOKEN=abc123 \ No newline at end of file +TOPGG_TOKEN=abc123 + +# DATABASE +DB_HOST=https://database.my-super-discord-bot.com +DB_PORT=3306 +DB_USER=managerx +DB_PASSWORD=abc123 +DB_DATABASE=managerx \ No newline at end of file diff --git a/main.py b/main.py index 618f6c1..3ef3eac 100644 --- a/main.py +++ b/main.py @@ -18,8 +18,9 @@ from dotenv import load_dotenv import ezcord from ezcord import CogLog -from fastapi import FastAPI +from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import PlainTextResponse from uvicorn import Server, Config # Logger (muss existieren!) @@ -52,10 +53,13 @@ colorama_init(autoreset=True) +print(f"[{Fore.BLUE}DEBUG{Style.RESET_ALL}] Translation Config: {BotConfig.translation}") +print(f"[{Fore.BLUE}DEBUG{Style.RESET_ALL}] Translation Path: {BotConfig.translation.path} (Type: {type(BotConfig.translation.path)})") + TranslationHandler.settings( - path=BotConfig.LANG_PATH, - default_lang=BotConfig.DEFAULT_LANG, - fallback_langs=BotConfig.FALLBACK_LANGS, + path=str(BotConfig.translation.path) if BotConfig.translation.path else "translation/messages", + default_lang=BotConfig.translation.default_lang, + fallback_langs=tuple(BotConfig.translation.fallback_langs), logging=False, colored=False, log_level="DEBUG" @@ -77,7 +81,7 @@ # CORS aktivieren app.add_middleware( CORSMiddleware, - allow_origins=BotConfig.CORS_ORIGINS, + allow_origins=BotConfig.api.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -87,17 +91,22 @@ app.include_router(dashboard_main_router) app.include_router(router_public) +@app.get("/robots.txt", response_class=PlainTextResponse) +async def robots(): + """Disallow all crawlers for the API.""" + return "User-agent: *\nDisallow: /" + async def start_webserver(): """Startet den FastAPI Webserver""" server_config = Config( app=app, - host=BotConfig.API_HOST, - port=BotConfig.API_PORT, - log_level=BotConfig.API_LOG_LEVEL + host=BotConfig.api.host, + port=BotConfig.api.port, + log_level=BotConfig.api.log_level ) server = Server(server_config) await server.serve() - logger.success("API", f"FastAPI-Server läuft auf http://{BotConfig.API_HOST}:{BotConfig.API_PORT}") + logger.success("API", f"FastAPI-Server läuft auf http://{BotConfig.api.host}:{BotConfig.api.port}") # ============================================================================= # MAIN EXECUTION diff --git a/mxmariadb/mxmariadb/__init__.py b/mxmariadb/mxmariadb/__init__.py new file mode 100644 index 0000000..0987c1b --- /dev/null +++ b/mxmariadb/mxmariadb/__init__.py @@ -0,0 +1,22 @@ +import sys +import os + +# Füge den aktuellen Ordner (mxmariadb) zum Systempfad hinzu +pkg_dir = os.path.dirname(os.path.abspath(__file__)) +if pkg_dir not in sys.path: + sys.path.append(pkg_dir) + +from .antispam_db import SpamDB as AntiSpamDatabase +from .autodelete_db import AutoDeleteDB +from .autorole_db import AutoRoleDatabase +from .globalchat_db import GlobalChatDatabase +from .settings_db import SettingsDB +from .levelsystem_db import LevelDatabase +from .logging_db import LoggingDatabase +from .notes_db import NotesDatabase +from .stats_db import StatsDB +from .vc_db import TempVCDatabase +from .warn_db import WarnDatabase +from .welcome_db import WelcomeDatabase +from .profile_db import ProfileDB +from .economy_db import EconomyDatabase diff --git a/mxmariadb/mxmariadb/antispam_db.py b/mxmariadb/mxmariadb/antispam_db.py new file mode 100644 index 0000000..005dd42 --- /dev/null +++ b/mxmariadb/mxmariadb/antispam_db.py @@ -0,0 +1,297 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of SpamDB +import aiomysql +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, List +from mxmariadb.connector import MariaConnector + +class SpamDBError(Exception): + """Custom exception for SpamDB errors""" + pass + + +class SpamDB(MariaConnector): + """MariaDB-backed spam database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + self.logger = logging.getLogger(__name__) + + async def init_db(self): + """Create all necessary tables.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS spam_settings ( + guild_id BIGINT PRIMARY KEY, + max_messages INT DEFAULT 5, + time_frame INT DEFAULT 10, + log_channel_id BIGINT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS spam_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + message TEXT NOT NULL, + message_count INT DEFAULT 1, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_spam_logs_guild_ts (guild_id, timestamp), + INDEX idx_spam_logs_user_ts (user_id, timestamp) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS spam_whitelist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + added_by BIGINT, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + reason TEXT, + UNIQUE KEY uq_guild_user (guild_id, user_id) + ) + ''') + await conn.commit() + self.logger.info("MariaDB spam tables initialized") + + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + async def set_spam_settings(self, guild_id: int, max_messages: int = 5, + time_frame: int = 10, log_channel_id: Optional[int] = None) -> bool: + await self.ensure_connection() + if max_messages <= 0 or time_frame <= 0: + raise SpamDBError("max_messages and time_frame must be positive integers") + + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO spam_settings (guild_id, max_messages, time_frame, log_channel_id) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + max_messages = VALUES(max_messages), + time_frame = VALUES(time_frame), + log_channel_id = VALUES(log_channel_id) + ''', (guild_id, max_messages, time_frame, log_channel_id)) + await conn.commit() + return True + + async def set_log_channel(self, guild_id: int, channel_id: int) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT max_messages, time_frame FROM spam_settings WHERE guild_id = %s', + (guild_id,)) + result = await cur.fetchone() + + max_messages = result['max_messages'] if result else 5 + time_frame = result['time_frame'] if result else 10 + + await cur.execute(''' + INSERT INTO spam_settings (guild_id, max_messages, time_frame, log_channel_id) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + log_channel_id = VALUES(log_channel_id) + ''', (guild_id, max_messages, time_frame, channel_id)) + await conn.commit() + return True + + async def get_spam_settings(self, guild_id: int) -> Optional[Dict]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT max_messages, time_frame, log_channel_id, created_at, updated_at ' + 'FROM spam_settings WHERE guild_id = %s', (guild_id,)) + return await cur.fetchone() + + async def get_log_channel(self, guild_id: int) -> Optional[int]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT log_channel_id FROM spam_settings WHERE guild_id = %s', (guild_id,)) + result = await cur.fetchone() + return result['log_channel_id'] if result and result['log_channel_id'] else None + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + + async def log_spam(self, guild_id: int, user_id: int, message: str, message_count: int = 1) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO spam_logs (guild_id, user_id, message, message_count) + VALUES (%s, %s, %s, %s) + ''', (guild_id, user_id, message[:1000], message_count)) + await conn.commit() + return True + + async def get_spam_logs(self, guild_id: int, limit: int = 10) -> List[Dict]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT user_id, message, message_count, timestamp + FROM spam_logs WHERE guild_id = %s + ORDER BY timestamp DESC LIMIT %s + ''', (guild_id, limit)) + return await cur.fetchall() + + async def get_user_spam_history(self, guild_id: int, user_id: int, + hours: int = 24, limit: int = 50) -> List[Dict]: + await self.ensure_connection() + cutoff_time = datetime.now() - timedelta(hours=hours) + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT message, message_count, timestamp + FROM spam_logs + WHERE guild_id = %s AND user_id = %s AND timestamp > %s + ORDER BY timestamp DESC LIMIT %s + ''', (guild_id, user_id, cutoff_time, limit)) + return await cur.fetchall() + + async def clear_spam_logs(self, guild_id: int, older_than_days: Optional[int] = None) -> int: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if older_than_days: + cutoff_date = datetime.now() - timedelta(days=older_than_days) + await cur.execute( + 'DELETE FROM spam_logs WHERE guild_id = %s AND timestamp < %s', + (guild_id, cutoff_date)) + else: + await cur.execute('DELETE FROM spam_logs WHERE guild_id = %s', (guild_id,)) + deleted = cur.rowcount + await conn.commit() + return deleted + + # ------------------------------------------------------------------ + # Whitelist + # ------------------------------------------------------------------ + + async def add_to_whitelist(self, guild_id: int, user_id: int, + added_by: Optional[int] = None, reason: Optional[str] = None) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT IGNORE INTO spam_whitelist (guild_id, user_id, added_by, reason) + VALUES (%s, %s, %s, %s) + ''', (guild_id, user_id, added_by, reason)) + success = cur.rowcount > 0 + await conn.commit() + return success + + async def remove_from_whitelist(self, guild_id: int, user_id: int) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'DELETE FROM spam_whitelist WHERE guild_id = %s AND user_id = %s', + (guild_id, user_id)) + success = cur.rowcount > 0 + await conn.commit() + return success + + async def is_whitelisted(self, guild_id: int, user_id: int) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT 1 FROM spam_whitelist WHERE guild_id = %s AND user_id = %s', + (guild_id, user_id)) + return await cur.fetchone() is not None + + async def get_whitelist(self, guild_id: int) -> List[Dict]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT user_id, added_by, added_at, reason ' + 'FROM spam_whitelist WHERE guild_id = %s ORDER BY user_id', + (guild_id,)) + return await cur.fetchall() + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ + + async def get_guild_stats(self, guild_id: int) -> Dict: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT COUNT(*) AS total FROM spam_logs WHERE guild_id = %s', (guild_id,)) + total_logs = (await cur.fetchone())['total'] + + yesterday = datetime.now() - timedelta(hours=24) + await cur.execute( + 'SELECT COUNT(*) AS recent FROM spam_logs WHERE guild_id = %s AND timestamp > %s', + (guild_id, yesterday)) + recent_logs = (await cur.fetchone())['recent'] + + await cur.execute( + 'SELECT COUNT(*) AS cnt FROM spam_whitelist WHERE guild_id = %s', (guild_id,)) + whitelist_count = (await cur.fetchone())['cnt'] + + week_ago = datetime.now() - timedelta(days=7) + await cur.execute(''' + SELECT user_id, COUNT(*) AS spam_count, SUM(message_count) AS total_messages + FROM spam_logs + WHERE guild_id = %s AND timestamp > %s + GROUP BY user_id ORDER BY spam_count DESC LIMIT 5 + ''', (guild_id, week_ago)) + top_spammers = await cur.fetchall() + + return { + 'total_spam_logs': total_logs, + 'recent_spam_logs': recent_logs, + 'whitelist_count': whitelist_count, + 'top_spammers': [ + {'user_id': r['user_id'], 'spam_incidents': r['spam_count'], + 'total_messages': r['total_messages']} + for r in top_spammers + ] + } + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + async def cleanup_old_logs(self, days_to_keep: int = 30) -> int: + await self.ensure_connection() + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM spam_logs WHERE timestamp < %s', (cutoff_date,)) + deleted = cur.rowcount + await conn.commit() + if deleted > 0: + self.logger.info(f"Cleaned up {deleted} old spam logs") + return deleted + + async def delete_user_data(self, user_id: int) -> bool: + await self.ensure_connection() + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM spam_logs WHERE user_id = %s", (user_id,)) + await cur.execute("DELETE FROM spam_whitelist WHERE user_id = %s", (user_id,)) + await conn.commit() + return True + except Exception as e: + self.logger.error(f"Error deleting user data for {user_id}: {e}") + return False + + async def cleanup_old_data(self, days: int = 30) -> int: + return await self.cleanup_old_logs(days_to_keep=days) \ No newline at end of file diff --git a/mxmariadb/mxmariadb/autodelete_db.py b/mxmariadb/mxmariadb/autodelete_db.py new file mode 100644 index 0000000..4ed1071 --- /dev/null +++ b/mxmariadb/mxmariadb/autodelete_db.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026 OPPRO.NET Network +# MariaDB version of AutoDeleteDB - FULL ASYNC FIX +import aiomysql +import logging +import asyncio +from datetime import datetime +from typing import Optional, Dict, List +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + +class AutoDeleteDB(MariaConnector): + """MariaDB-backed AutoDelete database. Vollständig asynchron und sicher gegen NoneType Errors.""" + + def __init__(self): + super().__init__() + + async def _ensure_pool(self): + """Wartet geduldig, bis der Datenbank-Pool bereit ist, ohne zu crashen.""" + if self.pool is None: + await self.connect() + + attempts = 0 + while self.pool is None and attempts < 15: + await asyncio.sleep(0.5) + attempts += 1 + + if self.pool is None: + logger.warning("MariaDB-Pool ist noch nicht bereit. Aktion wird übersprungen.") + return False + return True + + async def init_db(self): + if not await self._ensure_pool(): return + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS autodelete ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + channel_id BIGINT NOT NULL UNIQUE, + duration INT NOT NULL, + exclude_pinned TINYINT(1) DEFAULT 1, + exclude_bots TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS autodelete_whitelist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + channel_id BIGINT NOT NULL, + target_id BIGINT NOT NULL, + target_type VARCHAR(10) NOT NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_channel_target (channel_id, target_id, target_type) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS autodelete_stats ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + channel_id BIGINT NOT NULL UNIQUE, + deleted_count INT DEFAULT 0, + error_count INT DEFAULT 0, + last_deletion DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + ''') + await conn.commit() + logger.info("MariaDB autodelete tables initialized") + + async def add_autodelete(self, channel_id: int, duration: int, exclude_pinned: bool = True, exclude_bots: bool = False): + if not await self._ensure_pool(): return + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO autodelete (channel_id, duration, exclude_pinned, exclude_bots) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + duration = VALUES(duration), + exclude_pinned = VALUES(exclude_pinned), + exclude_bots = VALUES(exclude_bots) + ''', (channel_id, duration, exclude_pinned, exclude_bots)) + await cur.execute('INSERT IGNORE INTO autodelete_stats (channel_id) VALUES (%s)', (channel_id,)) + await conn.commit() + + async def get_all(self) -> List[tuple]: + if not await self._ensure_pool(): return [] + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('SELECT channel_id, duration, exclude_pinned, exclude_bots FROM autodelete ORDER BY channel_id') + results = await cur.fetchall() + # Umwandlung von Dict (aus Connector) in Tuple (für Cog Kompatibilität) + return [(r['channel_id'], r['duration'], r['exclude_pinned'], r['exclude_bots']) for r in results] + + async def get_autodelete_full(self, channel_id: int) -> Optional[tuple]: + if not await self._ensure_pool(): return None + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('SELECT duration, exclude_pinned, exclude_bots FROM autodelete WHERE channel_id = %s', (channel_id,)) + r = await cur.fetchone() + return (r['duration'], r['exclude_pinned'], r['exclude_bots']) if r else None + + async def update_stats(self, channel_id: int, deleted_count: int = 0, error_count: int = 0): + if not await self._ensure_pool(): return + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + last_del = datetime.utcnow() if deleted_count > 0 else None + await cur.execute(''' + INSERT INTO autodelete_stats (channel_id, deleted_count, error_count, last_deletion) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + deleted_count = deleted_count + VALUES(deleted_count), + error_count = error_count + VALUES(error_count), + last_deletion = COALESCE(VALUES(last_deletion), last_deletion) + ''', (channel_id, deleted_count, error_count, last_del)) + await conn.commit() \ No newline at end of file diff --git a/mxmariadb/mxmariadb/autorole_db.py b/mxmariadb/mxmariadb/autorole_db.py new file mode 100644 index 0000000..6f65724 --- /dev/null +++ b/mxmariadb/mxmariadb/autorole_db.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of AutoRoleDatabase +import aiomysql +import random +import string +import logging +from typing import Optional, List, Dict +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class AutoRoleDatabase(MariaConnector): + """MariaDB-backed autorole database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create table if it doesn't exist.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS autoroles ( + autorole_id VARCHAR(20) PRIMARY KEY, + guild_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + enabled TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_guild (guild_id) + ) + """) + await conn.commit() + logger.info("MariaDB autorole tables initialized") + + def generate_autorole_id(self, guild_id: int, role_id: int) -> str: + guild_part = str(guild_id)[-2:].zfill(2) + role_part = str(role_id)[-2:].zfill(2) + random_part = ''.join(random.choices(string.digits, k=3)) + return f"{guild_part}-{role_part}-{random_part}" + + async def add_autorole(self, guild_id: int, role_id: int) -> str: + await self.init_db() + autorole_id = self.generate_autorole_id(guild_id, role_id) + + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + # Ensure unique ID + while True: + await cur.execute( + "SELECT autorole_id FROM autoroles WHERE autorole_id = %s", + (autorole_id,)) + if not await cur.fetchone(): + break + autorole_id = self.generate_autorole_id(guild_id, role_id) + + await cur.execute(""" + INSERT INTO autoroles (autorole_id, guild_id, role_id, enabled) + VALUES (%s, %s, %s, 1) + """, (autorole_id, guild_id, role_id)) + await conn.commit() + return autorole_id + + async def get_all_autoroles(self, guild_id: int) -> List[Dict]: + await self.init_db() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT autorole_id, role_id, enabled FROM autoroles WHERE guild_id = %s", + (guild_id,)) + rows = await cur.fetchall() + return [{"autorole_id": r['autorole_id'], "role_id": r['role_id'], + "enabled": bool(r['enabled'])} for r in rows] + + async def get_autorole(self, autorole_id: str) -> Optional[Dict]: + await self.init_db() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT autorole_id, guild_id, role_id, enabled FROM autoroles WHERE autorole_id = %s", + (autorole_id,)) + row = await cur.fetchone() + if row: + return {"autorole_id": row['autorole_id'], "guild_id": row['guild_id'], + "role_id": row['role_id'], "enabled": bool(row['enabled'])} + return None + + async def get_enabled_autoroles(self, guild_id: int) -> List[int]: + await self.init_db() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT role_id FROM autoroles WHERE guild_id = %s AND enabled = 1", + (guild_id,)) + rows = await cur.fetchall() + return [r[0] for r in rows] + + async def remove_autorole(self, autorole_id: str): + await self.init_db() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM autoroles WHERE autorole_id = %s", (autorole_id,)) + await conn.commit() + + async def toggle_autorole(self, autorole_id: str, enabled: bool): + await self.init_db() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE autoroles SET enabled = %s WHERE autorole_id = %s", + (1 if enabled else 0, autorole_id)) + await conn.commit() diff --git a/mxmariadb/mxmariadb/connector.py b/mxmariadb/mxmariadb/connector.py new file mode 100644 index 0000000..dbd9ab6 --- /dev/null +++ b/mxmariadb/mxmariadb/connector.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025 OPPRO.NET Network +import os +import aiomysql +import asyncio +import logging +from dotenv import load_dotenv +from pathlib import Path + +env_path = Path(__file__).parent.parent / 'config' / '.env' +load_dotenv(dotenv_path=env_path) + +logger = logging.getLogger(__name__) + + +class MariaConnector: + _pool = None + _lock = asyncio.Lock() + _initialized: set = set() + + def __init__(self): + self.host = os.getenv("DB_HOST", "127.0.0.1") + self.user = os.getenv("DB_USER") + self.password = os.getenv("DB_PASSWORD") + self.database = os.getenv("DB_NAME") + self.port = int(os.getenv("DB_PORT", 3306)) + + @property + def pool(self): + return MariaConnector._pool + + async def connect(self): + async with MariaConnector._lock: + if MariaConnector._pool is None: + if not self.user or not self.database: + logger.error(f"DB-Credentials fehlen in {env_path}") + raise RuntimeError("Datenbankzugangsdaten fehlen.") + + try: + logger.info(f"[DB] Verbinde zu {self.host}:{self.port} DB='{self.database}' als '{self.user}'...") + MariaConnector._pool = await aiomysql.create_pool( + host=self.host, + user=self.user, + password=self.password, + db=self.database, + port=self.port, + autocommit=False, + minsize=2, + maxsize=15, + echo=False, + pool_recycle=1800, # Connections nach 30 Min recyclen + connect_timeout=10, # Verbindungs-Timeout + ) + logger.info("✅ MariaDB Pool erstellt.") + except Exception as e: + logger.critical(f"❌ Pool-Erstellung fehlgeschlagen: {e}") + raise + + cls_name = type(self).__name__ + if cls_name not in MariaConnector._initialized: + MariaConnector._initialized.add(cls_name) + try: + await self.init_db() + logger.info(f"[{cls_name}] init_db() erfolgreich.") + except Exception as e: + MariaConnector._initialized.discard(cls_name) + logger.critical(f"[{cls_name}] init_db() fehlgeschlagen: {e}") + raise + + async def init_db(self): + pass + + async def ensure_connection(self): + async with MariaConnector._lock: + if MariaConnector._pool is None or type(self).__name__ not in MariaConnector._initialized: + await self.connect() + + async def close(self): + async with MariaConnector._lock: + if MariaConnector._pool: + MariaConnector._pool.close() + await MariaConnector._pool.wait_closed() + MariaConnector._pool = None + MariaConnector._initialized.clear() + logger.info("MariaDB Pool geschlossen.") \ No newline at end of file diff --git a/mxmariadb/mxmariadb/economy_db.py b/mxmariadb/mxmariadb/economy_db.py new file mode 100644 index 0000000..6c39ee0 --- /dev/null +++ b/mxmariadb/mxmariadb/economy_db.py @@ -0,0 +1,296 @@ +# Copyright (c) 2026 OPPRO.NET Network +# MariaDB version of EconomyDatabase +import aiomysql +import logging +from typing import Optional, List, Dict, Tuple +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class EconomyDatabase(MariaConnector): + """MariaDB-backed economy database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create all required tables and seed shop.""" + await self.create_tables() + await self._seed_shop() + + async def create_tables(self): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS global_economy ( + user_id BIGINT PRIMARY KEY, + coins BIGINT DEFAULT 0, + total_earned BIGINT DEFAULT 0, + last_daily DATETIME, + last_message_at DATETIME + ) + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS guild_economy ( + guild_id BIGINT, + user_id BIGINT, + coins BIGINT DEFAULT 0, + total_earned BIGINT DEFAULT 0, + PRIMARY KEY (guild_id, user_id) + ) + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS shop_items ( + item_id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + value VARCHAR(255) NOT NULL, + is_active TINYINT(1) DEFAULT 1 + ) + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS user_inventory ( + user_id BIGINT, + item_id BIGINT, + purchased_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_equipped TINYINT(1) DEFAULT 0, + PRIMARY KEY (user_id, item_id) + ) + """) + await conn.commit() + logger.info("MariaDB economy tables created") + except Exception as e: + logger.error(f"Error creating economy tables: {e}") + raise + + async def _seed_shop(self): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT COUNT(*) FROM shop_items") + count = (await cur.fetchone())[0] + if count == 0: + items = [ + ('Rot (Name)', 'Verändert die Farbe deines Namens im GlobalChat zu Rot.', 500, 'color', '#FF0000'), + ('Blau (Name)', 'Verändert die Farbe deines Namens im GlobalChat zu Blau.', 500, 'color', '#3498DB'), + ('Grün (Name)', 'Verändert die Farbe deines Namens im GlobalChat zu Grün.', 500, 'color', '#2ECC71'), + ('Gold (Name)', 'Premium-Farbe für deinen Namen.', 2000, 'color', '#F1C40F'), + ('🔥 Feuer-Emoji', 'Fügt ein Feuer-Emoji neben deinen Namen.', 300, 'emoji', '🔥'), + ('👑 Kronen-Emoji', 'Fügt eine Krone neben deinen Namen.', 1000, 'emoji', '👑'), + ('⚡ Blitz-Emoji', 'Fügt einen Blitz neben deinen Namen.', 300, 'emoji', '⚡') + ] + await cur.executemany( + "INSERT INTO shop_items (name, description, price, type, value) VALUES (%s, %s, %s, %s, %s)", + items) + await conn.commit() + logger.info("Shop seeded") + except Exception as e: + logger.error(f"Error seeding shop: {e}") + + # --- Global Economy --- + + async def get_global_balance(self, user_id: int) -> int: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT coins FROM global_economy WHERE user_id = %s", (user_id,)) + result = await cur.fetchone() + return result[0] if result else 0 + except Exception: + return 0 + + async def add_global_coins(self, user_id: int, amount: int): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO global_economy (user_id, coins, total_earned) + VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE + coins = coins + %s, + total_earned = total_earned + %s + """, (user_id, amount, amount, amount, amount)) + await conn.commit() + except Exception as e: + logger.error(f"Error adding global coins: {e}") + + async def claim_daily(self, user_id: int, amount: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO global_economy (user_id, coins, last_daily) + VALUES (%s, %s, NOW()) + ON DUPLICATE KEY UPDATE + coins = coins + %s, + last_daily = NOW() + """, (user_id, amount, amount)) + await conn.commit() + return True + except Exception: + return False + + async def update_last_message(self, user_id: int): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO global_economy (user_id, last_message_at) + VALUES (%s, NOW()) + ON DUPLICATE KEY UPDATE last_message_at = NOW() + """, (user_id,)) + await conn.commit() + except Exception as e: + logger.error(f"Error updating last message: {e}") + + async def get_user_economy_info(self, user_id: int) -> Dict: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM global_economy WHERE user_id = %s", (user_id,)) + result = await cur.fetchone() + return dict(result) if result else {} + except Exception: + return {} + + # --- Guild Economy --- + + async def get_guild_balance(self, guild_id: int, user_id: int) -> int: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT coins FROM guild_economy WHERE guild_id = %s AND user_id = %s", + (guild_id, user_id)) + result = await cur.fetchone() + return result[0] if result else 0 + except Exception: + return 0 + + async def add_guild_coins(self, guild_id: int, user_id: int, amount: int): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO guild_economy (guild_id, user_id, coins, total_earned) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + coins = coins + %s, + total_earned = total_earned + %s + """, (guild_id, user_id, amount, amount, amount, amount)) + await conn.commit() + except Exception as e: + logger.error(f"Error adding guild coins: {e}") + + # --- Shop & Inventory --- + + async def get_shop_items(self) -> List[Dict]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM shop_items WHERE is_active = 1") + return await cur.fetchall() + except Exception: + return [] + + async def get_item(self, item_id: int) -> Optional[Dict]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM shop_items WHERE item_id = %s", (item_id,)) + return await cur.fetchone() + except Exception: + return None + + async def buy_item(self, user_id: int, item_id: int) -> Tuple[bool, str]: + item = await self.get_item(item_id) + if not item: + return False, "Item existiert nicht." + + balance = await self.get_global_balance(user_id) + if balance < item['price']: + return False, f"Nicht genug Coins. Du brauchst {item['price']} Coins." + + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE global_economy SET coins = coins - %s WHERE user_id = %s", + (item['price'], user_id)) + await cur.execute( + "INSERT IGNORE INTO user_inventory (user_id, item_id) VALUES (%s, %s)", + (user_id, item_id)) + await conn.commit() + return True, f"Du hast '{item['name']}' erfolgreich gekauft!" + except Exception as e: + logger.error(f"Error buying item: {e}") + return False, "Ein Datenbankfehler ist aufgetreten." + + async def get_user_inventory(self, user_id: int) -> List[Dict]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT si.*, ui.purchased_at, ui.is_equipped + FROM shop_items si + JOIN user_inventory ui ON si.item_id = ui.item_id + WHERE ui.user_id = %s + """, (user_id,)) + return await cur.fetchall() + except Exception: + return [] + + async def equip_item(self, user_id: int, item_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT type FROM shop_items WHERE item_id = %s", (item_id,)) + item = await cur.fetchone() + if not item: + return False + item_type = item[0] + + # Unequip same type + await cur.execute(""" + UPDATE user_inventory SET is_equipped = 0 + WHERE user_id = %s AND item_id IN + (SELECT item_id FROM shop_items WHERE type = %s) + """, (user_id, item_type)) + + await cur.execute( + "UPDATE user_inventory SET is_equipped = 1 WHERE user_id = %s AND item_id = %s", + (user_id, item_id)) + await conn.commit() + return True + except Exception: + return False + + async def get_equipped_overrides(self, user_id: int) -> Dict[str, str]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT si.type, si.value + FROM shop_items si + JOIN user_inventory ui ON si.item_id = ui.item_id + WHERE ui.user_id = %s AND ui.is_equipped = 1 + """, (user_id,)) + rows = await cur.fetchall() + return {r['type']: r['value'] for r in rows} + except Exception: + return {} + + async def delete_user_data(self, user_id: int): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM global_economy WHERE user_id = %s", (user_id,)) + await cur.execute("DELETE FROM guild_economy WHERE user_id = %s", (user_id,)) + await cur.execute("DELETE FROM user_inventory WHERE user_id = %s", (user_id,)) + await conn.commit() + except Exception as e: + logger.error(f"Error deleting user economy data: {e}") diff --git a/mxmariadb/mxmariadb/globalchat_db.py b/mxmariadb/mxmariadb/globalchat_db.py new file mode 100644 index 0000000..c8b315d --- /dev/null +++ b/mxmariadb/mxmariadb/globalchat_db.py @@ -0,0 +1,399 @@ +# Copyright (c) 2026 OPPRO.NET Network +# MariaDB version of GlobalChatDatabase - FINAL COMPLETE FIX +import aiomysql +import logging +import asyncio +from typing import Optional, List, Dict +from datetime import datetime, timedelta +from mxmariadb.connector import MariaConnector + +# Setze das Logging auf Info, damit wir sehen, was passiert +logger = logging.getLogger(__name__) + +class GlobalChatDatabase(MariaConnector): + """MariaDB-backed GlobalChat database. Vollständige Version mit allen Funktionen.""" + + def __init__(self): + super().__init__() + + async def _ensure_pool(self): + """Stellt sicher, dass die Verbindung steht.""" + if self.pool is None: + await self.connect() # Versuch zu verbinden + + attempts = 0 + while self.pool is None and attempts < 15: # Etwas länger warten + await asyncio.sleep(0.5) + attempts += 1 + + if self.pool is None: + # Wenn wir hier landen, konnte connect() den Pool nicht erstellen + logger.error("!!! DATABASE POOL COULD NOT BE INITIALIZED !!!") + # Wir werfen keinen harten RuntimeError mehr, damit der Bot nicht stirbt + return False + return True + + async def init_db(self): + """Create all required tables.""" + await self.create_tables() + + async def create_tables(self): + try: + # 1. Verbindung versuchen + await self._ensure_pool() + + # 2. DER KRITISCHE CHECK: Wenn der Pool nicht existiert, abbrechen statt crashen! + if self.pool is None: + logger.error("!!! DATABASE POOL IS NONE - SKIPPING TABLE CREATION !!!") + return # Hier beenden wir die Funktion sauber. Der Bot bleibt online! + + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + # Channels Table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS globalchat_channels ( + guild_id BIGINT PRIMARY KEY, + channel_id BIGINT NOT NULL, + guild_name VARCHAR(255), + channel_name VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + message_count BIGINT DEFAULT 0, + is_active TINYINT(1) DEFAULT 1 + ) + """) + + # Message Log Table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS message_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + content TEXT, + attachment_urls TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_guild_ts (guild_id, timestamp) + ) + """) + + # Blacklist Table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS globalchat_blacklist ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + entity_type VARCHAR(10) NOT NULL, + entity_id BIGINT NOT NULL, + reason TEXT, + banned_by BIGINT, + banned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + is_permanent TINYINT(1) DEFAULT 0, + UNIQUE KEY uq_entity (entity_type, entity_id) + ) + """) + + # Guild Settings Table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS guild_settings ( + guild_id BIGINT PRIMARY KEY, + filter_enabled TINYINT(1) DEFAULT 1, + nsfw_filter TINYINT(1) DEFAULT 1, + embed_color VARCHAR(10) DEFAULT '#5865F2', + custom_webhook_name VARCHAR(255), + max_message_length INT DEFAULT 1900, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Daily Stats Table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS daily_stats ( + date DATE PRIMARY KEY, + total_messages BIGINT DEFAULT 0, + active_guilds INT DEFAULT 0, + active_users INT DEFAULT 0 + ) + """) + await conn.commit() + logger.info("MariaDB globalchat tables created/verified") + except Exception as e: + logger.error(f"Error creating tables: {e}") + # WICHTIG: Kein 'raise' hier, wenn der Bot trotz DB-Fehler starten soll! + + # ------------------------------------------------------------------ + # Channels + # ------------------------------------------------------------------ + + async def set_globalchat_channel(self, guild_id: int, channel_id: int, + guild_name: str = None, channel_name: str = None) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO globalchat_channels + (guild_id, channel_id, guild_name, channel_name, last_activity) + VALUES (%s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + channel_id = VALUES(channel_id), + guild_name = VALUES(guild_name), + channel_name = VALUES(channel_name), + last_activity = NOW() + """, (guild_id, channel_id, guild_name, channel_name)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error setting GlobalChat channel: {e}") + return False + + async def get_all_channels(self) -> List[int]: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT channel_id FROM globalchat_channels WHERE is_active = 1") + rows = await cur.fetchall() + return [r[0] for r in rows] + except Exception as e: + logger.error(f"Error retrieving all channels: {e}") + return [] + + async def get_globalchat_channel(self, guild_id: int) -> Optional[int]: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT channel_id FROM globalchat_channels WHERE guild_id = %s AND is_active = 1", (guild_id,)) + result = await cur.fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error retrieving channel for guild {guild_id}: {e}") + return None + + async def remove_globalchat_channel(self, guild_id: int) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM globalchat_channels WHERE guild_id = %s", (guild_id,)) + changes = cur.rowcount + await conn.commit() + return changes > 0 + except Exception as e: + logger.error(f"Error removing GlobalChat channel: {e}") + return False + + async def update_channel_activity(self, guild_id: int): + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE globalchat_channels + SET last_activity = NOW(), message_count = message_count + 1 + WHERE guild_id = %s + """, (guild_id,)) + await conn.commit() + except Exception as e: + logger.error(f"Error updating activity: {e}") + + # ------------------------------------------------------------------ + # Message Log + # ------------------------------------------------------------------ + + async def log_message(self, user_id: int, guild_id: int, channel_id: int, + content: str, attachment_urls: str = None): + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO message_log (user_id, guild_id, channel_id, content, attachment_urls) + VALUES (%s, %s, %s, %s, %s) + """, (user_id, guild_id, channel_id, content, attachment_urls)) + await conn.commit() + except Exception as e: + logger.error(f"Error logging message: {e}") + + async def get_user_message_history(self, user_id: int, limit: int = 10) -> List[Dict]: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT * FROM message_log WHERE user_id = %s + ORDER BY timestamp DESC LIMIT %s + """, (user_id, limit)) + return await cur.fetchall() + except Exception as e: + logger.error(f"Error retrieving history: {e}") + return [] + + # ------------------------------------------------------------------ + # Blacklist + # ------------------------------------------------------------------ + + async def add_to_blacklist(self, entity_type: str, entity_id: int, reason: str, + banned_by: int, duration_hours: int = None) -> bool: + try: + await self._ensure_pool() + expires_at = datetime.now() + timedelta(hours=duration_hours) if duration_hours else None + is_perm = 1 if duration_hours is None else 0 + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO globalchat_blacklist + (entity_type, entity_id, reason, banned_by, expires_at, is_permanent) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + reason = VALUES(reason), + banned_by = VALUES(banned_by), + expires_at = VALUES(expires_at), + is_permanent = VALUES(is_permanent) + """, (entity_type, entity_id, reason, banned_by, expires_at, is_perm)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error adding to blacklist: {e}") + return False + + async def remove_from_blacklist(self, entity_type: str, entity_id: int) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM globalchat_blacklist WHERE entity_type = %s AND entity_id = %s", (entity_type, entity_id)) + changes = cur.rowcount + await conn.commit() + return changes > 0 + except Exception as e: + logger.error(f"Error removing from blacklist: {e}") + return False + + async def is_blacklisted(self, entity_type: str, entity_id: int) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT expires_at, is_permanent FROM globalchat_blacklist WHERE entity_type = %s AND entity_id = %s", (entity_type, entity_id)) + res = await cur.fetchone() + if not res: return False + if res['is_permanent']: return True + if res['expires_at'] and datetime.now() > res['expires_at']: + await self.remove_from_blacklist(entity_type, entity_id) + return False + return True + except Exception as e: + logger.error(f"Error checking blacklist: {e}") + return False + + async def get_blacklist(self, entity_type: str = None) -> List[Dict]: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if entity_type: + await cur.execute("SELECT * FROM globalchat_blacklist WHERE entity_type = %s", (entity_type,)) + else: + await cur.execute("SELECT * FROM globalchat_blacklist") + return await cur.fetchall() + except Exception as e: + logger.error(f"Error getting blacklist: {e}") + return [] + + # ------------------------------------------------------------------ + # Guild Settings + # ------------------------------------------------------------------ + + async def get_guild_settings(self, guild_id: int) -> Dict: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM guild_settings WHERE guild_id = %s", (guild_id,)) + res = await cur.fetchone() + if res: return dict(res) + return {'guild_id': guild_id, 'filter_enabled': True, 'nsfw_filter': True, 'embed_color': '#5865F2'} + except Exception as e: + logger.error(f"Error getting settings: {e}") + return {} + + async def update_guild_setting(self, guild_id: int, setting_name: str, value) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(f"INSERT INTO guild_settings (guild_id, {setting_name}) VALUES (%s, %s) ON DUPLICATE KEY UPDATE {setting_name} = %s", (guild_id, value, value)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error updating settings: {e}") + return False + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ + + async def get_global_stats(self) -> Dict: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT COUNT(*) AS cnt FROM globalchat_channels WHERE is_active = 1") + active_guilds = (await cur.fetchone())['cnt'] + await cur.execute("SELECT total_messages FROM daily_stats WHERE date = CURDATE()") + today = await cur.fetchone() + today_msg = today['total_messages'] if today else 0 + await cur.execute("SELECT COALESCE(SUM(message_count), 0) AS total FROM globalchat_channels") + total_msg = (await cur.fetchone())['total'] + return {'active_guilds': active_guilds, 'total_messages': total_msg, 'today_messages': today_msg} + except Exception as e: + logger.error(f"Error getting stats: {e}") + return {} + + async def update_daily_stats(self): + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT COUNT(*) FROM globalchat_channels") + cnt = (await cur.fetchone())[0] + await cur.execute(""" + INSERT INTO daily_stats (date, total_messages, active_guilds) + VALUES (CURDATE(), 1, %s) + ON DUPLICATE KEY UPDATE total_messages = total_messages + 1, active_guilds = %s + """, (cnt, cnt)) + await conn.commit() + except Exception as e: + logger.error(f"Error updating daily stats: {e}") + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + async def cleanup_old_data(self, days: int = 30): + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM message_log WHERE timestamp < DATE_SUB(NOW(), INTERVAL %s DAY)", (days,)) + await cur.execute("DELETE FROM globalchat_blacklist WHERE expires_at < NOW() AND is_permanent = 0") + await cur.execute("DELETE FROM daily_stats WHERE date < DATE_SUB(CURDATE(), INTERVAL 90 DAY)") + await conn.commit() + logger.info(f"Cleanup completed for data older than {days} days") + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + async def delete_user_data(self, user_id: int) -> bool: + try: + await self._ensure_pool() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM message_log WHERE user_id = %s", (user_id,)) + await cur.execute("DELETE FROM globalchat_blacklist WHERE entity_type = 'user' AND entity_id = %s", (user_id,)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error deleting user data: {e}") + return False \ No newline at end of file diff --git a/mxmariadb/mxmariadb/levelsystem_db.py b/mxmariadb/mxmariadb/levelsystem_db.py new file mode 100644 index 0000000..df133ea --- /dev/null +++ b/mxmariadb/mxmariadb/levelsystem_db.py @@ -0,0 +1,391 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of LevelDatabase +import aiomysql +import logging +import time +from typing import Optional, List, Tuple, Dict, Any +from collections import defaultdict +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class AntiSpamDetector: + """In-memory anti-spam (identical to aiosqlite version).""" + def __init__(self): + self.user_patterns = defaultdict(list) + self.user_messages = defaultdict(list) + + def is_xp_farming(self, user_id: int, message_content: str, timestamp: float) -> bool: + patterns = self.user_patterns[user_id] + patterns = [(c, ts) for c, ts in patterns if timestamp - ts < 600] + self.user_patterns[user_id] = patterns + recent = [c for c, _ in patterns[-5:]] + if recent.count(message_content) >= 3: + return True + if len(message_content.strip()) < 3: + return True + patterns.append((message_content, timestamp)) + return False + + def is_spam(self, user_id: int, current_time: float, max_messages: int = 5, time_window: int = 60) -> bool: + msgs = self.user_messages[user_id] + msgs = [t for t in msgs if current_time - t < time_window] + self.user_messages[user_id] = msgs + if len(msgs) >= max_messages: + return True + msgs.append(current_time) + return False + + +class LevelDatabase(MariaConnector): + """MariaDB-backed level system. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + self.anti_spam = AntiSpamDetector() + self.level_roles_cache: Dict[int, Dict[int, int]] = {} + self.enabled_guilds_cache: set = set() + self.guild_configs_cache: Dict[int, Dict] = {} + + async def init_db(self): + """Create tables and load caches.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS user_levels ( + user_id BIGINT, + guild_id BIGINT, + xp BIGINT DEFAULT 0, + level INT DEFAULT 0, + messages BIGINT DEFAULT 0, + last_message DOUBLE DEFAULT 0, + prestige_level INT DEFAULT 0, + total_xp_earned BIGINT DEFAULT 0, + PRIMARY KEY (user_id, guild_id), + INDEX idx_guild_xp (guild_id, xp) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS level_roles ( + guild_id BIGINT, + level INT, + role_id BIGINT, + is_temporary TINYINT(1) DEFAULT 0, + duration_hours INT DEFAULT 0, + PRIMARY KEY (guild_id, level, role_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS guild_settings_level ( + guild_id BIGINT PRIMARY KEY, + levelsystem_enabled TINYINT(1) DEFAULT 1, + min_xp INT DEFAULT 10, + max_xp INT DEFAULT 20, + xp_cooldown INT DEFAULT 30, + level_up_channel BIGINT, + webhook_url TEXT, + prestige_enabled TINYINT(1) DEFAULT 1, + prestige_min_level INT DEFAULT 50 + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS channel_settings_level ( + guild_id BIGINT, + channel_id BIGINT, + xp_multiplier DOUBLE DEFAULT 1.0, + is_blacklisted TINYINT(1) DEFAULT 0, + PRIMARY KEY (guild_id, channel_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS xp_boosts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT, + user_id BIGINT, + multiplier DOUBLE, + start_time DOUBLE, + end_time DOUBLE, + is_global TINYINT(1) DEFAULT 0, + INDEX idx_active (guild_id, start_time, end_time) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS achievements_level ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT, + user_id BIGINT, + achievement_type VARCHAR(50), + achievement_value INT, + earned_at DOUBLE, + UNIQUE KEY uq_ach (guild_id, user_id, achievement_type, achievement_value) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS temporary_roles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT, + user_id BIGINT, + role_id BIGINT, + granted_at DOUBLE, + expires_at DOUBLE + ) + ''') + await conn.commit() + await self.load_caches() + logger.info("MariaDB levelsystem tables initialized") + + async def load_caches(self): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('SELECT guild_id, level, role_id FROM level_roles') + for guild_id, level, role_id in await cur.fetchall(): + if guild_id not in self.level_roles_cache: + self.level_roles_cache[guild_id] = {} + self.level_roles_cache[guild_id][level] = role_id + + await cur.execute('SELECT guild_id FROM guild_settings_level WHERE levelsystem_enabled = 1') + self.enabled_guilds_cache = {r[0] for r in await cur.fetchall()} + + await cur.execute('SELECT * FROM guild_settings_level') + for row in await cur.fetchall(): + self.guild_configs_cache[row[0]] = { + 'enabled': row[1], 'min_xp': row[2], 'max_xp': row[3], + 'cooldown': row[4], 'level_up_channel': row[5], 'webhook_url': row[6], + 'prestige_enabled': row[7] if len(row) > 7 else True, + 'prestige_min_level': row[8] if len(row) > 8 else 50 + } + + async def add_xp(self, user_id: int, guild_id: int, xp_amount: int, + message_content: str = "") -> Tuple[bool, int]: + current_time = time.time() + if self.anti_spam.is_spam(user_id, current_time): + return False, 0 + if message_content and self.anti_spam.is_xp_farming(user_id, message_content, current_time): + return False, 0 + + xp_amount = int(xp_amount * await self.get_active_xp_multiplier(guild_id, user_id)) + + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT xp, level, messages, total_xp_earned FROM user_levels ' + 'WHERE user_id = %s AND guild_id = %s', (user_id, guild_id)) + result = await cur.fetchone() + + if result: + current_xp, current_level, messages, total_earned = result + new_xp = current_xp + xp_amount + new_level = self.calculate_level(new_xp) + await cur.execute(''' + UPDATE user_levels + SET xp = %s, level = %s, messages = messages + 1, + last_message = %s, total_xp_earned = %s + WHERE user_id = %s AND guild_id = %s + ''', (new_xp, new_level, current_time, total_earned + xp_amount, + user_id, guild_id)) + level_up = new_level > current_level + else: + new_xp = xp_amount + new_level = self.calculate_level(new_xp) + await cur.execute(''' + INSERT INTO user_levels + (user_id, guild_id, xp, level, messages, last_message, total_xp_earned) + VALUES (%s, %s, %s, %s, 1, %s, %s) + ''', (user_id, guild_id, new_xp, new_level, current_time, xp_amount)) + level_up = new_level > 0 + await conn.commit() + return level_up, new_level + + async def get_user_stats(self, user_id: int, guild_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT xp, level, messages, prestige_level, total_xp_earned ' + 'FROM user_levels WHERE user_id = %s AND guild_id = %s', + (user_id, guild_id)) + result = await cur.fetchone() + if result: + xp, level, messages, prestige, total_earned = result + xp_needed = self.xp_for_level(level + 1) - xp + return xp, level, messages, xp_needed, prestige, total_earned + return None + + async def get_leaderboard(self, guild_id: int, limit: int = 10) -> List[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT user_id, xp, level, messages, prestige_level ' + 'FROM user_levels WHERE guild_id = %s ' + 'ORDER BY prestige_level DESC, level DESC, xp DESC LIMIT %s', + (guild_id, limit)) + return await cur.fetchall() + + async def get_user_rank(self, user_id: int, guild_id: int) -> int: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT xp, level, prestige_level FROM user_levels ' + 'WHERE user_id = %s AND guild_id = %s', (user_id, guild_id)) + user = await cur.fetchone() + if not user: + return 0 + xp, level, prestige = user + await cur.execute(''' + SELECT COUNT(*) + 1 FROM user_levels + WHERE guild_id = %s AND ( + prestige_level > %s OR + (prestige_level = %s AND level > %s) OR + (prestige_level = %s AND level = %s AND xp > %s) + ) + ''', (guild_id, prestige, prestige, level, prestige, level, xp)) + return (await cur.fetchone())[0] + + async def get_active_xp_multiplier(self, guild_id: int, user_id: int) -> float: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + ct = time.time() + await cur.execute(''' + SELECT multiplier FROM xp_boosts + WHERE guild_id = %s AND (user_id = %s OR is_global = 1) + AND start_time <= %s AND end_time > %s + ORDER BY multiplier DESC LIMIT 1 + ''', (guild_id, user_id, ct, ct)) + result = await cur.fetchone() + return result[0] if result else 1.0 + + # --- Config --- + + async def set_guild_config(self, guild_id: int, **config): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + keys = list(config.keys()) + ['guild_id'] + values = list(config.values()) + [guild_id] + placeholders = ', '.join(['%s'] * len(keys)) + update_clause = ', '.join([f"{k} = VALUES({k})" for k in config.keys()]) + await cur.execute(f""" + INSERT INTO guild_settings_level ({', '.join(keys)}) + VALUES ({placeholders}) + ON DUPLICATE KEY UPDATE {update_clause} + """, values) + await conn.commit() + if guild_id not in self.guild_configs_cache: + self.guild_configs_cache[guild_id] = {} + self.guild_configs_cache[guild_id].update(config) + + async def get_guild_config(self, guild_id: int) -> Dict[str, Any]: + if guild_id in self.guild_configs_cache: + return self.guild_configs_cache[guild_id] + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('SELECT * FROM guild_settings_level WHERE guild_id = %s', (guild_id,)) + result = await cur.fetchone() + if result: + config = { + 'enabled': result[1], 'min_xp': result[2], 'max_xp': result[3], + 'cooldown': result[4], 'level_up_channel': result[5], + 'webhook_url': result[6], + 'prestige_enabled': result[7] if len(result) > 7 else True, + 'prestige_min_level': result[8] if len(result) > 8 else 50 + } + else: + config = {'enabled': True, 'min_xp': 10, 'max_xp': 20, 'cooldown': 30, + 'level_up_channel': None, 'webhook_url': None, + 'prestige_enabled': True, 'prestige_min_level': 50} + self.guild_configs_cache[guild_id] = config + return config + + # --- Level Roles --- + + async def add_level_role(self, guild_id: int, level: int, role_id: int, + is_temporary: bool = False, duration_hours: int = 0): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO level_roles (guild_id, level, role_id, is_temporary, duration_hours) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE role_id = VALUES(role_id), + is_temporary = VALUES(is_temporary), duration_hours = VALUES(duration_hours) + ''', (guild_id, level, role_id, is_temporary, duration_hours)) + await conn.commit() + if guild_id not in self.level_roles_cache: + self.level_roles_cache[guild_id] = {} + self.level_roles_cache[guild_id][level] = role_id + + async def remove_level_role(self, guild_id: int, level: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'DELETE FROM level_roles WHERE guild_id = %s AND level = %s', + (guild_id, level)) + await conn.commit() + if guild_id in self.level_roles_cache and level in self.level_roles_cache[guild_id]: + del self.level_roles_cache[guild_id][level] + + async def get_level_roles(self, guild_id: int) -> List[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT level, role_id, is_temporary, duration_hours FROM level_roles ' + 'WHERE guild_id = %s ORDER BY level ASC', (guild_id,)) + return await cur.fetchall() + + def get_role_for_level(self, guild_id: int, level: int) -> Optional[int]: + if guild_id in self.level_roles_cache: + applicable = {l: r for l, r in self.level_roles_cache[guild_id].items() if l <= level} + if applicable: + return applicable[max(applicable.keys())] + return None + + async def set_levelsystem_enabled(self, guild_id: int, enabled: bool): + await self.set_guild_config(guild_id, levelsystem_enabled=enabled) + if enabled: + self.enabled_guilds_cache.add(guild_id) + else: + self.enabled_guilds_cache.discard(guild_id) + + def is_levelsystem_enabled(self, guild_id: int) -> bool: + return guild_id in self.enabled_guilds_cache + + # --- Helpers --- + + @staticmethod + def calculate_level(xp: int) -> int: + level = 0 + while xp >= LevelDatabase.xp_for_level(level + 1): + level += 1 + return level + + @staticmethod + def xp_for_level(level: int) -> int: + if level == 0: + return 0 + return int(100 * (level ** 1.5)) + + # --- Maintenance --- + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM user_levels WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM achievements_level WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM xp_boosts WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM temporary_roles WHERE user_id = %s', (user_id,)) + await conn.commit() + return True + except Exception: + return False + + async def cleanup_old_data(self, days: int = 30) -> int: + cutoff = time.time() - (days * 86400) + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM xp_boosts WHERE end_time < %s', (cutoff,)) + await cur.execute('DELETE FROM temporary_roles WHERE expires_at < %s', (cutoff,)) + deleted = cur.rowcount + await conn.commit() + return deleted + except Exception: + return 0 diff --git a/mxmariadb/mxmariadb/logging_db.py b/mxmariadb/mxmariadb/logging_db.py new file mode 100644 index 0000000..b2015ac --- /dev/null +++ b/mxmariadb/mxmariadb/logging_db.py @@ -0,0 +1,200 @@ +# Copyright (c) 2025 OPPRO.NET Network +import aiomysql +import logging +from typing import Optional, Dict, List +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class LoggingDatabase(MariaConnector): + + def __init__(self): + super().__init__() + + async def init_db(self): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS log_channels ( + guild_id BIGINT NOT NULL, + log_type VARCHAR(50) NOT NULL, + channel_id BIGINT NOT NULL, + enabled TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, log_type), + INDEX idx_guild_enabled (guild_id, enabled), + INDEX idx_channel_id (channel_id) + ) + ''') + await conn.commit() + logger.info("LoggingDatabase: Tabellen initialisiert.") + except Exception as e: + logger.critical(f"LoggingDatabase.init_db() fehlgeschlagen: {e}") + raise + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + async def set_log_channel(self, guild_id: int, channel_id: int, log_type: str = 'general'): + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO log_channels (guild_id, log_type, channel_id, enabled) + VALUES (%s, %s, %s, 1) + ON DUPLICATE KEY UPDATE channel_id = VALUES(channel_id), enabled = 1 + ''', (guild_id, log_type, channel_id)) + await conn.commit() + + async def remove_log_channel(self, guild_id: int, log_type: str = None) -> int: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if log_type: + await cur.execute( + 'DELETE FROM log_channels WHERE guild_id = %s AND log_type = %s', + (guild_id, log_type)) + else: + await cur.execute( + 'DELETE FROM log_channels WHERE guild_id = %s', (guild_id,)) + deleted = cur.rowcount + await conn.commit() + return deleted + + async def remove_all_log_channels(self, guild_id: int) -> int: + return await self.remove_log_channel(guild_id) + + async def disable_logging(self, guild_id: int, log_type: str = None) -> int: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if log_type: + await cur.execute( + 'UPDATE log_channels SET enabled = 0 WHERE guild_id = %s AND log_type = %s', + (guild_id, log_type)) + else: + await cur.execute( + 'UPDATE log_channels SET enabled = 0 WHERE guild_id = %s', (guild_id,)) + updated = cur.rowcount + await conn.commit() + return updated + + async def enable_logging(self, guild_id: int, log_type: str = None) -> int: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if log_type: + await cur.execute( + 'UPDATE log_channels SET enabled = 1 WHERE guild_id = %s AND log_type = %s', + (guild_id, log_type)) + else: + await cur.execute( + 'UPDATE log_channels SET enabled = 1 WHERE guild_id = %s', (guild_id,)) + updated = cur.rowcount + await conn.commit() + return updated + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + async def get_log_channel(self, guild_id: int, log_type: str = 'general') -> Optional[int]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + SELECT channel_id FROM log_channels + WHERE guild_id = %s AND log_type = %s AND enabled = 1 + ''', (guild_id, log_type)) + row = await cur.fetchone() + return row[0] if row else None + + async def get_all_log_channels(self, guild_id: int) -> Dict[str, int]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + SELECT log_type, channel_id FROM log_channels + WHERE guild_id = %s AND enabled = 1 ORDER BY log_type + ''', (guild_id,)) + return dict(await cur.fetchall()) + + async def channel_exists(self, guild_id: int, log_type: str) -> bool: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT 1 FROM log_channels WHERE guild_id = %s AND log_type = %s', + (guild_id, log_type)) + return await cur.fetchone() is not None + + async def get_guilds_with_logging(self) -> List[int]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT DISTINCT guild_id FROM log_channels WHERE enabled = 1') + return [r[0] for r in await cur.fetchall()] + + async def get_channels_by_guild(self, guild_id: int) -> List[Dict]: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT log_type, channel_id, enabled, created_at, updated_at + FROM log_channels WHERE guild_id = %s ORDER BY log_type + ''', (guild_id,)) + return await cur.fetchall() + + async def cleanup_invalid_channels(self, valid_channel_ids: set) -> int: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if not valid_channel_ids: + await cur.execute('DELETE FROM log_channels') + else: + placeholders = ','.join(['%s'] * len(valid_channel_ids)) + await cur.execute( + f'DELETE FROM log_channels WHERE channel_id NOT IN ({placeholders})', + list(valid_channel_ids)) + deleted = cur.rowcount + await conn.commit() + return deleted + + async def get_statistics(self) -> Dict: + await self.ensure_connection() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + stats = {} + await cur.execute('SELECT COUNT(*) FROM log_channels') + stats['total_entries'] = (await cur.fetchone())[0] + await cur.execute('SELECT COUNT(*) FROM log_channels WHERE enabled = 1') + stats['enabled_entries'] = (await cur.fetchone())[0] + await cur.execute('SELECT COUNT(DISTINCT guild_id) FROM log_channels') + stats['unique_guilds'] = (await cur.fetchone())[0] + await cur.execute('SELECT COUNT(DISTINCT channel_id) FROM log_channels') + stats['unique_channels'] = (await cur.fetchone())[0] + await cur.execute(''' + SELECT log_type, COUNT(*) FROM log_channels + WHERE enabled = 1 GROUP BY log_type + ''') + stats['log_types'] = dict(await cur.fetchall()) + return stats + + async def backup_database(self, path: str) -> bool: + """Placeholder – MariaDB-Backups laufen extern via mysqldump.""" + logger.info(f"Backup angefragt nach: {path} (extern via mysqldump empfohlen)") + return False + + async def delete_user_data(self, user_id: int) -> bool: + return True + + async def cleanup_old_data(self, days: int = 30) -> int: + return 0 + + def close(self): + pass \ No newline at end of file diff --git a/mxmariadb/mxmariadb/notes_db.py b/mxmariadb/mxmariadb/notes_db.py new file mode 100644 index 0000000..e967f54 --- /dev/null +++ b/mxmariadb/mxmariadb/notes_db.py @@ -0,0 +1,96 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of NotesDatabase +import aiomysql +import logging +from typing import Optional, List, Dict +from datetime import datetime, timedelta +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class NotesDatabase(MariaConnector): + """MariaDB-backed notes database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create table.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS notes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT, + user_id BIGINT, + author_id BIGINT, + author_name VARCHAR(255), + note TEXT, + timestamp VARCHAR(50), + INDEX idx_guild_user (guild_id, user_id) + ) + """) + await conn.commit() + logger.info("MariaDB notes tables initialized") + + async def add_note(self, guild_id: int, user_id: int, author_id: int, + author_name: str, note: str, timestamp: str): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO notes (guild_id, user_id, author_id, author_name, note, timestamp) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (guild_id, user_id, author_id, author_name, note, timestamp)) + await conn.commit() + + async def get_notes(self, guild_id: int, user_id: int) -> List[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT id, note, timestamp, author_name FROM notes " + "WHERE guild_id = %s AND user_id = %s", + (guild_id, user_id)) + rows = await cur.fetchall() + return [ + {"id": r[0], "content": r[1], "timestamp": r[2], "author_name": r[3]} + for r in rows + ] + + async def delete_note(self, note_id: int) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM notes WHERE id = %s", (note_id,)) + success = cur.rowcount > 0 + await conn.commit() + return success + + async def get_note_by_id(self, note_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT * FROM notes WHERE id = %s", (note_id,)) + return await cur.fetchone() + + # --- Maintenance --- + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM notes WHERE user_id = %s", (user_id,)) + await conn.commit() + return True + except Exception: + return False + + async def cleanup_old_data(self, days: int = 30) -> int: + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM notes WHERE timestamp < %s", (cutoff,)) + count = cur.rowcount + await conn.commit() + return count + except Exception: + return 0 diff --git a/mxmariadb/mxmariadb/profile_db.py b/mxmariadb/mxmariadb/profile_db.py new file mode 100644 index 0000000..ab23636 --- /dev/null +++ b/mxmariadb/mxmariadb/profile_db.py @@ -0,0 +1,372 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of ProfileDB +import aiomysql +import json +import logging +from datetime import datetime +from typing import Optional, List, Dict, Any +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class ProfileDB(MariaConnector): + """MariaDB-backed profile database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create all required tables.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS profiles ( + user_id BIGINT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + bio TEXT DEFAULT '', + color VARCHAR(10) DEFAULT '#5865F2', + banner TEXT, + theme VARCHAR(50) DEFAULT 'default', + privacy VARCHAR(20) DEFAULT 'public', + language VARCHAR(10) DEFAULT 'de', + level INT DEFAULT 1, + xp BIGINT DEFAULT 0, + xp_needed BIGINT DEFAULT 100, + created_at VARCHAR(50) NOT NULL, + updated_at VARCHAR(50) NOT NULL + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS profile_links ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + emoji VARCHAR(10) DEFAULT '🔗', + position INT DEFAULT 0, + INDEX idx_user (user_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS achievements_profile ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(10), + unlocked_at VARCHAR(50) NOT NULL, + INDEX idx_user (user_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS marketplace ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + tags TEXT, + author_id BIGINT NOT NULL, + author_name VARCHAR(255) NOT NULL, + profile_data TEXT NOT NULL, + downloads BIGINT DEFAULT 0, + rating DOUBLE DEFAULT 0.0, + created_at VARCHAR(50) NOT NULL + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS marketplace_downloads ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + marketplace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + downloaded_at VARCHAR(50) NOT NULL, + INDEX idx_marketplace (marketplace_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS marketplace_ratings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + marketplace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + rating INT NOT NULL, + rated_at VARCHAR(50) NOT NULL, + UNIQUE KEY uq_rate (marketplace_id, user_id) + ) + ''') + await conn.commit() + logger.info("MariaDB profile tables initialized") + + # ===== PROFILE ===== + + async def get_profile(self, user_id: int) -> Optional[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute('SELECT * FROM profiles WHERE user_id = %s', (user_id,)) + profile = await cur.fetchone() + if not profile: + return None + profile = dict(profile) + + await cur.execute( + 'SELECT name, url, emoji FROM profile_links WHERE user_id = %s ORDER BY position', + (user_id,)) + profile['links'] = [dict(r) for r in await cur.fetchall()] + + await cur.execute( + 'SELECT name, description, icon, unlocked_at FROM achievements_profile ' + 'WHERE user_id = %s ORDER BY unlocked_at DESC', (user_id,)) + profile['achievements'] = [dict(r) for r in await cur.fetchall()] + + return profile + + async def create_profile(self, user_id: int, username: str) -> Dict: + now = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'INSERT INTO profiles (user_id, username, created_at, updated_at) VALUES (%s, %s, %s, %s)', + (user_id, username, now, now)) + await conn.commit() + return await self.get_profile(user_id) + + async def update_profile_setting(self, user_id: int, key: str, value: Any) -> bool: + allowed = ['bio', 'color', 'banner', 'theme', 'privacy', 'language', + 'level', 'xp', 'xp_needed', 'username'] + if key not in allowed: + return False + now = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + f'UPDATE profiles SET {key} = %s, updated_at = %s WHERE user_id = %s', + (value, now, user_id)) + success = cur.rowcount > 0 + await conn.commit() + return success + + async def delete_profile(self, user_id: int) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM profile_links WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM achievements_profile WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM profiles WHERE user_id = %s', (user_id,)) + success = cur.rowcount > 0 + await conn.commit() + return success + + # ===== LINKS ===== + + async def add_profile_link(self, user_id: int, link_data: Dict) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT COUNT(*) FROM profile_links WHERE user_id = %s', (user_id,)) + count = (await cur.fetchone())[0] + if count >= 5: + return False + await cur.execute( + 'INSERT INTO profile_links (user_id, name, url, emoji, position) VALUES (%s, %s, %s, %s, %s)', + (user_id, link_data['name'], link_data['url'], link_data.get('emoji', '🔗'), count)) + await conn.commit() + return True + + async def delete_profile_link(self, user_id: int, link_index: int) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT id FROM profile_links WHERE user_id = %s ORDER BY position LIMIT 1 OFFSET %s', + (user_id, link_index)) + row = await cur.fetchone() + if not row: + return False + await cur.execute('DELETE FROM profile_links WHERE id = %s', (row[0],)) + await conn.commit() + return True + + async def get_profile_links(self, user_id: int) -> List[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT name, url, emoji FROM profile_links WHERE user_id = %s ORDER BY position', + (user_id,)) + return [dict(r) for r in await cur.fetchall()] + + # ===== ACHIEVEMENTS ===== + + async def add_achievement(self, user_id: int, name: str, description: str = "", icon: str = "🏆") -> bool: + now = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'INSERT INTO achievements_profile (user_id, name, description, icon, unlocked_at) ' + 'VALUES (%s, %s, %s, %s, %s)', + (user_id, name, description, icon, now)) + await conn.commit() + return True + + async def get_achievements(self, user_id: int) -> List[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT name, description, icon, unlocked_at FROM achievements_profile ' + 'WHERE user_id = %s ORDER BY unlocked_at DESC', (user_id,)) + return [dict(r) for r in await cur.fetchall()] + + # ===== MARKETPLACE ===== + + async def add_to_marketplace(self, marketplace_data: Dict) -> int: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'INSERT INTO marketplace (name, description, tags, author_id, author_name, profile_data, created_at) ' + 'VALUES (%s, %s, %s, %s, %s, %s, %s)', + (marketplace_data['name'], marketplace_data['description'], + json.dumps(marketplace_data['tags']), marketplace_data['author_id'], + marketplace_data['author_name'], json.dumps(marketplace_data['profile_data']), + marketplace_data['created_at'])) + last_id = cur.lastrowid + await conn.commit() + return last_id + + async def get_marketplace_profiles(self, search: Optional[str] = None) -> List[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if search: + s = f'%{search}%' + await cur.execute( + 'SELECT id, name, description, tags, author_id, author_name, downloads, rating, created_at ' + 'FROM marketplace WHERE name LIKE %s OR description LIKE %s OR tags LIKE %s ' + 'ORDER BY downloads DESC, rating DESC', (s, s, s)) + else: + await cur.execute( + 'SELECT id, name, description, tags, author_id, author_name, downloads, rating, created_at ' + 'FROM marketplace ORDER BY downloads DESC, rating DESC') + profiles = [] + for row in await cur.fetchall(): + p = dict(row) + p['tags'] = json.loads(p['tags']) + profiles.append(p) + return profiles + + async def get_marketplace_profile(self, profile_id: int) -> Optional[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute('SELECT * FROM marketplace WHERE id = %s', (profile_id,)) + row = await cur.fetchone() + if not row: + return None + p = dict(row) + p['tags'] = json.loads(p['tags']) + p['profile_data'] = json.loads(p['profile_data']) + return p + + async def download_marketplace_profile(self, marketplace_id: int, user_id: int) -> bool: + now = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT id FROM marketplace_downloads WHERE marketplace_id = %s AND user_id = %s', + (marketplace_id, user_id)) + if await cur.fetchone(): + return False + await cur.execute( + 'INSERT INTO marketplace_downloads (marketplace_id, user_id, downloaded_at) VALUES (%s, %s, %s)', + (marketplace_id, user_id, now)) + await cur.execute( + 'UPDATE marketplace SET downloads = downloads + 1 WHERE id = %s', (marketplace_id,)) + await conn.commit() + return True + + async def rate_marketplace_profile(self, marketplace_id: int, user_id: int, rating: int) -> bool: + if not 1 <= rating <= 5: + return False + now = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO marketplace_ratings (marketplace_id, user_id, rating, rated_at) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE rating = VALUES(rating), rated_at = VALUES(rated_at) + ''', (marketplace_id, user_id, rating, now)) + await cur.execute( + 'SELECT AVG(rating) FROM marketplace_ratings WHERE marketplace_id = %s', + (marketplace_id,)) + avg = (await cur.fetchone())[0] + await cur.execute( + 'UPDATE marketplace SET rating = %s WHERE id = %s', (round(avg, 1), marketplace_id)) + await conn.commit() + return True + + async def get_user_uploads(self, user_id: int) -> List[Dict]: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT id, name, description, downloads, rating, created_at ' + 'FROM marketplace WHERE author_id = %s ORDER BY created_at DESC', (user_id,)) + return [dict(r) for r in await cur.fetchall()] + + async def delete_marketplace_profile(self, profile_id: int, user_id: int) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'DELETE FROM marketplace WHERE id = %s AND author_id = %s', + (profile_id, user_id)) + success = cur.rowcount > 0 + await conn.commit() + return success + + # ===== XP ===== + + async def add_xp(self, user_id: int, amount: int) -> Optional[Dict]: + profile = await self.get_profile(user_id) + if not profile: + return None + new_xp = profile['xp'] + amount + new_level = profile['level'] + xp_needed = profile['xp_needed'] + while new_xp >= xp_needed: + new_xp -= xp_needed + new_level += 1 + xp_needed = int(xp_needed * 1.5) + + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'UPDATE profiles SET xp = %s, level = %s, xp_needed = %s, updated_at = %s WHERE user_id = %s', + (new_xp, new_level, xp_needed, datetime.now().isoformat(), user_id)) + await conn.commit() + return {'level': new_level, 'xp': new_xp, 'xp_needed': xp_needed, + 'leveled_up': new_level > profile['level']} + + # ===== STATS ===== + + async def get_stats(self) -> Dict: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('SELECT COUNT(*) FROM profiles') + total_profiles = (await cur.fetchone())[0] + await cur.execute('SELECT COUNT(*) FROM marketplace') + total_marketplace = (await cur.fetchone())[0] + await cur.execute('SELECT COALESCE(SUM(downloads), 0) FROM marketplace') + total_downloads = (await cur.fetchone())[0] + return {'total_profiles': total_profiles, 'total_marketplace': total_marketplace, + 'total_downloads': total_downloads} + + # ===== MAINTENANCE ===== + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM profile_links WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM achievements_profile WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM profiles WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM marketplace WHERE author_id = %s', (user_id,)) + await cur.execute('DELETE FROM marketplace_downloads WHERE user_id = %s', (user_id,)) + await cur.execute('DELETE FROM marketplace_ratings WHERE user_id = %s', (user_id,)) + await conn.commit() + return True + except Exception: + return False + + def close(self): + pass diff --git a/mxmariadb/mxmariadb/settings_db.py b/mxmariadb/mxmariadb/settings_db.py new file mode 100644 index 0000000..4bf5a2d --- /dev/null +++ b/mxmariadb/mxmariadb/settings_db.py @@ -0,0 +1,103 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of SettingsDB +import aiomysql +import logging +from typing import Dict +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class SettingsDB(MariaConnector): + """MariaDB-backed settings database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create tables.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS user_settings ( + user_id BIGINT PRIMARY KEY, + language VARCHAR(10) NOT NULL DEFAULT 'en' + ) + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS guild_settings_core ( + guild_id BIGINT PRIMARY KEY, + user_role_id BIGINT, + team_role_id BIGINT, + language VARCHAR(10) NOT NULL DEFAULT 'de' + ) + """) + await conn.commit() + logger.info("MariaDB settings tables initialized") + + async def set_user_language(self, user_id: int, lang_code: str): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO user_settings (user_id, language) VALUES (%s, %s) + ON DUPLICATE KEY UPDATE language = VALUES(language) + """, (user_id, lang_code)) + await conn.commit() + + async def get_user_language(self, user_id: int) -> str: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT language FROM user_settings WHERE user_id = %s", (user_id,)) + result = await cur.fetchone() + return result[0] if result else 'en' + + # --- Guild Settings --- + + async def get_guild_settings(self, guild_id: int) -> Dict: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT user_role_id, team_role_id, language FROM guild_settings_core WHERE guild_id = %s", + (guild_id,)) + result = await cur.fetchone() + if result: + return dict(result) + return {"user_role_id": None, "team_role_id": None, "language": "de"} + + async def update_guild_settings(self, guild_id: int, **kwargs): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT guild_id FROM guild_settings_core WHERE guild_id = %s", (guild_id,)) + if not await cur.fetchone(): + await cur.execute( + "INSERT INTO guild_settings_core (guild_id) VALUES (%s)", (guild_id,)) + for key, value in kwargs.items(): + if key in ["user_role_id", "team_role_id", "language"]: + await cur.execute( + f"UPDATE guild_settings_core SET {key} = %s WHERE guild_id = %s", + (value, guild_id)) + await conn.commit() + + async def get_guild_language(self, guild_id: int) -> str: + settings = await self.get_guild_settings(guild_id) + return settings.get("language", "de") + + async def set_guild_language(self, guild_id: int, lang_code: str): + await self.update_guild_settings(guild_id, language=lang_code) + + # --- Maintenance --- + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM user_settings WHERE user_id = %s", (user_id,)) + await conn.commit() + return True + except Exception: + return False + + def close(self): + pass diff --git a/mxmariadb/mxmariadb/stats_db.py b/mxmariadb/mxmariadb/stats_db.py new file mode 100644 index 0000000..6b6f0d1 --- /dev/null +++ b/mxmariadb/mxmariadb/stats_db.py @@ -0,0 +1,485 @@ +# Copyright (c) 2025 OPPRO.NET Network +import aiomysql +import json +import logging +import asyncio +from datetime import datetime, timedelta +from typing import Optional, List, Tuple, Dict +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class StatsDB(MariaConnector): + + def __init__(self): + super().__init__() + self.lock = asyncio.Lock() + + async def init_db(self): + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + message_id BIGINT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + word_count INT DEFAULT 0, + has_attachment TINYINT(1) DEFAULT 0, + message_type VARCHAR(20) DEFAULT 'text', + INDEX idx_user_ts (user_id, timestamp), + INDEX idx_guild_ts (guild_id, timestamp) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS voice_sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + start_time DATETIME DEFAULT CURRENT_TIMESTAMP, + end_time DATETIME, + duration_minutes DOUBLE DEFAULT 0, + INDEX idx_user_ts (user_id, start_time) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS global_user_levels ( + user_id BIGINT PRIMARY KEY, + global_level INT DEFAULT 1, + global_xp DOUBLE DEFAULT 0, + total_messages BIGINT DEFAULT 0, + total_voice_minutes DOUBLE DEFAULT 0, + total_servers INT DEFAULT 0, + first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + achievements TEXT DEFAULT '[]', + daily_streak INT DEFAULT 0, + best_streak INT DEFAULT 0, + last_daily_activity DATE, + is_private TINYINT(1) DEFAULT 0, + INDEX idx_xp (global_xp) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS daily_stats ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + date DATE NOT NULL, + messages_count BIGINT DEFAULT 0, + voice_minutes DOUBLE DEFAULT 0, + UNIQUE KEY uq_guild_date (guild_id, date) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS channel_stats ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + channel_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + date DATE NOT NULL, + total_messages BIGINT DEFAULT 0, + unique_users INT DEFAULT 0, + avg_words_per_message DOUBLE DEFAULT 0, + UNIQUE KEY uq_channel_date (channel_id, date) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS user_achievements ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + achievement_name VARCHAR(100) NOT NULL, + unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + description TEXT, + icon VARCHAR(10) DEFAULT '🏆' + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS active_voice_sessions ( + user_id BIGINT PRIMARY KEY, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + start_time DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + await conn.commit() + logger.info("StatsDB: Tabellen initialisiert.") + except Exception as e: + logger.critical(f"StatsDB.init_db() fehlgeschlagen: {e}") + raise + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + async def log_message(self, user_id: int, guild_id: int, channel_id: int, message_id: int, + word_count: int = 0, has_attachment: bool = False, message_type: str = 'text'): + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO messages (user_id, guild_id, channel_id, message_id, + word_count, has_attachment, message_type) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ''', (user_id, guild_id, channel_id, message_id, + word_count, has_attachment, message_type)) + + today = datetime.now().date() + await cur.execute(''' + INSERT INTO daily_stats (guild_id, date, messages_count) + VALUES (%s, %s, 1) + ON DUPLICATE KEY UPDATE messages_count = messages_count + 1 + ''', (guild_id, today)) + + await self._update_global_xp(cur, user_id, guild_id, 'message', word_count) + await conn.commit() + except Exception as e: + logger.error(f"log_message fehlgeschlagen: {e}") + + async def start_voice_session(self, user_id: int, guild_id: int, channel_id: int): + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT channel_id FROM active_voice_sessions WHERE user_id = %s', + (user_id,)) + if await cur.fetchone(): + await self._end_voice_internal(cur, user_id) + + await cur.execute(''' + INSERT INTO active_voice_sessions (user_id, guild_id, channel_id) + VALUES (%s, %s, %s) + ''', (user_id, guild_id, channel_id)) + await conn.commit() + except Exception as e: + logger.error(f"start_voice_session fehlgeschlagen: {e}") + + async def end_voice_session(self, user_id: int, channel_id: int): + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await self._end_voice_internal(cur, user_id) + await conn.commit() + except Exception as e: + logger.error(f"end_voice_session fehlgeschlagen: {e}") + + async def _end_voice_internal(self, cur, user_id: int): + await cur.execute( + 'SELECT guild_id, channel_id, start_time FROM active_voice_sessions WHERE user_id = %s', + (user_id,)) + session = await cur.fetchone() + if not session: + return + guild_id, channel_id, start_time = session + duration_minutes = min((datetime.now() - start_time).total_seconds() / 60, 1440) + if duration_minutes > 0.5: + await cur.execute(''' + INSERT INTO voice_sessions + (user_id, guild_id, channel_id, start_time, end_time, duration_minutes) + VALUES (%s, %s, %s, %s, %s, %s) + ''', (user_id, guild_id, channel_id, start_time, datetime.now(), duration_minutes)) + + today = datetime.now().date() + await cur.execute(''' + INSERT INTO daily_stats (guild_id, date, voice_minutes) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE voice_minutes = voice_minutes + %s + ''', (guild_id, today, duration_minutes, duration_minutes)) + + await self._update_global_xp(cur, user_id, guild_id, 'voice', duration_minutes) + + await cur.execute('DELETE FROM active_voice_sessions WHERE user_id = %s', (user_id,)) + + # ------------------------------------------------------------------ + # XP (unverändert, nur ensure_connection nicht nötig — läuft intern) + # ------------------------------------------------------------------ + + async def _update_global_xp(self, cur, user_id: int, guild_id: int, + activity_type: str, value: float = 0): + try: + xp_gain = (1 + min(value * 0.1, 5)) if activity_type == 'message' else value * 0.5 + + await cur.execute( + 'SELECT global_level, global_xp, total_messages, total_voice_minutes, ' + 'last_daily_activity, daily_streak FROM global_user_levels WHERE user_id = %s', + (user_id,)) + user_data = await cur.fetchone() + today = datetime.now().date() + + if user_data: + current_level, current_xp, total_msg, total_voice, last_daily, daily_streak = user_data + if last_daily: + if today == last_daily + timedelta(days=1): + daily_streak += 1 + elif today != last_daily: + daily_streak = 1 + else: + daily_streak = 1 + + new_xp = current_xp + xp_gain + new_level = self._calculate_level(new_xp) + if activity_type == 'message': + total_msg += 1 + else: + total_voice += value + + await cur.execute( + 'SELECT COUNT(DISTINCT guild_id) FROM messages WHERE user_id = %s', (user_id,)) + server_count = (await cur.fetchone())[0] or 1 + + await cur.execute(''' + UPDATE global_user_levels + SET global_level = %s, global_xp = %s, total_messages = %s, + total_voice_minutes = %s, total_servers = %s, + last_activity = NOW(), last_daily_activity = %s, + daily_streak = %s, best_streak = GREATEST(best_streak, %s) + WHERE user_id = %s + ''', (new_level, new_xp, total_msg, total_voice, server_count, + today, daily_streak, daily_streak, user_id)) + else: + initial_level = self._calculate_level(xp_gain) + await cur.execute(''' + INSERT INTO global_user_levels + (user_id, global_level, global_xp, total_messages, + total_voice_minutes, total_servers, last_daily_activity, + daily_streak, best_streak) + VALUES (%s, %s, %s, %s, %s, 1, %s, 1, 1) + ''', (user_id, initial_level, xp_gain, + 1 if activity_type == 'message' else 0, + value if activity_type == 'voice' else 0, today)) + except Exception as e: + logger.error(f"_update_global_xp fehlgeschlagen: {e}") + + def _calculate_level(self, xp: float) -> int: + if xp < 50: + return 1 + return int((xp / 50) ** (2 / 3)) + 1 + + def _xp_for_level(self, level: int) -> int: + if level <= 1: + return 0 + return int(50 * ((level - 1) ** 1.5)) + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + async def get_user_stats(self, user_id: int, hours: int = 24, + guild_id: Optional[int] = None) -> Tuple[int, float]: + await self.ensure_connection() + async with self.lock: + try: + cutoff = datetime.now() - timedelta(hours=hours) + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if guild_id: + await cur.execute( + 'SELECT COUNT(*) FROM messages ' + 'WHERE user_id=%s AND guild_id=%s AND timestamp>%s', + (user_id, guild_id, cutoff)) + else: + await cur.execute( + 'SELECT COUNT(*) FROM messages WHERE user_id=%s AND timestamp>%s', + (user_id, cutoff)) + msg_count = (await cur.fetchone())[0] or 0 + + if guild_id: + await cur.execute( + 'SELECT COALESCE(SUM(duration_minutes),0) FROM voice_sessions ' + 'WHERE user_id=%s AND guild_id=%s AND start_time>%s', + (user_id, guild_id, cutoff)) + else: + await cur.execute( + 'SELECT COALESCE(SUM(duration_minutes),0) FROM voice_sessions ' + 'WHERE user_id=%s AND start_time>%s', (user_id, cutoff)) + voice_mins = (await cur.fetchone())[0] or 0 + return msg_count, voice_mins + except Exception as e: + logger.error(f"get_user_stats fehlgeschlagen: {e}") + return 0, 0 + + async def get_global_user_info(self, user_id: int) -> Optional[Dict]: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + SELECT global_level, global_xp, total_messages, total_voice_minutes, + total_servers, daily_streak, best_streak, + first_seen, achievements, is_private + FROM global_user_levels WHERE user_id = %s + ''', (user_id,)) + result = await cur.fetchone() + if not result: + return None + level, xp, total_msg, total_voice, servers, streak, \ + best_streak, first_seen, achievements, is_private = result + + await cur.execute( + 'SELECT COUNT(*) + 1 FROM global_user_levels WHERE global_xp > %s', (xp,)) + rank = (await cur.fetchone())[0] + + next_xp = self._xp_for_level(level + 1) + curr_xp = self._xp_for_level(level) + return { + 'level': level, 'xp': xp, + 'xp_progress': xp - curr_xp, 'xp_needed': next_xp - curr_xp, + 'total_messages': total_msg, 'total_voice_minutes': total_voice, + 'total_servers': servers, 'daily_streak': streak, + 'best_streak': best_streak, 'first_seen': first_seen, + 'achievements': json.loads(achievements) if achievements else [], + 'is_private': is_private, 'rank': rank, + } + except Exception as e: + logger.error(f"get_global_user_info fehlgeschlagen: {e}") + return None + + async def get_leaderboard(self, limit: int = 10, guild_id: Optional[int] = None, + bot=None) -> List[Tuple]: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + if guild_id: + await cur.execute(''' + SELECT user_id, COUNT(*) AS messages, + COALESCE(SUM(word_count), 0) AS total_words + FROM messages + WHERE guild_id = %s + AND timestamp > DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY user_id ORDER BY messages DESC LIMIT %s + ''', (guild_id, limit)) + else: + await cur.execute(''' + SELECT user_id, global_level, global_xp, + total_messages, total_voice_minutes, is_private + FROM global_user_levels + ORDER BY global_xp DESC LIMIT %s + ''', (limit,)) + rows = await cur.fetchall() + + if bot is None: + return rows + + clean, orphans = [], [] + for row in rows: + (orphans if bot.get_user(row[0]) is None else clean).append(row) + if orphans: + for uid in [r[0] for r in orphans]: + await self._hard_delete_user(cur, uid) + await conn.commit() + return clean + except Exception as e: + logger.error(f"get_leaderboard fehlgeschlagen: {e}") + return [] + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + async def monthly_season_reset(self): + if datetime.now().day != 1: + return + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + for table in ('messages', 'voice_sessions', 'daily_stats'): + await cur.execute(f'DELETE FROM {table}') + await cur.execute(''' + UPDATE global_user_levels SET + global_level = 1, global_xp = 0, total_messages = 0, + total_voice_minutes = 0, total_servers = 0, + daily_streak = 0, best_streak = 0, last_daily_activity = NULL + ''') + await conn.commit() + logger.info("Monatlicher Season-Reset abgeschlossen.") + except Exception as e: + logger.error(f"monthly_season_reset fehlgeschlagen: {e}") + + async def cleanup_old_data(self, days: int = 30): + await self.ensure_connection() + async with self.lock: + try: + cutoff = datetime.now() - timedelta(days=days) + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM messages WHERE timestamp < %s', (cutoff,)) + await cur.execute('DELETE FROM voice_sessions WHERE start_time < %s', (cutoff,)) + await cur.execute('DELETE FROM daily_stats WHERE date < %s', (cutoff.date(),)) + await conn.commit() + logger.info(f"Cleanup: Daten älter als {days} Tage entfernt.") + except Exception as e: + logger.error(f"cleanup_old_data fehlgeschlagen: {e}") + + async def delete_user_data(self, user_id: int) -> bool: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await self._hard_delete_user(cur, user_id) + await conn.commit() + return True + except Exception as e: + logger.error(f"delete_user_data fehlgeschlagen: {e}") + return False + + async def _hard_delete_user(self, cur, user_id: int): + for table, col in [ + ('messages', 'user_id'), + ('voice_sessions', 'user_id'), + ('active_voice_sessions', 'user_id'), + ('global_user_levels', 'user_id'), + ('user_achievements', 'user_id'), + ]: + await cur.execute(f'DELETE FROM {table} WHERE {col} = %s', (user_id,)) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + async def get_daily_messages(self, guild_id: int, date: str) -> int: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT messages_count FROM daily_stats WHERE guild_id=%s AND date=%s', + (guild_id, date)) + row = await cur.fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"get_daily_messages fehlgeschlagen: {e}") + return 0 + + async def get_weekly_stats(self, guild_id: int) -> list: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT date, messages_count AS messages FROM daily_stats + WHERE guild_id = %s AND date > DATE_SUB(CURDATE(), INTERVAL 7 DAY) + ORDER BY date ASC + ''', (guild_id,)) + return [dict(r) for r in await cur.fetchall()] + except Exception as e: + logger.error(f"get_weekly_stats fehlgeschlagen: {e}") + return [] + + def close(self): + logger.info("StatsDB.close() aufgerufen (Pool wird über MariaConnector.close() geschlossen).") \ No newline at end of file diff --git a/mxmariadb/mxmariadb/vc_db.py b/mxmariadb/mxmariadb/vc_db.py new file mode 100644 index 0000000..b306821 --- /dev/null +++ b/mxmariadb/mxmariadb/vc_db.py @@ -0,0 +1,171 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of TempVCDatabase +import aiomysql +import logging +from typing import Optional, Tuple, List +from mxmariadb.connector import MariaConnector + +logger = logging.getLogger(__name__) + + +class TempVCDatabase(MariaConnector): + """MariaDB-backed TempVC database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create tables.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS tempvc_settings ( + guild_id BIGINT PRIMARY KEY, + creator_channel_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + auto_delete_time INT DEFAULT 0 + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS temp_channels ( + channel_id BIGINT PRIMARY KEY, + guild_id BIGINT NOT NULL, + owner_id BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_activity DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_guild (guild_id), + INDEX idx_owner (owner_id) + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS ui_settings ( + guild_id BIGINT PRIMARY KEY, + ui_enabled TINYINT(1) DEFAULT 0, + ui_prefix VARCHAR(10) DEFAULT '🔧' + ) + ''') + await conn.commit() + logger.info("MariaDB tempvc tables initialized") + + async def set_tempvc_settings(self, guild_id: int, creator_channel_id: int, + category_id: int, auto_delete_time: int = 0): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO tempvc_settings (guild_id, creator_channel_id, category_id, auto_delete_time) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + creator_channel_id = VALUES(creator_channel_id), + category_id = VALUES(category_id), + auto_delete_time = VALUES(auto_delete_time) + ''', (guild_id, creator_channel_id, category_id, auto_delete_time)) + await conn.commit() + + async def get_tempvc_settings(self, guild_id: int) -> Optional[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT creator_channel_id, category_id, auto_delete_time ' + 'FROM tempvc_settings WHERE guild_id = %s', (guild_id,)) + return await cur.fetchone() + + async def remove_tempvc_settings(self, guild_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM tempvc_settings WHERE guild_id = %s', (guild_id,)) + await cur.execute('DELETE FROM temp_channels WHERE guild_id = %s', (guild_id,)) + await cur.execute('DELETE FROM ui_settings WHERE guild_id = %s', (guild_id,)) + await conn.commit() + + async def add_temp_channel(self, channel_id: int, guild_id: int, owner_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'INSERT INTO temp_channels (channel_id, guild_id, owner_id) VALUES (%s, %s, %s)', + (channel_id, guild_id, owner_id)) + await conn.commit() + + async def remove_temp_channel(self, channel_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM temp_channels WHERE channel_id = %s', (channel_id,)) + await conn.commit() + + async def is_temp_channel(self, channel_id: int) -> bool: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT 1 FROM temp_channels WHERE channel_id = %s', (channel_id,)) + return await cur.fetchone() is not None + + async def get_temp_channel_owner(self, channel_id: int) -> Optional[int]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT owner_id FROM temp_channels WHERE channel_id = %s', (channel_id,)) + result = await cur.fetchone() + return result[0] if result else None + + async def get_all_temp_channels(self, guild_id: int) -> List[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT channel_id, owner_id, created_at FROM temp_channels WHERE guild_id = %s', + (guild_id,)) + return await cur.fetchall() + + async def update_channel_activity(self, channel_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'UPDATE temp_channels SET last_activity = NOW() WHERE channel_id = %s', + (channel_id,)) + await conn.commit() + + async def get_channels_to_delete(self, guild_id: int, minutes_inactive: int) -> List[int]: + if minutes_inactive <= 0: + return [] + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + SELECT channel_id FROM temp_channels + WHERE guild_id = %s + AND DATE_ADD(last_activity, INTERVAL %s MINUTE) < NOW() + ''', (guild_id, minutes_inactive)) + return [r[0] for r in await cur.fetchall()] + + # --- UI Settings --- + + async def set_ui_settings(self, guild_id: int, ui_enabled: bool, ui_prefix: str = "🔧"): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO ui_settings (guild_id, ui_enabled, ui_prefix) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE ui_enabled = VALUES(ui_enabled), ui_prefix = VALUES(ui_prefix) + ''', (guild_id, ui_enabled, ui_prefix)) + await conn.commit() + + async def get_ui_settings(self, guild_id: int) -> Optional[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT ui_enabled, ui_prefix FROM ui_settings WHERE guild_id = %s', (guild_id,)) + return await cur.fetchone() + + async def remove_ui_settings(self, guild_id: int): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM ui_settings WHERE guild_id = %s', (guild_id,)) + await conn.commit() + + # --- Maintenance --- + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute('DELETE FROM temp_channels WHERE owner_id = %s', (user_id,)) + await conn.commit() + return True + except Exception: + return False diff --git a/mxmariadb/mxmariadb/warn_db.py b/mxmariadb/mxmariadb/warn_db.py new file mode 100644 index 0000000..db9ed2e --- /dev/null +++ b/mxmariadb/mxmariadb/warn_db.py @@ -0,0 +1,87 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of WarnDatabase +import aiomysql +import logging +from datetime import datetime, timedelta +from typing import List, Tuple +from mxmariadb.connector import MariaConnector +logger = logging.getLogger(__name__) + + +class WarnDatabase(MariaConnector): + """MariaDB-backed warn database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + + async def init_db(self): + """Create table.""" + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS warns ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + moderator_id BIGINT NOT NULL, + reason TEXT NOT NULL, + timestamp VARCHAR(50) NOT NULL, + INDEX idx_guild_user (guild_id, user_id) + ) + """) + await conn.commit() + logger.info("MariaDB warns tables initialized") + + async def add_warning(self, guild_id: int, user_id: int, + moderator_id: int, reason: str, + timestamp: str = None) -> int: + if timestamp is None: + timestamp = datetime.now().isoformat() + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO warns (guild_id, user_id, moderator_id, reason, timestamp) " + "VALUES (%s, %s, %s, %s, %s)", + (guild_id, user_id, moderator_id, reason, timestamp)) + last_id = cur.lastrowid + await conn.commit() + return last_id + + async def get_warnings(self, guild_id: int, user_id: int) -> List[Tuple]: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT id, reason, timestamp, moderator_id FROM warns " + "WHERE guild_id = %s AND user_id = %s ORDER BY id DESC", + (guild_id, user_id)) + return await cur.fetchall() + + # --- Privacy & Maintenance --- + + async def delete_user_data(self, user_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM warns WHERE user_id = %s", (user_id,)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Hard Delete Error: {e}") + return False + + async def cleanup_old_data(self, days: int = 180) -> int: + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM warns WHERE timestamp < %s", (cutoff,)) + count = cur.rowcount + await conn.commit() + if count > 0: + logger.info(f"[Cleanup] {count} warnings older than {days} days deleted") + return count + except Exception as e: + logger.error(f"Cleanup Error: {e}") + return 0 diff --git a/mxmariadb/mxmariadb/welcome_db.py b/mxmariadb/mxmariadb/welcome_db.py new file mode 100644 index 0000000..85970c2 --- /dev/null +++ b/mxmariadb/mxmariadb/welcome_db.py @@ -0,0 +1,192 @@ +# Copyright (c) 2025 OPPRO.NET Network +# MariaDB version of WelcomeDatabase +import aiomysql +import logging +from typing import Optional, Dict, Any, List +from datetime import datetime +from mxmariadb.connector import MariaConnector +logger = logging.getLogger(__name__) + + +class WelcomeDatabase(MariaConnector): + """MariaDB-backed welcome database. Same API as the aiosqlite version.""" + + def __init__(self): + super().__init__() + self.migration_done = False + + async def init_db(self): + """Create tables and run migrations.""" + await self._create_tables() + self.migration_done = True + + async def _create_tables(self): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + CREATE TABLE IF NOT EXISTS welcome_settings ( + guild_id BIGINT PRIMARY KEY, + channel_id BIGINT, + welcome_message TEXT, + enabled TINYINT(1) DEFAULT 1, + embed_enabled TINYINT(1) DEFAULT 0, + embed_color VARCHAR(10) DEFAULT '#00ff00', + embed_title VARCHAR(255), + embed_description TEXT, + embed_thumbnail TINYINT(1) DEFAULT 0, + embed_footer VARCHAR(255), + ping_user TINYINT(1) DEFAULT 0, + delete_after INT DEFAULT 0, + auto_role_id BIGINT, + join_dm_enabled TINYINT(1) DEFAULT 0, + join_dm_message TEXT, + template_name VARCHAR(100), + welcome_stats_enabled TINYINT(1) DEFAULT 0, + rate_limit_enabled TINYINT(1) DEFAULT 1, + rate_limit_seconds INT DEFAULT 60, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP + ) + ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS welcome_stats ( + guild_id BIGINT, + date VARCHAR(20), + joins INT DEFAULT 0, + leaves_ INT DEFAULT 0, + PRIMARY KEY (guild_id, date) + ) + ''') + await conn.commit() + logger.info("MariaDB welcome tables initialized") + + # --- Backwards‑compat helpers --- + + async def set_welcome_channel(self, guild_id: int, channel_id: int) -> bool: + return await self.update_welcome_settings(guild_id, channel_id=channel_id) + + async def set_welcome_message(self, guild_id: int, message: str) -> bool: + return await self.update_welcome_settings(guild_id, welcome_message=message) + + # --- Core CRUD --- + + async def update_welcome_settings(self, guild_id: int, **kwargs) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'SELECT guild_id FROM welcome_settings WHERE guild_id = %s', + (guild_id,)) + if not await cur.fetchone(): + await cur.execute( + 'INSERT INTO welcome_settings (guild_id) VALUES (%s)', + (guild_id,)) + + valid_fields = [ + 'channel_id', 'welcome_message', 'enabled', + 'embed_enabled', 'embed_color', 'embed_title', + 'embed_description', 'embed_thumbnail', 'embed_footer', + 'ping_user', 'delete_after', 'auto_role_id', + 'join_dm_enabled', 'join_dm_message', 'template_name', + 'welcome_stats_enabled', 'rate_limit_enabled', + 'rate_limit_seconds', + ] + + updates = [] + values = [] + for key, value in kwargs.items(): + if key in valid_fields: + updates.append(f"{key} = %s") + values.append(value) + + if updates: + query = ( + f"UPDATE welcome_settings SET {', '.join(updates)} " + f"WHERE guild_id = %s" + ) + values.append(guild_id) + await cur.execute(query, values) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error updating welcome settings: {e}") + return False + + async def get_welcome_settings(self, guild_id: int) -> Optional[Dict[str, Any]]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + 'SELECT * FROM welcome_settings WHERE guild_id = %s', + (guild_id,)) + result = await cur.fetchone() + return dict(result) if result else None + except Exception as e: + logger.error(f"Error getting welcome settings: {e}") + return None + + async def delete_welcome_settings(self, guild_id: int) -> bool: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + 'DELETE FROM welcome_settings WHERE guild_id = %s', + (guild_id,)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error deleting welcome settings: {e}") + return False + + async def toggle_welcome(self, guild_id: int) -> Optional[bool]: + try: + settings = await self.get_welcome_settings(guild_id) + if not settings: + return None + new_state = not settings.get('enabled', True) + await self.update_welcome_settings(guild_id, enabled=new_state) + return new_state + except Exception as e: + logger.error(f"Toggle error: {e}") + return None + + # --- Stats --- + + async def update_welcome_stats(self, guild_id: int, + joins: int = 0, leaves: int = 0): + try: + date = datetime.now().strftime('%Y-%m-%d') + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO welcome_stats (guild_id, date, joins, leaves_) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + joins = joins + VALUES(joins), + leaves_ = leaves_ + VALUES(leaves_) + ''', (guild_id, date, joins, leaves)) + await conn.commit() + except Exception as e: + logger.error(f"Stats update error: {e}") + + async def get_weekly_stats(self, guild_id: int) -> List[Dict]: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT date, joins, leaves_ AS leaves + FROM welcome_stats + WHERE guild_id = %s + AND date > DATE_SUB(CURDATE(), INTERVAL 7 DAY) + ORDER BY date ASC + ''', (guild_id,)) + return [dict(r) for r in await cur.fetchall()] + except Exception as e: + logger.error(f"Get weekly stats error: {e}") + return [] + + # --- Migration placeholder (no-op, tables are correct from start) --- + + async def migrate_database(self): + pass diff --git a/src/bot/cogs/bot/about.py b/src/bot/cogs/cogs/bot/about.py similarity index 89% rename from src/bot/cogs/bot/about.py rename to src/bot/cogs/cogs/bot/about.py index 4c9e617..758f20f 100644 --- a/src/bot/cogs/bot/about.py +++ b/src/bot/cogs/cogs/bot/about.py @@ -33,9 +33,8 @@ async def about(self, ctx: discord.ApplicationContext): server_count = len(self.bot.guilds) member_count = sum(g.member_count for g in self.bot.guilds) - from src.bot.core.config import BotConfig # Create Container - container = discord.ui.Container(color=discord.Color.from_rgb(*BotConfig.ui.colors.info)) + container = discord.ui.Container(color=discord.Color.red()) # Header title = await TranslationHandler.get_for_user(self.bot, ctx.author.id, "cog_about.messages.title") @@ -114,16 +113,12 @@ async def about(self, ctx: discord.ApplicationContext): # Footer footer = await TranslationHandler.get_for_user(self.bot, ctx.author.id, "cog_about.messages.footer", year=datetime.now().year, - version=BotConfig.bot.version + version="2.0.0" ) container.add_text(footer) - # Buttons + # Send View view = discord.ui.DesignerView(container, timeout=0) - view.add_item(discord.ui.Button(label="Website", url=BotConfig.links.website, style=discord.ButtonStyle.link)) - view.add_item(discord.ui.Button(label="Invite", url=BotConfig.links.invite, style=discord.ButtonStyle.link)) - view.add_item(discord.ui.Button(label="Support", url=BotConfig.links.support, style=discord.ButtonStyle.link)) - await ctx.respond(view=view) def setup(bot): diff --git a/src/bot/cogs/bot/admin.py b/src/bot/cogs/cogs/bot/admin.py similarity index 99% rename from src/bot/cogs/bot/admin.py rename to src/bot/cogs/cogs/bot/admin.py index 49bd716..09b36cc 100644 --- a/src/bot/cogs/bot/admin.py +++ b/src/bot/cogs/cogs/bot/admin.py @@ -2,7 +2,6 @@ from discord import SlashCommandGroup, Option import ezcord from discord.ui import Container, View, Button, Modal, InputText -from src.bot.core.config import BotConfig import sys import os import psutil @@ -16,10 +15,11 @@ from typing import Optional, List import time -# Configuration (Centralized) -ALLOWED_IDS = BotConfig.ALLOWED_IDS -AUDIT_LOG_FILE = BotConfig.AUDIT_LOG_FILE -BLACKLIST_FILE = BotConfig.BLACKLIST_FILE +ALLOWED_IDS = [1427994077332373554] + +# Audit Log Storage +AUDIT_LOG_FILE = Path("data/admin_audit.json") +BLACKLIST_FILE = Path("data/blacklist.json") @@ -257,8 +257,8 @@ class admin(ezcord.Cog, hidden=True): def __init__(self, bot): self.bot = bot self.start_time = datetime.now() - self.cogs_path = BotConfig.COGS_PATH - self.data_path = BotConfig.DATA_PATH + self.cogs_path = Path("src/bot/cogs") + self.data_path = Path("data") self.data_path.mkdir(exist_ok=True) # Lade Blacklist diff --git a/src/bot/cogs/bot/botjoinevent.py b/src/bot/cogs/cogs/bot/botjoinevent.py similarity index 88% rename from src/bot/cogs/bot/botjoinevent.py rename to src/bot/cogs/cogs/bot/botjoinevent.py index b714651..2040be4 100644 --- a/src/bot/cogs/bot/botjoinevent.py +++ b/src/bot/cogs/cogs/bot/botjoinevent.py @@ -1,7 +1,6 @@ import discord from discord.ui import Container # Dein ezcord/discord.ui Import import ezcord -from src.bot.core.config import BotConfig class BotJoinEvents(ezcord.Cog): def __init__(self, bot): @@ -34,7 +33,7 @@ async def on_guild_join(self, guild): container.add_separator() container.add_text("### 🔗 Links") - container.add_text(f"🔗 [Website]({BotConfig.links.website}) × [Top.gg]({BotConfig.links.topgg}) × [Support]({BotConfig.links.support}) × [GitHub]({BotConfig.links.github})") + container.add_text("🔗 [Website](https://managerx-bot.de) × [Top.gg](https://top.gg/bot/1368201272624287754) × [Support](https://discord.gg/9T28DWup3g) × [GitHub](https://github.com/ManagerX-Development/ManagerX)") # Die DesignerView für den Container erstellen view = discord.ui.DesignerView(container, timeout=0) diff --git a/src/bot/cogs/bot/server_join_alert.py b/src/bot/cogs/cogs/bot/server_join_alert.py similarity index 93% rename from src/bot/cogs/bot/server_join_alert.py rename to src/bot/cogs/cogs/bot/server_join_alert.py index d771fe9..088d266 100644 --- a/src/bot/cogs/bot/server_join_alert.py +++ b/src/bot/cogs/cogs/bot/server_join_alert.py @@ -1,7 +1,6 @@ import discord from discord.ui import Container, DesignerView import ezcord -from src.bot.core.config import BotConfig class JoinAlert(ezcord.Cog): def __init__(self, bot): @@ -29,7 +28,7 @@ async def on_guild_join(self, guild: discord.Guild): ) # Die Channel-ID von dir - log_channel_id = BotConfig.JOIN_LOG_CHANNEL + log_channel_id = 1429163147687886889 log_channel = self.bot.get_channel(log_channel_id) if log_channel: diff --git a/src/bot/cogs/bot/server_leave_alert.py b/src/bot/cogs/cogs/bot/server_leave_alert.py similarity index 94% rename from src/bot/cogs/bot/server_leave_alert.py rename to src/bot/cogs/cogs/bot/server_leave_alert.py index ed800b6..b26a546 100644 --- a/src/bot/cogs/bot/server_leave_alert.py +++ b/src/bot/cogs/cogs/bot/server_leave_alert.py @@ -1,13 +1,12 @@ import discord from discord.ui import Container, DesignerView import ezcord -from src.bot.core.config import BotConfig class LeaveAlert(ezcord.Cog): def __init__(self, bot): self.bot = bot # Dein Log-Kanal für Abgänge - self.log_channel_id = BotConfig.LEAVE_LOG_CHANNEL + self.log_channel_id = 1429164270435700849 @discord.Cog.listener() async def on_guild_remove(self, guild: discord.Guild): diff --git a/src/bot/cogs/cogs/bot/status.py b/src/bot/cogs/cogs/bot/status.py new file mode 100644 index 0000000..cd2601f --- /dev/null +++ b/src/bot/cogs/cogs/bot/status.py @@ -0,0 +1,47 @@ +import discord +from discord.ext import commands, tasks +import aiohttp +import logging + +class StatusCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + # Deine Uptime Kuma Push URL + self.push_url = "https://status.oppro-network.de/api/push/f9jAKPSCUqWUBTK91oFKOsRE60MwwOo9" + self.status_heartbeat.start() + + def cog_unload(self): + self.status_heartbeat.cancel() + + @tasks.loop(seconds=30) + async def status_heartbeat(self): + """Sendet alle 60 Sekunden den Status an Uptime Kuma.""" + # Warte, bis der Bot bereit ist, um Fehlberechnungen beim Ping zu vermeiden + await self.bot.wait_until_ready() + + # Berechne den aktuellen Discord-Ping in Millisekunden + current_ping = round(self.bot.latency * 1000) + + # Parameter für die API + params = { + "status": "up", + "msg": "ManagerXBot: System aktiv", + "ping": current_ping + } + + try: + async with aiohttp.ClientSession() as session: + async with session.get(self.push_url, params=params, timeout=10) as response: + if response.status == 200: + logging.info(f"[Status] Push erfolgreich (Ping: {current_ping}ms)") + else: + logging.error(f"[Status] Fehler beim Push! HTTP {response.status}") + except Exception as e: + logging.error(f"[Status] Verbindung zur Statusseite fehlgeschlagen: {e}") + + @status_heartbeat.before_loop + async def before_status_heartbeat(self): + await self.bot.wait_until_ready() + +def setup(bot): + bot.add_cog(StatusCog(bot)) \ No newline at end of file diff --git a/src/bot/cogs/economy/gb_economy.py b/src/bot/cogs/cogs/economy/gb_economy.py similarity index 99% rename from src/bot/cogs/economy/gb_economy.py rename to src/bot/cogs/cogs/economy/gb_economy.py index f3f1871..d30b11c 100644 --- a/src/bot/cogs/economy/gb_economy.py +++ b/src/bot/cogs/cogs/economy/gb_economy.py @@ -3,7 +3,7 @@ from discord.ext import commands from discord import SlashCommandGroup, Option import ezcord -from mx_devtools import EconomyDatabase +from mxmariadb import EconomyDatabase from datetime import datetime, timedelta import random diff --git a/src/bot/cogs/economy/gld_economy.py b/src/bot/cogs/cogs/economy/gld_economy.py similarity index 98% rename from src/bot/cogs/economy/gld_economy.py rename to src/bot/cogs/cogs/economy/gld_economy.py index a18e87c..59d6b77 100644 --- a/src/bot/cogs/economy/gld_economy.py +++ b/src/bot/cogs/cogs/economy/gld_economy.py @@ -3,7 +3,7 @@ from discord.ext import commands from discord import slash_command, Option import ezcord -from mx_devtools import EconomyDatabase +from mxmariadb import EconomyDatabase class GuildEconomy(ezcord.Cog): def __init__(self, bot): diff --git a/src/bot/cogs/fun/4gewinnt.py b/src/bot/cogs/cogs/fun/4gewinnt.py similarity index 100% rename from src/bot/cogs/fun/4gewinnt.py rename to src/bot/cogs/cogs/fun/4gewinnt.py diff --git a/src/bot/cogs/fun/tictactoe.py b/src/bot/cogs/cogs/fun/tictactoe.py similarity index 100% rename from src/bot/cogs/fun/tictactoe.py rename to src/bot/cogs/cogs/fun/tictactoe.py diff --git a/src/bot/cogs/guild/globalchat.py b/src/bot/cogs/cogs/guild/globalchat.py similarity index 60% rename from src/bot/cogs/guild/globalchat.py rename to src/bot/cogs/cogs/guild/globalchat.py index 6118e51..dd6c046 100644 --- a/src/bot/cogs/guild/globalchat.py +++ b/src/bot/cogs/cogs/guild/globalchat.py @@ -2,7 +2,7 @@ import discord from discord.ext import commands, tasks from discord import slash_command, Option, SlashCommandGroup -from mx_devtools.backend.database.globalchat_db import GlobalChatDatabase, db +from mxmariadb import GlobalChatDatabase import asyncio import logging import re @@ -16,87 +16,61 @@ import ezcord from collections import defaultdict from discord.ui import Container -from src.bot.core.config import BotConfig - -# Logger konfigurieren +db = GlobalChatDatabase() logger = logging.getLogger(__name__) class GlobalChatConfig: - """Zentrale Konfiguration für GlobalChat""" - # Bot Owner IDs - BOT_OWNERS = BotConfig.BOT_OWNERS - - # Rate Limits - RATE_LIMIT_MESSAGES = BotConfig.GC_RATE_LIMIT_MSGS - RATE_LIMIT_SECONDS = BotConfig.GC_RATE_LIMIT_SECS - CACHE_DURATION = BotConfig.GC_CACHE_DURATION - CLEANUP_DAYS = BotConfig.GC_CLEANUP_DAYS - DEFAULT_MAX_MESSAGE_LENGTH = BotConfig.GC_MAX_MSG_LEN - DEFAULT_EMBED_COLOR = BotConfig.GC_DEFAULT_COLOR - MAX_FILE_SIZE_MB = BotConfig.GC_MAX_FILE_SIZE - - MIN_MESSAGE_LENGTH = 0 # Erlaube Nachrichten ohne Text (nur Medien) + RATE_LIMIT_MESSAGES = 15 + RATE_LIMIT_SECONDS = 60 + CACHE_DURATION = 180 + CLEANUP_DAYS = 30 + MIN_MESSAGE_LENGTH = 0 + DEFAULT_MAX_MESSAGE_LENGTH = 1900 + DEFAULT_EMBED_COLOR = '#5865F2' + MAX_FILE_SIZE_MB = 25 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'] - - # Content Filter Patterns + BOT_OWNERS = [1093555256689959005, 1427994077332373554] 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 = BotConfig.GC_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 + 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: @@ -105,25 +79,13 @@ def categorize_attachment(self, attachment: discord.Attachment) -> str: return 'audio' elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: return 'document' - else: - return 'other' - + 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, '📎') - + icons = {'image': '🖼️', 'video': '🎥', 'audio': '🎵', 'document': '📄', 'other': '📎'} + return icons.get(self.categorize_attachment(attachment), '📎') + 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}" @@ -132,193 +94,121 @@ def format_file_size(self, size_bytes: int) -> str: 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 + + # ✅ Umgewandelt zu async – is_blacklisted ist async in der DB + async def validate_message(self, message: discord.Message, settings: Dict) -> Tuple[bool, str]: if message.author.bot: return False, "Bot-Nachricht" - - # Blacklist prüfen - if db.is_blacklisted('user', message.author.id): + + if await db.is_blacklisted('user', message.author.id): return False, "User auf Blacklist" - - if db.is_blacklisted('guild', message.guild.id): + if await 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): + if re.search(r'\b' + re.escape(keyword) + r'\b', 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 = content.replace('@everyone', '@everyone').replace('@here', '@here') 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 - + self.bot = bot + 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 mit stilisiertem Layout + if content: description = f"{content}" elif message.attachments or message.stickers or attachment_data: description = "📎 *Medien-Nachricht*" else: description = "" - - # Embed erstellen - embed = discord.Embed( - description=description, - color=embed_color, - timestamp=message.created_at - ) - - # Author mit Badges und Username-Tag + + embed = discord.Embed(description=description, color=embed_color, timestamp=message.created_at) author_text, badges = self._build_author_info(message.author) - - # --- ECONOMY OVERRIDES --- + from mx_devtools import EconomyDatabase eco_db = EconomyDatabase() overrides = eco_db.get_equipped_overrides(message.author.id) - - # Color Override if 'color' in overrides: embed_color = self._parse_color(overrides['color']) embed.color = embed_color - - # Emoji Override if 'emoji' in overrides: - emoji = overrides['emoji'] - # Add emoji to the author name - author_text = f"{emoji} {author_text}" - # ------------------------- - - embed.set_author( - name=author_text, - icon_url=message.author.display_avatar.url - ) - - # Thumbnail mit User-Avatar für bessere Optik + author_text = f"{overrides['emoji']} {author_text}" + + embed.set_author(name=author_text, icon_url=message.author.display_avatar.url) embed.set_thumbnail(url=message.author.display_avatar.url) - - # Footer mit Server-Info und schönerem Layout 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) + embed.set_footer(text=footer_text, icon_url=message.guild.icon.url if message.guild.icon else None) + 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) @@ -326,26 +216,19 @@ async def create_message_embed(self, message: discord.Message, settings: Dict, a 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)" @@ -357,7 +240,6 @@ async def create_message_embed(self, message: discord.Message, settings: Dict, a 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: @@ -373,7 +255,6 @@ async def create_message_embed(self, message: discord.Message, settings: Dict, a except Exception: author_display = "Unbekannter User" - # Herkunft (Server • #channel) origin = None try: if getattr(replied_msg, 'guild', None) and getattr(replied_msg, 'channel', None): @@ -384,211 +265,112 @@ async def create_message_embed(self, message: discord.Message, settings: Dict, a 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 + files_to_upload = await self._process_media(embed, message, attachment_data) 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 = [] - + 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 + elif category == 'video': 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 + others.append((filename, data)) - # === 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 - ) + 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})") + video_links.append(f"🎥 {video_name} ({self.media_handler.format_file_size(len(video_data))})") 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 - ) + embed.add_field(name="🎬 Videos", value="\n".join(video_links[: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})") + audio_links.append(f"🎵 {audio_name} ({self.media_handler.format_file_size(len(audio_data))})") attachment_bytes.append((audio_name, audio_data)) + embed.add_field(name="🎧 Audio-Dateien", value="\n".join(audio_links[:3]), inline=False) - 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})") + doc_links.append(f"📄 {doc_name} ({self.media_handler.format_file_size(len(doc_data))})") 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 === + embed.add_field(name="📄 Dokumente", value="\n".join(doc_links[:3]), inline=False) + 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})") + other_links.append(f"📎 {other_name} ({self.media_handler.format_file_size(len(other_data))})") 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 - + embed.add_field(name="📎 Sonstige", value="\n".join(other_links[:3]), inline=False) + + return attachment_bytes + 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 + embed.add_field(name="🎨 Sticker", value="\n".join(sticker_info[:3]), inline=False) 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 - ) + 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' @@ -598,65 +380,44 @@ def _get_attachment_category(self, filename: str, content_type: str) -> str: return 'audio' elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: return 'document' - else: - return 'other' + 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 = content.replace('@everyone', '@everyone').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)) + return discord.Color(int(color_hex.lstrip('#'), 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 – schöneres Format""" - badges = [] - roles = [] - # Bot Owner + badges, roles = [], [] 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") - - # Nitro/Booster Badge if hasattr(author, 'premium_since') and author.premium_since: badges.append("💎") roles.append("Booster") - badge_text = " ".join(badges) display = author.display_name - - # Format: "👑 ⚡ DisplayName (@username)" - if badge_text: - author_text = f"{badge_text} {display} (@{author.name})" - else: - author_text = f"{display} (@{author.name})" - - # Hinzufügen von Discord System Badges (z.B. Bot, Verified Bot) + author_text = f"{badge_text} {display} (@{author.name})" if badge_text else f"{display} (@{author.name})" 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): self.bot = bot self.config = config @@ -664,25 +425,19 @@ def __init__(self, bot, config: GlobalChatConfig, embed_builder: EmbedBuilder): self._cached_channels: Optional[List[int]] = None async def _get_all_active_channels(self) -> List[int]: - """Ruft alle aktiven Channel-IDs ab, nutzt den Cache""" if self._cached_channels is None: 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 [] + try: + # ✅ await hinzugefügt + return await db.get_all_channels() + 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: @@ -691,16 +446,13 @@ async def _send_to_channel(self, channel_id: int, embed: discord.Embed, attachme except Exception: logger.warning(f"⚠️ Channel {channel_id} konnte nicht abgerufen werden.") return False - - # Permissions prüfen + if hasattr(channel, 'guild') and channel.guild: 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} ({channel.guild.name})") 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: @@ -709,7 +461,6 @@ async def _send_to_channel(self, channel_id: int, embed: discord.Embed, attachme 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: @@ -722,38 +473,29 @@ async def _send_to_channel(self, channel_id: int, embed: discord.Embed, attachme 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: + logger.warning(f"❌ Bot hat Senderechte in {channel_id} verloren.") + if self._cached_channels and 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) - + # ✅ await hinzugefügt + settings = await 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 + successful_sends, failed_sends = 0, 0 - # Sende an ALLE aktiven Channels (inkl. Ursprungskanal – Original wird gelöscht) - tasks = [] - for channel_id in active_channels: - tasks.append(self._send_to_channel(channel_id, embed, files_to_upload)) - - results = await asyncio.gather(*tasks, return_exceptions=True) + task_list = [self._send_to_channel(channel_id, embed, files_to_upload) for channel_id in active_channels] + results = await asyncio.gather(*task_list, return_exceptions=True) for result in results: if result is True: @@ -762,35 +504,24 @@ async def send_global_message(self, message: discord.Message, attachment_data: L failed_sends += 1 if isinstance(result, Exception): logger.error(f"❌ Task-Fehler beim Senden: {result}") - + return successful_sends, failed_sends async def send_global_broadcast_message(self, embed: discord.Embed) -> Tuple[int, int]: - """Sendet einen Broadcast an alle Kanäle""" active_channels = await self._get_all_active_channels() - successful_sends = 0 - failed_sends = 0 - - tasks = [] - for channel_id in active_channels: - tasks.append(self._send_to_channel(channel_id, embed, [])) - - results = await asyncio.gather(*tasks, return_exceptions=True) - + successful_sends, failed_sends = 0, 0 + task_list = [self._send_to_channel(channel_id, embed, []) for channel_id in active_channels] + results = await asyncio.gather(*task_list, return_exceptions=True) for result in results: if result is True: successful_sends += 1 else: failed_sends += 1 - return successful_sends, failed_sends class GlobalChat(ezcord.Cog): - """Haupt-Cog für das GlobalChat-System""" - globalchat = SlashCommandGroup("globalchat", "GlobalChat Verwaltung") - def __init__(self, bot): self.bot = bot @@ -798,51 +529,42 @@ def __init__(self, bot): 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, + self.config.RATE_LIMIT_MESSAGES, + self.config.RATE_LIMIT_SECONDS, commands.BucketType.user ) - self._cached_channels = None # Wird bei der ersten Nachricht geladen + self._cached_channels = None self.sender = GlobalChatSender(self.bot, self.config, self.embed_builder) - self.cleanup_task.change_interval(hours=BotConfig.intervals.global_chat_cleanup_12) self.cleanup_task.start() + self.bot.loop.create_task(GlobalChatDatabase().create_tables()) @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 - await self.sender._get_all_active_channels() - logger.info("🧠 GlobalChat: Channel-Cache neu geladen.") + await self.sender._get_all_active_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) + # ✅ await war bereits vorhanden + global_chat_channel_id = await 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) + # ✅ await hinzugefügt + settings = await db.get_guild_settings(message.guild.id) - # Message validieren - is_valid, reason = self.validator.validate_message(message, settings) + # ✅ validate_message ist jetzt async + is_valid, reason = await 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( + 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}", @@ -851,35 +573,28 @@ async def on_message(self, message: discord.Message): await asyncio.sleep(2) await message.delete() except (discord.Forbidden, discord.NotFound): - pass # Kann Nachricht nicht löschen/reagieren - return + pass + return - # --- ECONOMY: Award Coins --- from mx_devtools import EconomyDatabase eco_db = EconomyDatabase() - # Award 5-15 coins per message with a simple cooldown check user_info = eco_db.get_user_economy_info(message.author.id) last_msg_raw = user_info.get('last_message_at') can_earn = True if last_msg_raw: try: - # Handle common SQLite formats try: last_dt = datetime.strptime(last_msg_raw, "%Y-%m-%d %H:%M:%S") except ValueError: last_dt = datetime.fromisoformat(last_msg_raw) - if datetime.utcnow() < last_dt + timedelta(seconds=30): can_earn = False - except Exception: pass - + except Exception: + pass if can_earn: - amount = random.randint(5, 15) - eco_db.add_global_coins(message.author.id, amount) + eco_db.add_global_coins(message.author.id, random.randint(5, 15)) eco_db.update_last_message(message.author.id) - # ---------------------------- - # Rate Limiting prüfen bucket = self.message_cooldown.get_bucket(message) retry_after = bucket.update_rate_limit() if retry_after: @@ -892,22 +607,18 @@ async def on_message(self, message: discord.Message): 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 = [] - # Ursprüngliche Nachricht IMMER löschen (wird als Embed neu gepostet) try: await message.delete() except discord.Forbidden: @@ -915,63 +626,51 @@ async def on_message(self, message: discord.Message): except discord.NotFound: pass - # Nachricht als Embed an alle Channels senden (inkl. eigenen) successful, failed = await self.sender.send_global_message(message, attachment_data) - 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" - ) + @globalchat.command(name="setup", description="Richtet einen GlobalChat-Channel ein") async def setup_globalchat( - self, - ctx: discord.ApplicationContext, + 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 not bot_perms.attach_files: missing_perms.append("Dateien anhängen") 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}", + f"❌ Mir fehlen wichtige Berechtigungen in {channel.mention}:\n" + + "\n".join([f"• {p}" for p in missing_perms]), ephemeral=True ) return try: - db.set_globalchat_channel(ctx.guild.id, channel.id) - - # Cache aktualisieren + # ✅ await hinzugefügt + await db.set_globalchat_channel(ctx.guild.id, channel.id) self.sender._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.sender._cached_channels)}** Server." - + status_text = ( + f"✅ **GlobalChat eingerichtet!**\n\n" + f"Der GlobalChat ist nun in {channel.mention} aktiv.\n" + f"Aktuell verbunden: **{len(self.sender._cached_channels)}** Server." + ) container.add_text(status_text) container.add_separator() - - # Feature-Liste - feature_text = ( + container.add_text( "**Unterstützte Features:**\n" "• 🖼️ Bilder, 🎥 Videos, 🎵 Audio\n" "• 📄 Dokumente (Office, PDF, Archive)\n" @@ -983,37 +682,27 @@ async def setup_globalchat( "• `/globalchat stats` - Statistiken anzeigen\n" "• `/globalchat media-info` - Medien-Limits anzeigen" ) - container.add_text(feature_text) - view = discord.ui.DesignerView(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" - ) + @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) + channel_id = await 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 + # ✅ await hinzugefügt + await db.set_globalchat_channel(ctx.guild.id, None) self.sender._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" @@ -1024,65 +713,48 @@ async def remove_globalchat(self, ctx: discord.ApplicationContext): 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" - ) + @globalchat.command(name="settings", description="Verwaltet Server-spezifische GlobalChat-Einstellungen") async def settings_globalchat( - self, + self, ctx: discord.ApplicationContext, - filter_enabled: Optional[bool] = Option(bool, "Content-Filter aktivieren/deaktivieren (Invites, etc.)", required=False), + filter_enabled: Optional[bool] = Option(bool, "Content-Filter aktivieren/deaktivieren", 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 - ) + 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 - ) + if not await db.get_globalchat_channel(ctx.guild.id): + await ctx.respond("❌ Dieser Server nutzt GlobalChat nicht!\nNutze `/globalchat setup` zuerst.", ephemeral=True) return updated = [] - # Filter aktivieren/deaktivieren + # ✅ await hinzugefügt für alle update_guild_setting Aufrufe if filter_enabled is not None: - if db.update_guild_setting(ctx.guild.id, 'filter_enabled', filter_enabled): + if await 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): + if await 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): + if await 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): + if await 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), @@ -1090,20 +762,15 @@ async def settings_globalchat( ) await ctx.respond(embed=embed, ephemeral=True) - - @globalchat.command( - name="ban", - description="🔨 Bannt einen User oder Server vom GlobalChat" - ) + @globalchat.command(name="ban", description="🔨 Bannt einen User oder Server vom GlobalChat") async def globalchat_ban( - self, + 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 @@ -1114,61 +781,36 @@ async def globalchat_ban( 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 - ) + # ✅ await hinzugefügt + success = await 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 = 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 - ) - + 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}" - ) - + 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" - ) + @globalchat.command(name="unban", description="🔓 Entfernt einen User oder Server von der GlobalChat-Blacklist") async def globalchat_unban( - self, + 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 @@ -1178,13 +820,14 @@ async def globalchat_unban( 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 hinzugefügt + if not await 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): + if await 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.", @@ -1195,20 +838,16 @@ async def globalchat_unban( 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" - ) + @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() - + # ✅ await hinzugefügt, einmal laden statt 3x + guild_settings = await db.get_guild_settings(ctx.guild.id) + embed = discord.Embed( title="🌍 GlobalChat - Vollständiger Medien-Support", description=( @@ -1226,183 +865,99 @@ async def globalchat_info(self, ctx: discord.ApplicationContext): 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)" - ), + 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" + f"• **Content-Filter:** {'✅ An' if guild_settings.get('filter_enabled', True) else '❌ Aus'}\n" + f"• **NSFW-Filter:** {'✅ An' if guild_settings.get('nsfw_filter', True) else '❌ Aus'}\n" + f"• **Nachrichtenlänge:** {guild_settings.get('max_message_length', self.config.DEFAULT_MAX_MESSAGE_LENGTH)} Zeichen" ), inline=True ) - await ctx.respond(embed=embed, ephemeral=True) - @globalchat.command( - name="stats", - description="Zeigt GlobalChat-Statistiken" - ) + @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() + # ✅ await hinzugefügt + user_bans, guild_bans = await 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 = 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 - ) - + 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" - ) + @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.MAX_FILE_SIZE_MB} MB** pro Datei\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 - ) - + 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" - ) + @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" - ), + 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" - ), + 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" - ), + 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" - ), + 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" - ) + @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) + channel_id = await db.get_globalchat_channel(ctx.guild.id) if not channel_id: await ctx.respond("❌ GlobalChat ist auf diesem Server nicht eingerichtet.", ephemeral=True) return @@ -1411,112 +966,66 @@ async def globalchat_test_media(self, ctx: discord.ApplicationContext): 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" + "**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)" + "Probiere verschiedene Kombinationen aus!" ), 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" - ), + value=f"• Max. {self.config.MAX_ATTACHMENTS} Anhänge\n• Max. {self.config.MAX_FILE_SIZE_MB} MB pro Datei\n• {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.add_field(name="✅ Unterstützte Formate", value="Bilder, Videos, Audio,\nDokumente, Archive,\nOffice-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" - ) + @globalchat.command(name="broadcast", description="📢 Sendet eine Nachricht an alle verbundenen GlobalChat-Server") async def globalchat_broadcast( - self, + 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) + 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 + embed.set_footer(text=f"GlobalChat Broadcast von {ctx.author}", icon_url=ctx.author.display_avatar.url) + successful, failed = await self.sender.send_global_broadcast_message(embed) - # Response - result_embed = discord.Embed( - title="✅ Broadcast gesendet", - color=discord.Color.green(), - timestamp=datetime.utcnow() - ) + 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}" - ), + value=f"**Erfolgreich:** {successful}\n**Fehlgeschlagen:** {failed}\n**Gesamt:** {successful + failed}", inline=False ) result_embed.add_field( - name="📝 Nachricht", - value=f"**{title}**\n{message[:100]}{'...' if len(message) > 100 else ''}", + 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}" - ) + logger.info(f"📢 Broadcast: '{title}' | Von: {ctx.author} | ✅ {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)" - ) + @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 @@ -1526,26 +1035,17 @@ async def globalchat_reload_cache(self, ctx: discord.ApplicationContext): old_count = len(self.sender._cached_channels or []) self.sender._cached_channels = await self.sender._fetch_all_channels() new_count = len(self.sender._cached_channels) - await ctx.respond( - f"✅ **Cache neu geladen!**\n\n" - f"Alte Channel-Anzahl: **{old_count}**\n" - f"Neue Channel-Anzahl: **{new_count}**", + f"✅ **Cache neu geladen!**\n\nAlte Channel-Anzahl: **{old_count}**\nNeue 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)" - ) + @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 @@ -1553,8 +1053,10 @@ async def globalchat_debug(self, ctx: discord.ApplicationContext): await ctx.defer(ephemeral=True) try: cached_channels = len(self.sender._cached_channels or []) - all_settings = db.get_all_guild_settings() - + # ✅ await hinzugefügt + all_settings = await db.get_all_guild_settings() + user_bans, guild_bans = await db.get_blacklist_stats() + debug_info = ( f"**Bot-Status:**\n" f"• Latency: `{round(self.bot.latency * 1000)}ms`\n" @@ -1564,11 +1066,6 @@ async def globalchat_debug(self, ctx: discord.ApplicationContext): 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}`" ) @@ -1584,10 +1081,5 @@ async def globalchat_debug(self, ctx: discord.ApplicationContext): 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(GlobalChat(bot)) \ No newline at end of file diff --git a/src/bot/cogs/guild/levelsystem.py b/src/bot/cogs/cogs/guild/levelsystem.py similarity index 97% rename from src/bot/cogs/guild/levelsystem.py rename to src/bot/cogs/cogs/guild/levelsystem.py index 523522c..a0f9f7b 100644 --- a/src/bot/cogs/guild/levelsystem.py +++ b/src/bot/cogs/cogs/guild/levelsystem.py @@ -4,7 +4,7 @@ from discord.ext import commands, tasks import time import random -from mx_devtools import LevelDatabase +from mxmariadb import LevelDatabase import asyncio import io import csv @@ -30,14 +30,14 @@ async def confirm_prestige(self, interaction: discord.Interaction, button: disco 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=discord.Color.from_rgb(*self.config.ui.colors.prestige) + 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=discord.Color.from_rgb(*self.config.ui.colors.error) + color=0xff0000 ) await interaction.response.edit_message(embed=embed, view=None) @@ -51,7 +51,7 @@ async def cancel_prestige(self, interaction: discord.Interaction, button: discor embed = discord.Embed( title="❌ Prestige abgebrochen", description="Das Prestige wurde abgebrochen.", - color=discord.Color.from_rgb(*self.config.ui.colors.info) + color=0x999999 ) await interaction.response.edit_message(embed=embed, view=None) @@ -59,14 +59,10 @@ async def cancel_prestige(self, interaction: discord.Interaction, button: discor class LevelSystem(ezcord.Cog): def __init__(self, bot): self.bot = bot - from src.bot.core.config import BotConfig - self.config = BotConfig self.db = LevelDatabase() self.xp_cooldowns = {} # User-ID -> Timestamp # Starte Background Tasks - self.cleanup_expired_boosts.change_interval(hours=self.config.INT_LVL_CLEANUP) - self.cleanup_temporary_roles.change_interval(hours=self.config.INT_LVL_CLEANUP) self.cleanup_expired_boosts.start() self.cleanup_temporary_roles.start() @@ -80,7 +76,7 @@ def cog_unload(self): xpboost = SlashCommandGroup("xpboost", "Verwalte XP-Boosts") levelconfig = SlashCommandGroup("levelconfig", "Konfiguriere das Levelsystem") - @tasks.loop(hours=1) # Wird über config.py beim Start geladen, statische Definition bleibt + @tasks.loop(hours=1) async def cleanup_expired_boosts(self): """Entfernt abgelaufene XP-Boosts""" # Hier würde die DB-Cleanup Logik implementiert werden @@ -94,13 +90,13 @@ async def cleanup_temporary_roles(self): 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=discord.Color.from_rgb(*self.config.ui.colors.success)) + 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 = discord.Color.from_rgb(*self.config.ui.colors.warning) + embed.color = 0xffff00 embed.set_thumbnail(url=user.avatar.url if user.avatar else user.default_avatar.url) return embed @@ -128,8 +124,8 @@ async def on_message(self, message): current_time = time.time() # Guild-Konfiguration holen - guild_config = self.db.get_guild_config(guild_id) - cooldown = guild_config.get('cooldown', self.config.leveling.default_cooldown) + config = self.db.get_guild_config(guild_id) + cooldown = config.get('cooldown', 30) # XP-Cooldown prüfen if user_id in self.xp_cooldowns: @@ -140,8 +136,8 @@ async def on_message(self, message): channel_multiplier = self.db.get_channel_multiplier(guild_id, message.channel.id) # XP berechnen - min_xp = guild_config.get('min_xp', self.config.leveling.default_min_xp) - max_xp = guild_config.get('max_xp', self.config.leveling.default_max_xp) + 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) @@ -333,7 +329,7 @@ async def prestige(self, ctx): return user_stats = self.db.get_user_stats(ctx.author.id, ctx.guild.id) - min_level = config.get('prestige_min_level', self.config.leveling.prestige_min_level) + min_level = config.get('prestige_min_level', 50) if not user_stats or user_stats[1] < min_level: embed = discord.Embed( diff --git a/src/bot/cogs/guild/loggingsystem.py b/src/bot/cogs/cogs/guild/loggingsystem.py similarity index 99% rename from src/bot/cogs/guild/loggingsystem.py rename to src/bot/cogs/cogs/guild/loggingsystem.py index e7824a7..5cad6c0 100644 --- a/src/bot/cogs/guild/loggingsystem.py +++ b/src/bot/cogs/cogs/guild/loggingsystem.py @@ -10,7 +10,7 @@ import logging import ezcord # Import our separate database class -from mx_devtools import LoggingDatabase +from mxmariadb import LoggingDatabase # Setup logging logger = logging.getLogger(__name__) diff --git a/src/bot/cogs/guild/tempvc.py b/src/bot/cogs/cogs/guild/tempvc.py similarity index 99% rename from src/bot/cogs/guild/tempvc.py rename to src/bot/cogs/cogs/guild/tempvc.py index 9c42c13..0321b8f 100644 --- a/src/bot/cogs/guild/tempvc.py +++ b/src/bot/cogs/cogs/guild/tempvc.py @@ -1,5 +1,5 @@ # Copyright (c) 2025 OPPRO.NET Network -from mx_devtools import TempVCDatabase +from mxmariadb import TempVCDatabase import discord from discord import slash_command, option, SlashCommandGroup from discord.ext import commands diff --git a/src/bot/cogs/guild/utility.py b/src/bot/cogs/cogs/guild/utility.py similarity index 100% rename from src/bot/cogs/guild/utility.py rename to src/bot/cogs/cogs/guild/utility.py diff --git a/src/bot/cogs/guild/welcome.py b/src/bot/cogs/cogs/guild/welcome.py similarity index 99% rename from src/bot/cogs/guild/welcome.py rename to src/bot/cogs/cogs/guild/welcome.py index f8973c5..e338e47 100644 --- a/src/bot/cogs/guild/welcome.py +++ b/src/bot/cogs/cogs/guild/welcome.py @@ -8,7 +8,7 @@ import discord from discord.ext import commands -from mx_devtools import WelcomeDatabase +from mxmariadb import WelcomeDatabase import asyncio import json import io @@ -616,7 +616,7 @@ async def callback(self, interaction: discord.Interaction): container.add_separator() container.add_text( "## 👀 Vorschau (mit deinen Daten)\n\n" - f"{preview[:500] + ("..." if len(preview) > 500 else "")}\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.DesignerView(container, timeout=None) diff --git a/src/bot/cogs/legacy/secret_commands.py b/src/bot/cogs/cogs/legacy/secret_commands.py similarity index 100% rename from src/bot/cogs/legacy/secret_commands.py rename to src/bot/cogs/cogs/legacy/secret_commands.py diff --git a/src/bot/cogs/cogs/management/autodelete.py b/src/bot/cogs/cogs/management/autodelete.py new file mode 100644 index 0000000..8510552 --- /dev/null +++ b/src/bot/cogs/cogs/management/autodelete.py @@ -0,0 +1,103 @@ +from mxmariadb 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.processing_channels = set() + self.db = AutoDeleteDB() + # WICHTIG: KEIN create_task hier im __init__! + self.delete_task.start() + + 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", required=True)): + await ctx.defer(ephemeral=True) + await self.db.add_autodelete(channel.id, duration) + await ctx.followup.send(f"✅ AutoDelete für {channel.mention} aktiviert!") + + @autodelete.command(name="list", description="Zeigt alle aktiven AutoDelete-Kanäle.") + async def list(self, ctx): + await ctx.defer(ephemeral=True) + channels = await self.db.get_all() + + if not channels: + return await ctx.followup.send("❌ Keine AutoDelete-Kanäle gefunden.") + + embed = discord.Embed(title="🗑️ Aktive AutoDelete-Kanäle", color=discord.Color.blue()) + for chan_id, duration, exp, exb in channels: + channel = self.bot.get_channel(chan_id) + name = f"#{channel.name}" if channel else f"ID: {chan_id}" + embed.add_field(name=name, value=f"⏱️ {duration}s", inline=True) + + await ctx.followup.send(embed=embed) + + @tasks.loop(seconds=30) + async def delete_task(self): + # Der eigentliche Loop-Inhalt + try: + channels = await self.db.get_all() + if not channels: return + + for chan_id, duration, exp, exb in channels: + if chan_id not in self.processing_channels: + await self._process_channel_deletion(chan_id) + except Exception as e: + logger.error(f"Fehler im delete_task: {e}") + + @delete_task.before_loop + async def before_delete_task(self): + """Hier wird die DB sicher initialisiert, wenn der Loop bereits läuft.""" + await self.bot.wait_until_ready() + # JETZT ist der Loop bereit, jetzt können wir connecten + print("[DB] Initialisiere AutoDelete Tabellen...") + await self.db.init_db() + print("[DB] AutoDelete Tabellen bereit.") + + async def _process_channel_deletion(self, channel_id): + self.processing_channels.add(channel_id) + try: + config = await self.db.get_autodelete_full(channel_id) + if not config: return + + duration, exp, exb = config + channel = self.bot.get_channel(channel_id) + if not channel: return + + cutoff = discord.utils.utcnow() - timedelta(seconds=duration) + + # Sammle Nachrichten zum Löschen + to_delete = [] + async for msg in channel.history(limit=100, oldest_first=True): + if msg.created_at < cutoff: + if not (exp and msg.pinned) and not (exb and msg.author.bot): + to_delete.append(msg) + else: + break # Nachrichten werden neuer, wir können aufhören + + if to_delete: + # Bulk Delete nur für Nachrichten unter 14 Tage + two_weeks = discord.utils.utcnow() - timedelta(days=14) + to_bulk = [m for m in to_delete if m.created_at > two_weeks] + + if to_bulk: + await channel.delete_messages(to_bulk) + await self.db.update_stats(channel_id, len(to_bulk), 0) + except Exception as e: + logger.error(f"Fehler beim Löschen in {channel_id}: {e}") + finally: + self.processing_channels.discard(channel_id) + +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/cogs/management/autorole.py similarity index 99% rename from src/bot/cogs/management/autorole.py rename to src/bot/cogs/cogs/management/autorole.py index 7d30f30..127e3f6 100644 --- a/src/bot/cogs/management/autorole.py +++ b/src/bot/cogs/cogs/management/autorole.py @@ -1,7 +1,7 @@ import discord from discord.ext import commands from discord import option -from mx_devtools import AutoRoleDatabase +from mxmariadb import AutoRoleDatabase from mx_handler import TranslationHandler as TH class AutoRole(commands.Cog): diff --git a/src/bot/cogs/moderation/antispam.py b/src/bot/cogs/cogs/moderation/antispam.py similarity index 99% rename from src/bot/cogs/moderation/antispam.py rename to src/bot/cogs/cogs/moderation/antispam.py index db833f0..713ad42 100644 --- a/src/bot/cogs/moderation/antispam.py +++ b/src/bot/cogs/cogs/moderation/antispam.py @@ -8,7 +8,7 @@ from datetime import timedelta -from mx_devtools import AntiSpamDatabase as SpamDB +from mxmariadb import AntiSpamDatabase as SpamDB antispam = SlashCommandGroup("antispam") class AntiSpam(ezcord.Cog): @@ -29,7 +29,7 @@ async def on_message(self, message): return # Check if user is whitelisted - if self.is_whitelisted(message.guild.id, message.author.id): + if await self.is_whitelisted(message.guild.id, message.author.id): return # Get spam settings for this guild diff --git a/src/bot/cogs/moderation/moderation.py b/src/bot/cogs/cogs/moderation/moderation.py similarity index 98% rename from src/bot/cogs/moderation/moderation.py rename to src/bot/cogs/cogs/moderation/moderation.py index 054abe2..5daf98c 100644 --- a/src/bot/cogs/moderation/moderation.py +++ b/src/bot/cogs/cogs/moderation/moderation.py @@ -128,14 +128,13 @@ def _format_duration(self, duration: timedelta) -> str: 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""" - from src.bot.core.config import BotConfig color_map = { - "Bann": discord.Color.from_rgb(*BotConfig.moderation.log_colors.ban), - "Kick": discord.Color.from_rgb(*BotConfig.moderation.log_colors.kick), - "Timeout": discord.Color.from_rgb(*BotConfig.moderation.log_colors.timeout), - "Timeout aufgehoben": discord.Color.from_rgb(*BotConfig.moderation.log_colors.untimeout), - "Slowmode aktiviert": discord.Color.from_rgb(*BotConfig.moderation.log_colors.slowmode_on), - "Slowmode deaktiviert": discord.Color.from_rgb(*BotConfig.moderation.log_colors.slowmode_off), + "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", diff --git a/src/bot/cogs/moderation/notes.py b/src/bot/cogs/cogs/moderation/notes.py similarity index 97% rename from src/bot/cogs/moderation/notes.py rename to src/bot/cogs/cogs/moderation/notes.py index 5694538..3da74d0 100644 --- a/src/bot/cogs/moderation/notes.py +++ b/src/bot/cogs/cogs/moderation/notes.py @@ -6,7 +6,7 @@ from discord import SlashCommandGroup import datetime import ezcord -from mx_devtools import NotesDatabase +from mxmariadb import NotesDatabase notes = SlashCommandGroup("notes") # ─────────────────────────────────────────────── @@ -16,7 +16,7 @@ class NotesCog(ezcord.Cog, group="moderation"): def __init__(self, bot): self.bot = bot - self.db = NotesDatabase("data") + self.db = NotesDatabase() @notes.command(name="add", description="📝 Speichere eine Notiz für einen User") async def add( diff --git a/src/bot/cogs/moderation/warn.py b/src/bot/cogs/cogs/moderation/warn.py similarity index 99% rename from src/bot/cogs/moderation/warn.py rename to src/bot/cogs/cogs/moderation/warn.py index e58f29e..64993c6 100644 --- a/src/bot/cogs/moderation/warn.py +++ b/src/bot/cogs/cogs/moderation/warn.py @@ -2,7 +2,7 @@ # ─────────────────────────────────────────────── # >> Imports # ─────────────────────────────────────────────── -from mx_devtools import WarnDatabase +from mxmariadb import WarnDatabase import discord from discord import slash_command, Option import os @@ -48,8 +48,7 @@ class WarnSystem(ezcord.Cog, group="moderation"): def __init__(self, bot): self.bot = bot - base_path = os.path.dirname(__file__) - self.db = WarnDatabase(base_path) + self.db = WarnDatabase() # Cache für bessere Performance self._warn_cache = {} diff --git a/src/bot/cogs/user/settings.py b/src/bot/cogs/cogs/user/settings.py similarity index 98% rename from src/bot/cogs/user/settings.py rename to src/bot/cogs/cogs/user/settings.py index 1d264df..e847153 100644 --- a/src/bot/cogs/user/settings.py +++ b/src/bot/cogs/cogs/user/settings.py @@ -18,12 +18,12 @@ from src.bot.ui.emojis import emoji_warn except ImportError: emoji_warn = "⚠️" -from mx_devtools import ( +from mxmariadb import ( StatsDB, WarnDatabase, NotesDatabase, LevelDatabase, ProfileDB, SettingsDB, AutoDeleteDB, AntiSpamDatabase, TempVCDatabase ) -from mx_devtools.backend.database.globalchat_db import GlobalChatDatabase, db as global_db +from mxmariadb import GlobalChatDatabase class Settings(ezcord.Cog): """Cog für Benutzereinstellungen, Sprache und Datenverwaltung.""" diff --git a/src/bot/cogs/user/stats.py b/src/bot/cogs/cogs/user/stats.py similarity index 98% rename from src/bot/cogs/user/stats.py rename to src/bot/cogs/cogs/user/stats.py index 1c56e17..d8d263a 100644 --- a/src/bot/cogs/user/stats.py +++ b/src/bot/cogs/cogs/user/stats.py @@ -3,7 +3,7 @@ from discord import SlashCommandGroup import logging from typing import Optional -from mx_devtools import StatsDB, LevelDatabase +from mxmariadb import StatsDB, LevelDatabase import asyncio import ezcord from datetime import datetime, timedelta @@ -21,15 +21,19 @@ class EnhancedStatsCog(ezcord.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.db = getattr(bot, "stats_db", None) or StatsDB() - if not hasattr(bot, "stats_db"): - bot.stats_db = self.db + + # 1. StatsDB initialisieren (Keine Argumente in die Klammern!) + self.db = StatsDB() + + # 2. LevelDatabase initialisieren (Ebenfalls leer lassen) self.level_db = LevelDatabase() - from src.bot.core.config import BotConfig - self.cleanup_task.change_interval(hours=BotConfig.intervals.stats_update) + # Optional: Die DB am Bot-Objekt registrieren, falls andere Cogs sie brauchen + if not hasattr(bot, "stats_db"): + bot.stats_db = self.db + self.cleanup_task.start() - self.monthly_reset_task.start() # Monthly remains monthly, but we could make it configurable too + self.monthly_reset_task.start() logger.info("Enhanced StatsCog initialized") stats = SlashCommandGroup("stats", "Statistiken") diff --git a/src/bot/cogs/management/autodelete.py b/src/bot/cogs/management/autodelete.py deleted file mode 100644 index 45009d8..0000000 --- a/src/bot/cogs/management/autodelete.py +++ /dev/null @@ -1,311 +0,0 @@ -from mx_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 - from src.bot.core.config import BotConfig - self.delete_task.change_interval(seconds=BotConfig.intervals.autodelete_check_seconds) - 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: 60s max: 7d (604800s))", 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 = discord.utils.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 = discord.utils.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/core/bot_setup.py b/src/bot/core/bot_setup.py index 08640a1..aecec11 100644 --- a/src/bot/core/bot_setup.py +++ b/src/bot/core/bot_setup.py @@ -32,22 +32,22 @@ def create_bot(self) -> ezcord.Bot: # Bot erstellen bot = ezcord.PrefixBot( intents=intents, - language=BotConfig.LANGUAGE, - command_prefix=BotConfig.PREFIX, + language=BotConfig.bot.language, + command_prefix=BotConfig.bot.prefix, help_command=None ) # Ezcord Help Command aktivieren embed = discord.Embed( - title=f"Hello, I'm {BotConfig.NAME}!", + title=f"Hello, I'm {BotConfig.bot.name}!", description=( f"**The ultimate all-in-one Discord solution.**\n\n" - f"> {BotConfig.NAME} simplifies server management and brings your community " + f"> {BotConfig.bot.name} simplifies server management and brings your community " "together with engaging games and reliable tools.\n\n" "✨ **Getting Started**\n" "Use the menu below to explore all commands!" ), - color=discord.Color.from_rgb(*BotConfig.EMBED_COLOR), + color=discord.Color.from_rgb(*BotConfig.ui.colors.primary), timestamp=discord.utils.utcnow() ) @@ -65,15 +65,15 @@ def create_bot(self) -> ezcord.Bot: embed.add_field( name="🔗 **Important Links**", value=( - f"🌐 [**Website**]({BotConfig.WEBSITE}) • " - f"🚑 [**Support**]({BotConfig.SUPPORT}) • " - f"💻 [**GitHub**]({BotConfig.GITHUB})" + f"🌐 [**Website**]({BotConfig.links.website}) • " + f"🚑 [**Support**]({BotConfig.links.support}) • " + f"💻 [**GitHub**]({BotConfig.links.github})" ), inline=False ) # Check if we can set a thumbnail or image (safe fallback) - embed.set_footer(text=BotConfig.FOOTER_TEXT, icon_url=None) + embed.set_footer(text=BotConfig.ui.footer_text, icon_url=None) bot.add_help_command( embed=embed, diff --git a/src/bot/core/config.py b/src/bot/core/config.py index 33c3851..0d6e7d6 100644 --- a/src/bot/core/config.py +++ b/src/bot/core/config.py @@ -17,26 +17,58 @@ class ConfigDict(dict): def __getattr__(self, name): if name in self: val = self[name] - if isinstance(val, dict): - return ConfigDict(val) + if isinstance(val, dict) and not isinstance(val, ConfigDict): + val = ConfigDict(val) + self[name] = val return val - # Fallback für verschachtelte Zugriffe auf nicht existierende Keys - return ConfigDict() + + # Spezialfall: Falls nach 'path' oder ähnlichem auf einem leeren Dict gefragt wird + # geben wir einen leeren String oder das Dict selbst zurück, um Abstürze zu vermeiden. + return ConfigDict() + + def __getitem__(self, key): + val = super().get(key, {}) + if isinstance(val, dict) and not isinstance(val, ConfigDict): + return ConfigDict(val) + return val def __setattr__(self, name, value): self[name] = value - - def get(self, key, default=None): - return super().get(key, default) -class classproperty(object): - def __init__(self, fget): - self.fget = fget - def __get__(self, owner_self, owner_cls): - return self.fget(owner_cls) +class ConfigMeta(type): + """Metaklasse, die alle Attribut-Zugriffe auf BotConfig._data umleitet.""" + def __getattr__(cls, name): + if name.startswith('_'): + raise AttributeError(name) + + # Direkter Zugriff auf das interne Dictionary + if name in cls._data: + return cls._data[name] + + # Fallback: Versuche es über ConfigDict.__getattr__ + return getattr(cls._data, name) + + @property + def VERSION(cls): return cls._data.bot.get('version', '2.0.0') + + @property + def PREFIX(cls): return cls._data.bot.get('prefix', '!mx ') + + @property + def DATA_PATH(cls): + # Sicherstellen, dass .logging existiert und ein Dict/ConfigDict ist + log_cfg = cls._data.get('logging', {}) + path_str = log_cfg.get('data_path', 'data') if isinstance(log_cfg, dict) else 'data' + return Path(path_str) + + @property + def COGS_PATH(cls): + log_cfg = cls._data.get('logging', {}) + path_str = log_cfg.get('cogs_path', 'src/bot/cogs') if isinstance(log_cfg, dict) else 'src/bot/cogs' + return Path(path_str) -class BotConfig: - """Zentrale Konfigurations-Schnittstelle""" +class BotConfig(metaclass=ConfigMeta): + """Zentrale Konfigurations-Schnittstelle (Metaklasse übernimmt lookups)""" _data = ConfigDict() TOKEN = os.getenv("TOKEN") @@ -48,58 +80,26 @@ def load(cls, basedir: Path): try: with open(config_path, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) + print(f"[{Fore.BLUE}DEBUG{Style.RESET_ALL}] Lade Config von: {config_path}") + print(f"[{Fore.BLUE}DEBUG{Style.RESET_ALL}] Keys in Config: {list(data.keys()) if data else 'None'}") cls._data = ConfigDict(data) # Grundlegende Prüfung - if not cls._data.get('bot', {}).get('enabled', True): + if not cls._data.bot.get('enabled', True): print(f"[{Fore.YELLOW}INFO{Style.RESET_ALL}] Bot ist in config.yaml deaktiviert. Beende...") sys.exit(0) return data except Exception as e: print(f"[{Fore.RED}ERROR{Style.RESET_ALL}] Konfigurationsfehler: {e}") + import traceback + traceback.print_exc() sys.exit(1) - def __getattr__(self, name): - return getattr(self._data, name) - - @classproperty - def bot(cls): return cls._data.get('bot', ConfigDict()) - @classproperty - def security(cls): return cls._data.get('security', ConfigDict()) - @classproperty - def ui(cls): return cls._data.get('ui', ConfigDict()) - @classproperty - def api(cls): return cls._data.get('api', ConfigDict()) - @classproperty - def leveling(cls): return cls._data.get('leveling', ConfigDict()) - @classproperty - def moderation(cls): return cls._data.get('moderation', ConfigDict()) - @classproperty - def global_chat(cls): return cls._data.get('global_chat', ConfigDict()) - @classproperty - def logging(cls): return cls._data.get('logging', ConfigDict()) - @classproperty - def links(cls): return cls._data.get('links', ConfigDict()) - @classproperty - def intervals(cls): return cls._data.get('intervals', ConfigDict()) - @classproperty - def features(cls): return cls._data.get('features', ConfigDict()) - @classproperty - def translation(cls): return cls._data.get('translation', ConfigDict()) - - # --- Legacy Aliases/Shortcuts (Minimale Liste für Pfade) --- - @classproperty - def VERSION(cls): return cls.bot.get('version', '2.0.0') - @classproperty - def PREFIX(cls): return cls.bot.get('prefix', '!mx ') - @classproperty - def LANGUAGE(cls): return cls.bot.get('language', 'de') - - @classproperty - def DATA_PATH(cls): return Path(cls.logging.get('data_path', 'data')) - @classproperty - def COGS_PATH(cls): return Path(cls.logging.get('cogs_path', 'src/bot/cogs')) +# Alias für ConfigLoader +class ConfigLoader: + def __init__(self, basedir): self.basedir = basedir + def load(self): return BotConfig.load(self.basedir) # Alias für ConfigLoader class ConfigLoader: diff --git a/src/bot/core/utils.py b/src/bot/core/utils.py index c7a7d72..ed157c4 100644 --- a/src/bot/core/utils.py +++ b/src/bot/core/utils.py @@ -25,7 +25,7 @@ def print_logo(): for line in logo_lines: print(line) print(f"{'=' * 91}") - print(f" ManagerX Discord Bot v{BotConfig.VERSION}") + print(f" ManagerX Discord Bot v{BotConfig.bot.version}") print(f"{'=' * 91}{Style.RESET_ALL}\n") diff --git a/src/web/components/AntiSpamSettings.tsx b/src/web/components/AntiSpamSettings.tsx index 5533674..2d39616 100644 --- a/src/web/components/AntiSpamSettings.tsx +++ b/src/web/components/AntiSpamSettings.tsx @@ -21,6 +21,8 @@ interface Channel { name: string; } +import { API_URL } from "../lib/api"; + export default function AntiSpamSettings({ guildId }: { guildId: string }) { const { token } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -35,10 +37,8 @@ export default function AntiSpamSettings({ guildId }: { guildId: string }) { const fetchData = async () => { if (!token || !guildId) return; try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - // Fetch Channels - const channelRes = await fetch(`${baseUrl}/dashboard/guilds/${guildId}/channels`, { + const channelRes = await fetch(`${API_URL}/dashboard/guilds/${guildId}/channels`, { headers: { "Authorization": `Bearer ${token}` } }); if (channelRes.ok) { @@ -47,7 +47,7 @@ export default function AntiSpamSettings({ guildId }: { guildId: string }) { } // Fetch AntiSpam Settings - const settingsRes = await fetch(`${baseUrl}/dashboard/settings/${guildId}/antispam`, { + const settingsRes = await fetch(`${API_URL}/dashboard/settings/${guildId}/antispam`, { headers: { "Authorization": `Bearer ${token}` } }); if (settingsRes.ok) { @@ -69,14 +69,13 @@ export default function AntiSpamSettings({ guildId }: { guildId: string }) { const handleSave = async () => { setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; const payload = { max_messages: maxMessages, time_frame: timeFrame, log_channel_id: logChannelId }; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/antispam`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/antispam`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, diff --git a/src/web/components/AuthProvider.tsx b/src/web/components/AuthProvider.tsx index d4933f2..bb858bd 100644 --- a/src/web/components/AuthProvider.tsx +++ b/src/web/components/AuthProvider.tsx @@ -13,6 +13,8 @@ interface AuthContextType { const AuthContext = createContext(undefined); +import { API_URL } from "../lib/api"; + export const AuthProvider = ({ children }: { children: ReactNode }) => { const [token, setToken] = useState(localStorage.getItem("token")); const [user, setUser] = useState(JSON.parse(localStorage.getItem("user") || "null")); @@ -42,9 +44,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const path = params.get("p"); if (code && (window.location.pathname.includes("auth/callback") || path?.includes("/auth/callback"))) { - const baseUrl = import.meta.env.VITE_API_URL || 'https://api.managerx-bot.de'; - - fetch(`${baseUrl}/dashboard/auth/callback`, { + fetch(`${API_URL}/dashboard/auth/callback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) @@ -61,8 +61,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // --- USER & GUILDS LADEN --- useEffect(() => { if (token) { - const baseUrl = import.meta.env.VITE_API_URL || 'https://api.managerx-bot.de'; - fetch(`${baseUrl}/dashboard/auth/me`, { + fetch(`${API_URL}/dashboard/auth/me`, { headers: { "Authorization": `Bearer ${token}`, "X-Discord-Token": localStorage.getItem("discord_token") || "" diff --git a/src/web/components/AutoDeleteSettings.tsx b/src/web/components/AutoDeleteSettings.tsx index f82ab3a..a475a78 100644 --- a/src/web/components/AutoDeleteSettings.tsx +++ b/src/web/components/AutoDeleteSettings.tsx @@ -26,6 +26,8 @@ interface ChannelConfig { delay: number; } +import { API_URL } from "../lib/api"; + export default function AutoDeleteSettings({ guildId, channels }: AutoDeleteSettingsProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -37,8 +39,7 @@ export default function AutoDeleteSettings({ guildId, channels }: AutoDeleteSett const fetchSettings = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/autodelete`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/autodelete`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } @@ -58,8 +59,7 @@ export default function AutoDeleteSettings({ guildId, channels }: AutoDeleteSett const handleSave = async () => { setSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/autodelete`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/autodelete`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/web/components/AutoRoleSettings.tsx b/src/web/components/AutoRoleSettings.tsx index 3c25972..c515584 100644 --- a/src/web/components/AutoRoleSettings.tsx +++ b/src/web/components/AutoRoleSettings.tsx @@ -21,6 +21,8 @@ interface AutoRoleSettingsProps { roles: any[]; } +import { API_URL } from "../lib/api"; + export default function AutoRoleSettings({ guildId, roles }: AutoRoleSettingsProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -37,8 +39,7 @@ export default function AutoRoleSettings({ guildId, roles }: AutoRoleSettingsPro const fetchSettings = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/autorole`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/autorole`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } @@ -58,8 +59,7 @@ export default function AutoRoleSettings({ guildId, roles }: AutoRoleSettingsPro const handleSave = async () => { setSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/autorole`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/autorole`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/web/components/GlobalChatSettings.tsx b/src/web/components/GlobalChatSettings.tsx index b8edace..bdbbce6 100644 --- a/src/web/components/GlobalChatSettings.tsx +++ b/src/web/components/GlobalChatSettings.tsx @@ -21,6 +21,8 @@ interface Channel { name: string; } +import { API_URL } from "../lib/api"; + export default function GlobalChatSettings({ guildId }: { guildId: string }) { const { token } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -36,10 +38,8 @@ export default function GlobalChatSettings({ guildId }: { guildId: string }) { const fetchData = async () => { if (!token || !guildId) return; try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - // Fetch Channels - const channelRes = await fetch(`${baseUrl}/dashboard/guilds/${guildId}/channels`, { + const channelRes = await fetch(`${API_URL}/dashboard/guilds/${guildId}/channels`, { headers: { "Authorization": `Bearer ${token}` } }); if (channelRes.ok) { @@ -48,7 +48,7 @@ export default function GlobalChatSettings({ guildId }: { guildId: string }) { } // Fetch GlobalChat Settings - const settingsRes = await fetch(`${baseUrl}/dashboard/settings/${guildId}/globalchat`, { + const settingsRes = await fetch(`${API_URL}/dashboard/settings/${guildId}/globalchat`, { headers: { "Authorization": `Bearer ${token}` } }); if (settingsRes.ok) { @@ -71,7 +71,6 @@ export default function GlobalChatSettings({ guildId }: { guildId: string }) { const handleSave = async () => { setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; const payload = { channel_id: channelId, filter_enabled: filterEnabled, @@ -79,7 +78,7 @@ export default function GlobalChatSettings({ guildId }: { guildId: string }) { embed_color: embedColor }; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/globalchat`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/globalchat`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, diff --git a/src/web/components/LevelSettings.tsx b/src/web/components/LevelSettings.tsx index 8319b80..bc39a92 100644 --- a/src/web/components/LevelSettings.tsx +++ b/src/web/components/LevelSettings.tsx @@ -25,6 +25,8 @@ interface LevelSettingsProps { channels: any[]; } +import { API_URL } from "../lib/api"; + export default function LevelSettings({ guildId, channels }: LevelSettingsProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -43,8 +45,7 @@ export default function LevelSettings({ guildId, channels }: LevelSettingsProps) const fetchSettings = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/levels`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/levels`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } @@ -64,8 +65,7 @@ export default function LevelSettings({ guildId, channels }: LevelSettingsProps) const handleSave = async () => { setSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/levels`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/levels`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/web/components/LoggingSettings.tsx b/src/web/components/LoggingSettings.tsx index c377c53..6da5396 100644 --- a/src/web/components/LoggingSettings.tsx +++ b/src/web/components/LoggingSettings.tsx @@ -17,12 +17,15 @@ import { Search } from "lucide-react"; import { SearchableSelect } from "./ui/SearchableSelect"; +import { toast } from "sonner"; interface LoggingSettingsProps { guildId: string; channels: any[]; } +import { API_URL } from "../lib/api"; + export default function LoggingSettings({ guildId, channels }: LoggingSettingsProps) { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -41,8 +44,7 @@ export default function LoggingSettings({ guildId, channels }: LoggingSettingsPr const fetchSettings = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/logging`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/logging`, { headers: { "Authorization": `Bearer ${localStorage.getItem("token")}` } @@ -62,8 +64,7 @@ export default function LoggingSettings({ guildId, channels }: LoggingSettingsPr const handleSave = async () => { setSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/logging`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/logging`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/web/components/OverviewSettings.tsx b/src/web/components/OverviewSettings.tsx index b9094bb..e37a594 100644 --- a/src/web/components/OverviewSettings.tsx +++ b/src/web/components/OverviewSettings.tsx @@ -13,6 +13,8 @@ interface OverviewSettingsProps { settings?: any; } +import { API_URL } from "../lib/api"; + export default function OverviewSettings({ guildId, initialStats, settings }: OverviewSettingsProps) { const [stats, setStats] = useState(initialStats || null); const [loading, setLoading] = useState(!initialStats); @@ -28,7 +30,7 @@ export default function OverviewSettings({ guildId, initialStats, settings }: Ov if (!guildId) return; const token = localStorage.getItem("token"); try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/dashboard/guilds/${guildId}/stats`, { + const response = await fetch(`${API_URL}/dashboard/guilds/${guildId}/stats`, { headers: { "Authorization": `Bearer ${token}` } }); if (response.ok) { @@ -142,7 +144,7 @@ export default function OverviewSettings({ guildId, initialStats, settings }: Ov
- +

Nachrichten Volumen (7 Tage)

diff --git a/src/web/components/TempVCSettings.tsx b/src/web/components/TempVCSettings.tsx index 39db5d7..2035099 100644 --- a/src/web/components/TempVCSettings.tsx +++ b/src/web/components/TempVCSettings.tsx @@ -32,6 +32,8 @@ interface TempVCSettingsProps { voiceChannels: any[]; } +import { API_URL } from "../lib/api"; + export default function TempVCSettings({ guildId, categories, voiceChannels }: TempVCSettingsProps) { const { token } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -51,8 +53,7 @@ export default function TempVCSettings({ guildId, categories, voiceChannels }: T if (!token || !guildId) return; setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/tempvc`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/tempvc`, { headers: { "Authorization": `Bearer ${token}` } }); @@ -82,8 +83,7 @@ export default function TempVCSettings({ guildId, categories, voiceChannels }: T if (!token || !guildId) return; setIsSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/tempvc`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/tempvc`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, diff --git a/src/web/components/WelcomeSettings.tsx b/src/web/components/WelcomeSettings.tsx index 77e10b3..2ea11bc 100644 --- a/src/web/components/WelcomeSettings.tsx +++ b/src/web/components/WelcomeSettings.tsx @@ -25,6 +25,8 @@ interface Channel { name: string; } +import { API_URL } from "../lib/api"; + export default function WelcomeSettings({ guildId }: { guildId: string }) { const { token } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -45,10 +47,8 @@ export default function WelcomeSettings({ guildId }: { guildId: string }) { const fetchData = async () => { if (!token || !guildId) return; try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - // Fetch Channels - const channelRes = await fetch(`${baseUrl}/dashboard/guilds/${guildId}/channels`, { + const channelRes = await fetch(`${API_URL}/dashboard/guilds/${guildId}/channels`, { headers: { "Authorization": `Bearer ${token}` } }); if (channelRes.ok) { @@ -57,7 +57,7 @@ export default function WelcomeSettings({ guildId }: { guildId: string }) { } // Fetch Welcome Settings - const settingsRes = await fetch(`${baseUrl}/dashboard/settings/${guildId}/welcome`, { + const settingsRes = await fetch(`${API_URL}/dashboard/settings/${guildId}/welcome`, { headers: { "Authorization": `Bearer ${token}` } }); if (settingsRes.ok) { @@ -97,8 +97,7 @@ export default function WelcomeSettings({ guildId }: { guildId: string }) { ping_user: pingUser }; - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/settings/${guildId}/welcome`, { + const res = await fetch(`${API_URL}/dashboard/settings/${guildId}/welcome`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, diff --git a/src/web/dashboard/LoginPage.tsx b/src/web/dashboard/LoginPage.tsx index 60a4db7..f9e81ad 100644 --- a/src/web/dashboard/LoginPage.tsx +++ b/src/web/dashboard/LoginPage.tsx @@ -28,11 +28,12 @@ const FeatureItem = ({ icon: Icon, title, description }: { icon: any, title: str
); +import { API_URL } from "../lib/api"; + export default function LoginPage() { const handleLogin = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const apiUrl = `${baseUrl}/dashboard/auth/login`; + const apiUrl = `${API_URL}/dashboard/auth/login`; const res = await fetch(apiUrl); if (!res.ok) throw new Error("Could not fetch login URL"); diff --git a/src/web/dashboard/SettingsPage.tsx b/src/web/dashboard/SettingsPage.tsx index 4e72ac0..9d18ca4 100644 --- a/src/web/dashboard/SettingsPage.tsx +++ b/src/web/dashboard/SettingsPage.tsx @@ -48,6 +48,8 @@ const CategoryHeader = ({ children }: { children: React.ReactNode }) => (

{children}

); +import { API_URL } from "../lib/api"; + export default function SettingsPage() { const { token, selectedGuildId } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -78,10 +80,8 @@ export default function SettingsPage() { if (!token || !guildId) return; setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - // Fetch EVERYTHING in ONE call - const res = await fetch(`${baseUrl}/dashboard/guilds/${guildId}/mega-data`, { + const res = await fetch(`${API_URL}/dashboard/guilds/${guildId}/mega-data`, { headers: { "Authorization": `Bearer ${token}` } }); @@ -123,8 +123,7 @@ export default function SettingsPage() { const handleSave = async () => { setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const apiUrl = `${baseUrl}/dashboard/settings/${guildId}`; + const apiUrl = `${API_URL}/dashboard/settings/${guildId}`; const payload = { prefix, autoMod, welcomeMessage, language, user_role_id: userRoleId, team_role_id: teamRoleId }; diff --git a/src/web/dashboard/UserSettingsPage.tsx b/src/web/dashboard/UserSettingsPage.tsx index 9b85cd0..35a9571 100644 --- a/src/web/dashboard/UserSettingsPage.tsx +++ b/src/web/dashboard/UserSettingsPage.tsx @@ -37,6 +37,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co import { Toaster, toast } from "sonner"; import { cn } from "../lib/utils"; +import { API_URL } from "../lib/api"; + export default function UserSettingsPage() { const { token, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -58,8 +60,7 @@ export default function UserSettingsPage() { if (!token) return; setIsLoading(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/user/settings`, { + const res = await fetch(`${API_URL}/dashboard/user/settings`, { headers: { "Authorization": `Bearer ${token}` } }); @@ -91,8 +92,7 @@ export default function UserSettingsPage() { if (!token) return; setIsSaving(true); try { - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const res = await fetch(`${baseUrl}/dashboard/user/settings`, { + const res = await fetch(`${API_URL}/dashboard/user/settings`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, diff --git a/favicon.ico b/src/web/favicon.ico similarity index 100% rename from favicon.ico rename to src/web/favicon.ico diff --git a/src/web/hooks/useStats.ts b/src/web/hooks/useStats.ts index f4497ba..434254a 100644 --- a/src/web/hooks/useStats.ts +++ b/src/web/hooks/useStats.ts @@ -11,6 +11,8 @@ interface StatsData { database: string; } +import { API_URL } from "../lib/api"; + export const useStats = () => { const [data, setData] = useState({ uptime: "--", @@ -28,8 +30,7 @@ export const useStats = () => { useEffect(() => { const fetchStats = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:8040"; - const response = await fetch(`${baseUrl}/v1/managerx/stats`); + const response = await fetch(`${API_URL}/v1/managerx/stats`); if (!response.ok) throw new Error("Offline"); const result = await response.json(); diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts new file mode 100644 index 0000000..ae69660 --- /dev/null +++ b/src/web/lib/api.ts @@ -0,0 +1,2 @@ +// @ts-ignore +export const API_URL = import.meta.env.VITE_API_URL || "https://api.managerx-bot.de"; diff --git a/src/web/lib/legal.ts b/src/web/lib/legal.ts new file mode 100644 index 0000000..b531964 --- /dev/null +++ b/src/web/lib/legal.ts @@ -0,0 +1,23 @@ +export const LEGAL_CONFIG = { + owner: { + name: "Lenny Steiger", + role: "Gründer & Projektleiter", + address: { + street: "Eulauer Str. 24", + city: "04523 Pegau", + country: "Deutschland" + } + }, + contact: { + email: "contact@managerx-bot.de", + legalEmail: "legal@managerx-bot.de", + supportEmail: "support@managerx-bot.de" + }, + hosting: { + provider: "Self-Hosted / Managed Server", + location: "Deutschland (Frankfurt am Main)", + details: "Serverbetrieb durch ManagerX Development Network in einem ISO-zertifizierten Rechenzentrum." + }, + version: "2.0.0", + lastUpdate: "1. April 2026" +}; diff --git a/src/web/pages/AuthCallback.tsx b/src/web/pages/AuthCallback.tsx index ec145f6..6dcda58 100644 --- a/src/web/pages/AuthCallback.tsx +++ b/src/web/pages/AuthCallback.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { motion } from "framer-motion"; import { useAuth } from "../components/AuthProvider"; import { toast } from "sonner"; +import { API_URL } from "../lib/api"; export default function AuthCallback() { const [searchParams] = useSearchParams(); @@ -21,8 +22,7 @@ export default function AuthCallback() { const handleAuth = async () => { try { // Adjust to your actual backend domain/port, 8040 is what the python API uses - const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8040'; - const apiUrl = `${baseUrl}/dashboard/auth/callback`; + const apiUrl = `${API_URL}/dashboard/auth/callback`; const response = await fetch(apiUrl, { method: "POST", diff --git a/src/web/pages/Datenschutz.tsx b/src/web/pages/Datenschutz.tsx index d4b2da3..3e4096d 100644 --- a/src/web/pages/Datenschutz.tsx +++ b/src/web/pages/Datenschutz.tsx @@ -8,6 +8,7 @@ import { import { Navbar } from "../components/Navbar"; import { Footer } from "../components/Footer"; import { motion } from "framer-motion"; +import { LEGAL_CONFIG } from "../lib/legal"; const SECTIONS = [ { id: "intro", title: "Introduction", icon: Shield }, @@ -109,7 +110,7 @@ export const Datenschutz = memo(function Datenschutz() {

GDPR Compliant

-

Ihre Daten werden nach strengsten EU-Richtlinien in Frankfurt gehostet.

+

Ihre Daten werden nach strengsten EU-Richtlinien in {LEGAL_CONFIG.hosting.location} gehostet.

System Secure @@ -138,7 +139,7 @@ export const Datenschutz = memo(function Datenschutz() {

- Status: DSGVO / GDPR Standard v2.2 (Aktualisiert am 28. März 2026) + Status: DSGVO / GDPR Standard v2.2 (Aktualisiert am {LEGAL_CONFIG.lastUpdate})
@@ -151,14 +152,14 @@ export const Datenschutz = memo(function Datenschutz() {

ManagerX Development Network

-

Lenny Steiger

-

Eulauer Str. 24

-

04523 Pegau, Deutschland

+

{LEGAL_CONFIG.owner.name}

+

{LEGAL_CONFIG.owner.address.street}

+

{LEGAL_CONFIG.owner.address.city}, {LEGAL_CONFIG.owner.address.country}

@@ -242,8 +243,8 @@ export const Datenschutz = memo(function Datenschutz() {
-

Frankfurt am Main

-

Unsere Server stehen in Deutschland (EU).

+

{LEGAL_CONFIG.hosting.location}

+

{LEGAL_CONFIG.hosting.provider}

Safe EU Data Residency
@@ -254,7 +255,7 @@ export const Datenschutz = memo(function Datenschutz() {

Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Widerspruch.

-

Senden Sie uns eine E-Mail an legal@managerx-bot.de.

+

Senden Sie uns eine E-Mail an {LEGAL_CONFIG.contact.legalEmail}.

@@ -279,7 +280,7 @@ export const Datenschutz = memo(function Datenschutz() {
-

Diese Website wird über GitHub Pages gehostet.

+

Diese Website wird über {LEGAL_CONFIG.hosting.provider} ({LEGAL_CONFIG.hosting.location}) gehostet.

@@ -309,10 +310,10 @@ export const Datenschutz = memo(function Datenschutz() {

Privacy Support

- - legal@managerx-bot.de + + {LEGAL_CONFIG.contact.legalEmail} -

© 2026 ManagerX Development

+

© {new Date().getFullYear()} ManagerX Development

diff --git a/src/web/pages/Impressum.tsx b/src/web/pages/Impressum.tsx index 46aaef8..e2124e8 100644 --- a/src/web/pages/Impressum.tsx +++ b/src/web/pages/Impressum.tsx @@ -1,214 +1,215 @@ -import { memo, useState, useEffect } from "react"; -import { Link } from "react-router-dom"; -import { - ArrowLeft, ShieldCheck, User, MapPin, Mail, - Info, Scale, ExternalLink, Globe, ChevronRight, Gavel -} from "lucide-react"; -import { Navbar } from "../components/Navbar"; -import { Footer } from "../components/Footer"; -import { SEO } from "../components/SEO"; -import { motion } from "framer-motion"; - -const SECTIONS = [ - { id: "verantwortlich", title: "Verantwortlich", icon: User }, - { id: "anschrift", title: "Anschrift", icon: MapPin }, - { id: "kontakt", title: "Kontakt", icon: Mail }, - { id: "rechtliches", title: "Rechtliche Angaben", icon: Gavel }, - { id: "haftung-inhalte", title: "Haftung für Inhalte", icon: Info }, - { id: "haftung-links", title: "Haftung für Links", icon: ExternalLink }, - { id: "urheberrecht", title: "Urheberrecht", icon: Scale }, - { id: "hosting", title: "Hosting", icon: Globe }, - { id: "streitbeilegung", title: "Streitbeilegung", icon: ShieldCheck }, -]; - -const Section = ({ id, title, children }: { id: string; title: string; children: React.ReactNode }) => ( -
-
-
-

- {title} -

-
-
- {children} -
-
-); - -export const Impressum = memo(function Impressum() { - const [activeSection, setActiveSection] = useState("verantwortlich"); - - useEffect(() => { - const handleScroll = () => { - const sectionElements = SECTIONS.map(s => document.getElementById(s.id)); - const scrollPosition = window.scrollY + 200; - - for (let i = sectionElements.length - 1; i >= 0; i--) { - const el = sectionElements[i]; - if (el && scrollPosition >= el.offsetTop) { - setActiveSection(SECTIONS[i].id); - break; - } - } - }; - - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - - const scrollToSection = (id: string) => { - const el = document.getElementById(id); - if (el) { - window.scrollTo({ - top: el.offsetTop - 120, - behavior: "smooth" - }); - } - }; - - return ( -
- - -
- - {/* Sidebar */} - - - {/* Content Area */} -
-
- - - Legal Disclosure - -

- Impressum -

-

- Angaben gemäß § 5 DDG. ManagerX ist ein privates Open-Source-Projekt von ManagerX Development. -

-
- -
-
-
-

Lenny Steiger

-

Gründer & Projektleiter

-
-
- -
-
-

Eulauer Str. 24

-

04523 Pegau

-

Deutschland

-
-
- -
-
- -
-

Legal Support

- - legal@managerx-bot.de - -
-
-
- -
-
-

-

Projektart: Privates Open-Source-Projekt (nicht-kommerziell).

-
-
- -
-

Als Diensteanbieter sind wir gemäß § 7 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen.

-
- - - -
-

Die durch die Seitenbetreiber erstellten Inhalte unterliegen dem deutschen Urheberrecht. Der Source-Code ist unter der GPL-3.0 Lizenz frei auf GitHub verfügbar.

-
- -
-
-

Bereitgestellt via GitHub Pages:

-

GitHub Inc., 88 Colin P. Kelly Jr St, San Francisco, CA 94107, USA.

-
-
- -
-

Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/

-

Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren teilzunehmen.

-
- - {/* Final Contact UI */} -
-
- -

Support

- - contact@managerx-bot.de - -

Stand: Februar 2026 • © ManagerX Development

-
-
-
-
-
- -
-
- ); -}); - -export default Impressum; \ No newline at end of file +import { memo, useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { + ArrowLeft, ShieldCheck, User, MapPin, Mail, + Info, Scale, ExternalLink, Globe, ChevronRight, Gavel +} from "lucide-react"; +import { Navbar } from "../components/Navbar"; +import { Footer } from "../components/Footer"; +import { SEO } from "../components/SEO"; +import { motion } from "framer-motion"; +import { LEGAL_CONFIG } from "../lib/legal"; + +const SECTIONS = [ + { id: "verantwortlich", title: "Verantwortlich", icon: User }, + { id: "anschrift", title: "Anschrift", icon: MapPin }, + { id: "kontakt", title: "Kontakt", icon: Mail }, + { id: "rechtliches", title: "Rechtliche Angaben", icon: Gavel }, + { id: "haftung-inhalte", title: "Haftung für Inhalte", icon: Info }, + { id: "haftung-links", title: "Haftung für Links", icon: ExternalLink }, + { id: "urheberrecht", title: "Urheberrecht", icon: Scale }, + { id: "hosting", title: "Hosting", icon: Globe }, + { id: "streitbeilegung", title: "Streitbeilegung", icon: ShieldCheck }, +]; + +const Section = ({ id, title, children }: { id: string; title: string; children: React.ReactNode }) => ( +
+
+
+

+ {title} +

+
+
+ {children} +
+
+); + +export const Impressum = memo(function Impressum() { + const [activeSection, setActiveSection] = useState("verantwortlich"); + + useEffect(() => { + const handleScroll = () => { + const sectionElements = SECTIONS.map(s => document.getElementById(s.id)); + const scrollPosition = window.scrollY + 200; + + for (let i = sectionElements.length - 1; i >= 0; i--) { + const el = sectionElements[i]; + if (el && scrollPosition >= el.offsetTop) { + setActiveSection(SECTIONS[i].id); + break; + } + } + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + const scrollToSection = (id: string) => { + const el = document.getElementById(id); + if (el) { + window.scrollTo({ + top: el.offsetTop - 120, + behavior: "smooth" + }); + } + }; + + return ( +
+ + +
+ + {/* Sidebar */} + + + {/* Content Area */} +
+
+ + + Legal Disclosure + +

+ Impressum +

+

+ Angaben gemäß § 5 DDG. ManagerX ist ein privates Open-Source-Projekt von ManagerX Development. +

+
+ +
+
+
+

{LEGAL_CONFIG.owner.name}

+

{LEGAL_CONFIG.owner.role}

+
+
+ +
+
+

{LEGAL_CONFIG.owner.address.street}

+

{LEGAL_CONFIG.owner.address.city}

+

{LEGAL_CONFIG.owner.address.country}

+
+
+ +
+ +
+ +
+
+

+

Projektart: Privates Open-Source-Projekt (nicht-kommerziell).

+
+
+ +
+

Als Diensteanbieter sind wir gemäß § 7 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen.

+
+ + + +
+

Die durch die Seitenbetreiber erstellten Inhalte unterliegen dem deutschen Urheberrecht. Der Source-Code ist unter der GPL-3.0 Lizenz frei auf GitHub verfügbar.

+
+ +
+
+

{LEGAL_CONFIG.hosting.provider}

+

{LEGAL_CONFIG.hosting.details}

+
+
+ +
+

Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/

+

Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren teilzunehmen.

+
+ + {/* Final Contact UI */} +
+
+ +

Support

+ + {LEGAL_CONFIG.contact.email} + +

Stand: {LEGAL_CONFIG.lastUpdate} • © ManagerX Development

+
+
+
+
+
+ +
+
+ ); +}); + +export default Impressum; \ No newline at end of file diff --git a/src/web/pages/LeaderboardPage.tsx b/src/web/pages/LeaderboardPage.tsx index 57904d9..107f6b6 100644 --- a/src/web/pages/LeaderboardPage.tsx +++ b/src/web/pages/LeaderboardPage.tsx @@ -17,6 +17,8 @@ interface LeaderboardUser { voice_minutes: number; } +import { API_URL } from "../lib/api"; + export const LeaderboardPage = memo(function LeaderboardPage() { const [leaderboard, setLeaderboard] = useState([]); const [loading, setLoading] = useState(true); @@ -25,8 +27,7 @@ export const LeaderboardPage = memo(function LeaderboardPage() { useEffect(() => { const fetchLeaderboard = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:8040"; - const response = await fetch(`${baseUrl}/v1/managerx/leaderboard`); + const response = await fetch(`${API_URL}/v1/managerx/leaderboard`); if (response.ok) { const data = await response.json(); if (data.success) { diff --git a/src/web/pages/Nutzungsbedingungen.tsx b/src/web/pages/Nutzungsbedingungen.tsx index f86023d..7a3d747 100644 --- a/src/web/pages/Nutzungsbedingungen.tsx +++ b/src/web/pages/Nutzungsbedingungen.tsx @@ -9,6 +9,7 @@ import { import { Navbar } from "../components/Navbar"; import { Footer } from "../components/Footer"; import { motion } from "framer-motion"; +import { LEGAL_CONFIG } from "../lib/legal"; const SECTIONS = [ { id: "overview", title: "Overview", icon: Info }, @@ -110,7 +111,7 @@ export const Nutzungsbedingungen = memo(function Nutzungsbedingungen() {

Need help?

Unser Team steht für rechtliche Fragen zur Verfügung.

- + Contact Legal
@@ -131,18 +132,18 @@ export const Nutzungsbedingungen = memo(function Nutzungsbedingungen() {

Nutzungsbedingungen

-

+

Bitte lesen Sie diese Bedingungen sorgfältig durch, bevor Sie ManagerX nutzen. Sie regeln die rechtliche Beziehung zwischen Ihnen und ManagerX Development. -

+

- March 28, 2026 + {LEGAL_CONFIG.lastUpdate}
Version - 2.0.0-beta + {LEGAL_CONFIG.version}-beta
@@ -242,7 +243,7 @@ export const Nutzungsbedingungen = memo(function Nutzungsbedingungen() {
-

Wird "WIE BESEHEN" bereitgestellt.

+

Wird "WIE BESEHEN" bereitgestellt.

Wir übernehmen keine Garantie für die ständige Verfügbarkeit (Uptime), die absolute Richtigkeit von Statistiken oder die vollständige Fehlerfreiheit des Codes.

@@ -262,8 +263,8 @@ export const Nutzungsbedingungen = memo(function Nutzungsbedingungen() {

Bei rechtlichen Anfragen erreichen Sie uns unter:

diff --git a/src/web/pages/Status.tsx b/src/web/pages/Status.tsx index 098032d..e51910d 100644 --- a/src/web/pages/Status.tsx +++ b/src/web/pages/Status.tsx @@ -8,6 +8,10 @@ import { Navbar } from "../components/Navbar"; import { Footer } from "../components/Footer"; import { SEO } from "../components/SEO"; import { motion } from "framer-motion"; +import { LEGAL_CONFIG } from "../lib/legal"; + +import { API_URL } from "../lib/api"; + const Status = memo(function Status() { // State für die Live-Daten vom Bot const [data, setData] = useState({ @@ -24,8 +28,7 @@ const Status = memo(function Status() { useEffect(() => { const fetchStatus = async () => { try { - const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:8040"; - const response = await fetch(`${baseUrl}/v1/managerx/stats`); + const response = await fetch(`${API_URL}/v1/managerx/stats`); if (!response.ok) throw new Error("Offline"); const result = await response.json(); @@ -182,7 +185,7 @@ const Status = memo(function Status() {

Überprüfung erfolgt alle 15 Sekunden via FastAPI-Endpoint.