diff --git a/.gitignore b/.gitignore index 00ec715..e1feb86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ .vscode/ data/ !src/web/data/ - +build/ # Python __pycache__/ *.pyc diff --git a/config/example.env b/config/example.env index 4f91c77..807a404 100644 --- a/config/example.env +++ b/config/example.env @@ -1,19 +1,23 @@ +# DISCORD BOT TOKEN=abc123 WEATHER_API=abc123 ERROR_WEBHOOK=https://discord.com/api/webhooks +# DISCORD OAUTH2 DISCORD_CLIENT_ID=12345678900 DISCORD_CLIENT_SECRET=abc123 DISCORD_REDIRECT_URI=https:// +# INVITE URL URL=https://discord.com/oauth2/authorize? -DASHBOARD_API_KEYS=abc123 - +# WEBPAGE JWT_SECRET=abc123 DASHBOARD_URL=https://my-super-discord-bot.com VITE_API_URL=https://api.my-super-discord-bot.com +DASHBOARD_API_KEYS=abc123 +# TOPGG TOPGG_TOKEN=abc123 # DATABASE @@ -22,3 +26,7 @@ DB_PORT=3306 DB_USER=managerx DB_PASSWORD=abc123 DB_NAME=managerx +DB_DATABASE=managerx +# CMS +CMS_ADMIN_EMAIL=name.name@gmail.com +CMS_ADMIN_PASSWORD=123 diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d0c3cbf..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index dc1312a..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css deleted file mode 100644 index 1aea4b3..0000000 --- a/docs/source/_static/custom.css +++ /dev/null @@ -1,1064 +0,0 @@ -/* ========================================================================== - MANAGERX PREMIUM DOCS THEME - PyData Sphinx Theme - Optimized & Refined - ========================================================================== */ - -:root { - /* ManagerX Premium Color System */ - --mx-red-primary: #dc2626; - --mx-red-dark: #991b1b; - --mx-red-darker: #7f1d1d; - --mx-red-light: #fef2f2; - --mx-red-accent: #f87171; - --mx-red-glow: rgba(220, 38, 38, 0.1); - - /* Neutral Palette */ - --mx-gray-50: #f8fafc; - --mx-gray-100: #f1f5f9; - --mx-gray-200: #e2e8f0; - --mx-gray-300: #cbd5e1; - --mx-gray-400: #94a3b8; - --mx-gray-500: #64748b; - --mx-gray-600: #475569; - --mx-gray-700: #334155; - --mx-gray-800: #1e293b; - --mx-gray-900: #0f172a; - - /* Semantic Colors */ - --mx-success: #059669; - --mx-warning: #d97706; - --mx-danger: #dc2626; - --mx-info: #0284c7; - - /* Typography System */ - --pst-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --pst-font-family-heading: 'Space Grotesk', 'Inter', sans-serif; - --pst-font-family-monospace: 'JetBrains Mono', 'Consolas', 'Monaco', monospace; - - /* PyData Theme Overrides */ - --pst-color-primary: var(--mx-red-primary); - --pst-color-secondary: var(--mx-gray-600); - --pst-color-link: var(--mx-red-primary); - --pst-color-link-hover: var(--mx-red-dark); - --pst-color-target: #fbbf24; - - /* Shadows & Effects */ - --mx-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); - --mx-shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06); - --mx-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); - --mx-shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.1); - --mx-shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.12); - - /* Transitions */ - --mx-transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); - --mx-transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1); - --mx-transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); - - /* Border Radius */ - --mx-radius-sm: 6px; - --mx-radius-md: 10px; - --mx-radius-lg: 14px; - --mx-radius-xl: 20px; - --mx-radius-full: 9999px; -} - -/* ========================================================================== - 1. GLOBAL FOUNDATION & TYPOGRAPHY - ========================================================================== */ - -body { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - letter-spacing: -0.011em; - line-height: 1.65; -} - -/* Premium Scrollbar Design */ -::-webkit-scrollbar { - width: 14px; - height: 14px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--mx-gray-300); - border-radius: var(--mx-radius-full); - border: 3px solid var(--mx-gray-50); - transition: background var(--mx-transition-base); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--mx-red-primary); -} - -[data-theme="dark"] ::-webkit-scrollbar-thumb { - background: var(--mx-gray-600); - border-color: var(--mx-gray-900); -} - -[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { - background: var(--mx-red-accent); -} - -/* Improved Typography Hierarchy */ -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: var(--pst-font-family-heading); - font-weight: 700; - letter-spacing: -0.025em; - color: var(--mx-gray-900); - line-height: 1.25; -} - -[data-theme="dark"] h1, -[data-theme="dark"] h2, -[data-theme="dark"] h3, -[data-theme="dark"] h4 { - color: var(--mx-gray-50); -} - -h1 { - font-size: 2.5rem; - margin-top: 0 !important; - margin-bottom: 2rem !important; -} - -h2 { - font-size: 2rem; - margin-top: 3rem !important; - margin-bottom: 1.5rem !important; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--mx-gray-100); -} - -[data-theme="dark"] h2 { - border-bottom-color: var(--mx-gray-700); -} - -h3 { - font-size: 1.5rem; - margin-top: 2rem !important; - margin-bottom: 1rem !important; -} - -h4 { - font-size: 1.25rem; - margin-top: 1.5rem !important; - margin-bottom: 0.75rem !important; -} - -/* Subtle Section Indicators */ -h2::before { - content: ""; - display: inline-block; - width: 4px; - height: 1.5rem; - background: linear-gradient(180deg, var(--mx-red-primary), var(--mx-red-accent)); - margin-right: 1rem; - border-radius: var(--mx-radius-sm); - vertical-align: middle; -} - -/* ========================================================================== - 2. HEADER & NAVIGATION - ========================================================================== */ - -/* Premium Glassmorphic Header */ -.bd-header { - background: rgba(255, 255, 255, 0.85) !important; - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border-bottom: 1px solid rgba(220, 38, 38, 0.1) !important; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(220, 38, 38, 0.03); - transition: all var(--mx-transition-base); -} - -[data-theme="dark"] .bd-header { - background: rgba(15, 23, 42, 0.9) !important; - border-bottom-color: rgba(220, 38, 38, 0.2) !important; -} - -/* Logo & Brand Styling */ -.navbar-brand { - font-family: var(--pst-font-family-heading); - font-weight: 700; - font-size: 1.25rem; - letter-spacing: -0.02em; - transition: transform var(--mx-transition-fast); -} - -.navbar-brand:hover { - transform: translateX(2px); -} - -/* Mobile Toggle */ -.bd-header .navbar-toggler { - border: 2px solid var(--mx-red-primary); - border-radius: var(--mx-radius-md); - color: var(--mx-red-primary); - transition: all var(--mx-transition-base); -} - -.bd-header .navbar-toggler:hover { - background: var(--mx-red-light); - transform: scale(1.05); -} - -/* ========================================================================== - 3. SIDEBAR NAVIGATION - ========================================================================== */ - -/* Primary Sidebar Styling */ -.bd-sidebar-primary { - border-right: 1px solid var(--mx-gray-100); -} - -[data-theme="dark"] .bd-sidebar-primary { - border-right-color: var(--mx-gray-700); -} - -/* Section Headers */ -.bd-sidebar-primary .caption-text { - color: var(--mx-red-dark); - font-family: var(--pst-font-family-heading); - font-weight: 700; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.1em; - margin-top: 1.5rem; - margin-bottom: 0.75rem; - padding-left: 1rem; -} - -/* Navigation Links */ -.bd-sidebar-primary .nav-link { - border-radius: var(--mx-radius-md); - margin: 2px 0; - padding: 0.5rem 1rem; - transition: all var(--mx-transition-base); - font-size: 0.9rem; -} - -.bd-sidebar-primary .nav-link:hover { - background: var(--mx-gray-50); - color: var(--mx-red-primary); - transform: translateX(4px); -} - -[data-theme="dark"] .bd-sidebar-primary .nav-link:hover { - background: var(--mx-gray-800); -} - -/* Active Navigation Item */ -.bd-sidebar-primary .nav-item.current>a, -.bd-sidebar-primary .nav-item.active>a { - background: linear-gradient(90deg, var(--mx-red-light) 0%, transparent 100%); - color: var(--mx-red-primary) !important; - font-weight: 600; - border-left: 3px solid var(--mx-red-primary); - padding-left: calc(1rem - 3px); -} - -[data-theme="dark"] .bd-sidebar-primary .nav-item.current>a { - background: linear-gradient(90deg, rgba(220, 38, 38, 0.15) 0%, transparent 100%); -} - -/* Nested Navigation */ -.bd-sidebar-primary .nav-item .nav-item .nav-link { - font-size: 0.85rem; - padding-left: 2rem; -} - -/* ========================================================================== - 4. MAIN CONTENT AREA - ========================================================================== */ - -/* Content Container */ -.bd-main { - padding-top: 2rem; - padding-bottom: 4rem; -} - -article.bd-article { - max-width: 850px; -} - -/* Paragraph Spacing */ -article p { - margin-bottom: 1.25rem; - line-height: 1.75; -} - -/* Target Highlighting */ -:target { - scroll-margin-top: 120px; - animation: highlight-pulse 2s ease-out; -} - -@keyframes highlight-pulse { - - 0%, - 50% { - background-color: var(--mx-red-glow); - box-shadow: 0 0 0 8px var(--mx-red-glow); - } - - 100% { - background-color: transparent; - box-shadow: 0 0 0 0 transparent; - } -} - -/* ========================================================================== - 5. ADMONITIONS & CALLOUTS - ========================================================================== */ - -/* Base Admonition Styling */ -.admonition { - border: none !important; - border-left: 4px solid var(--mx-red-primary) !important; - border-radius: var(--mx-radius-lg) !important; - background: var(--mx-gray-50) !important; - box-shadow: var(--mx-shadow-sm) !important; - padding: 1.5rem !important; - margin: 2rem 0 !important; - transition: all var(--mx-transition-base); -} - -.admonition:hover { - box-shadow: var(--mx-shadow-md) !important; - transform: translateY(-2px); -} - -[data-theme="dark"] .admonition { - background: var(--mx-gray-800) !important; -} - -/* Admonition Title */ -.admonition-title { - background: transparent !important; - color: var(--mx-red-primary) !important; - font-family: var(--pst-font-family-heading) !important; - font-weight: 700 !important; - font-size: 0.875rem !important; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.75rem !important; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.admonition-title::before { - content: "●"; - font-size: 0.6em; -} - -/* Admonition Variants */ -.admonition.note { - border-left-color: var(--mx-info) !important; -} - -.admonition.note .admonition-title { - color: var(--mx-info) !important; -} - -.admonition.warning { - border-left-color: var(--mx-warning) !important; -} - -.admonition.warning .admonition-title { - color: var(--mx-warning) !important; -} - -.admonition.danger, -.admonition.error { - border-left-color: var(--mx-danger) !important; -} - -.admonition.danger .admonition-title, -.admonition.error .admonition-title { - color: var(--mx-danger) !important; -} - -.admonition.tip, -.admonition.hint { - border-left-color: var(--mx-success) !important; -} - -.admonition.tip .admonition-title, -.admonition.hint .admonition-title { - color: var(--mx-success) !important; -} - -/* ========================================================================== - 6. CODE BLOCKS & SYNTAX HIGHLIGHTING - ========================================================================== */ - -/* Code Block Container */ -div.highlight { - border: 1px solid var(--mx-gray-200) !important; - border-radius: var(--mx-radius-lg) !important; - background: var(--mx-gray-50) !important; - box-shadow: var(--mx-shadow-sm); - padding: 0 !important; - margin: 1.5rem 0; - overflow: hidden; - transition: all var(--mx-transition-base); -} - -div.highlight:hover { - box-shadow: var(--mx-shadow-md); -} - -[data-theme="dark"] div.highlight { - background: var(--mx-gray-900) !important; - border-color: var(--mx-gray-700) !important; -} - -/* Code Block Pre */ -div.highlight pre { - padding: 1.25rem !important; - margin: 0 !important; - overflow-x: auto; - font-size: 0.875rem; - line-height: 1.6; - background: transparent !important; -} - -/* Inline Code */ -code.literal { - background: var(--mx-red-light); - color: var(--mx-red-dark); - padding: 0.15em 0.4em; - border-radius: var(--mx-radius-sm); - font-size: 0.875em; - font-weight: 500; - border: 1px solid rgba(220, 38, 38, 0.1); -} - -[data-theme="dark"] code.literal { - background: rgba(220, 38, 38, 0.15); - color: var(--mx-red-accent); - border-color: rgba(220, 38, 38, 0.2); -} - -/* Keyboard Shortcuts */ -kbd { - background: linear-gradient(180deg, var(--mx-gray-50), var(--mx-gray-100)); - border: 1px solid var(--mx-gray-300); - border-radius: var(--mx-radius-sm); - box-shadow: 0 2px 0 var(--mx-gray-300), inset 0 1px 0 rgba(255, 255, 255, 0.8); - color: var(--mx-gray-700); - font-family: var(--pst-font-family-monospace); - font-size: 0.85em; - padding: 0.2em 0.5em; - font-weight: 500; -} - -[data-theme="dark"] kbd { - background: linear-gradient(180deg, var(--mx-gray-700), var(--mx-gray-800)); - border-color: var(--mx-gray-600); - box-shadow: 0 2px 0 var(--mx-gray-600); - color: var(--mx-gray-200); -} - -/* Syntax Highlighting Customization */ -.highlight .k, -.highlight .kd { - color: var(--mx-red-primary); - font-weight: 600; -} - -.highlight .nc, -.highlight .nn { - color: var(--mx-red-dark); - font-weight: 600; -} - -.highlight .s, -.highlight .s2, -.highlight .s1 { - color: #059669; -} - -.highlight .c1, -.highlight .cm { - color: var(--mx-gray-400); - font-style: italic; -} - -.highlight .nf { - color: #0284c7; -} - -.highlight .mi, -.highlight .mf { - color: #7c3aed; -} - -/* ========================================================================== - 7. TABLES - ========================================================================== */ - -table.docutils { - width: 100%; - border-collapse: separate !important; - border-spacing: 0; - border-radius: var(--mx-radius-lg); - overflow: hidden; - border: 1px solid var(--mx-gray-200) !important; - margin: 2rem 0; - box-shadow: var(--mx-shadow-sm); -} - -[data-theme="dark"] table.docutils { - border-color: var(--mx-gray-700) !important; -} - -/* Table Header */ -table.docutils thead { - background: linear-gradient(180deg, var(--mx-red-primary), var(--mx-red-dark)); -} - -table.docutils thead th { - color: white !important; - font-family: var(--pst-font-family-heading); - font-weight: 600 !important; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 1rem !important; - border: none !important; - text-align: left; -} - -/* Table Body */ -table.docutils tbody tr { - transition: background-color var(--mx-transition-fast); -} - -table.docutils tbody tr:hover { - background-color: var(--mx-gray-50); -} - -[data-theme="dark"] table.docutils tbody tr:hover { - background-color: var(--mx-gray-800); -} - -table.docutils tbody td { - padding: 0.875rem 1rem !important; - border-bottom: 1px solid var(--mx-gray-100) !important; - font-size: 0.9rem; -} - -[data-theme="dark"] table.docutils tbody td { - border-bottom-color: var(--mx-gray-700) !important; -} - -table.docutils tbody tr:last-child td { - border-bottom: none !important; -} - -/* ========================================================================== - 8. LINKS & REFERENCES - ========================================================================== */ - -/* Internal & External Links */ -article a.reference.internal, -article a.reference.external { - color: var(--mx-red-primary); - text-decoration: none; - font-weight: 500; - position: relative; - transition: color var(--mx-transition-fast); -} - -article a.reference:hover { - color: var(--mx-red-dark); -} - -/* Animated Underline */ -article a.reference::after { - content: ''; - position: absolute; - width: 100%; - height: 2px; - bottom: -2px; - left: 0; - background: linear-gradient(90deg, var(--mx-red-primary), var(--mx-red-accent)); - transform: scaleX(0); - transform-origin: bottom right; - transition: transform var(--mx-transition-base); - border-radius: var(--mx-radius-full); -} - -article a.reference:hover::after { - transform: scaleX(1); - transform-origin: bottom left; -} - -/* External Link Icon */ -article a.reference.external::before { - content: "↗"; - font-size: 0.7em; - margin-left: 0.2em; - opacity: 0.6; -} - -/* ========================================================================== - 9. IMAGES & FIGURES - ========================================================================== */ - -/* Figure Container */ -figure.align-default, -figure.align-center { - margin: 3rem auto; - padding: 1.5rem; - background: white; - border-radius: var(--mx-radius-xl); - box-shadow: var(--mx-shadow-md); - text-align: center; - transition: all var(--mx-transition-base); -} - -figure.align-default:hover, -figure.align-center:hover { - box-shadow: var(--mx-shadow-lg); - transform: translateY(-4px); -} - -[data-theme="dark"] figure.align-default, -[data-theme="dark"] figure.align-center { - background: var(--mx-gray-800); -} - -/* Figure Image */ -figure.align-default img, -figure.align-center img { - border-radius: var(--mx-radius-md); - max-width: 100%; - height: auto; -} - -/* Figure Caption */ -figcaption, -.caption-text { - margin-top: 1rem; - font-size: 0.875rem; - color: var(--mx-gray-500); - font-style: italic; - line-height: 1.5; -} - -/* ========================================================================== - 10. API DOCUMENTATION (Autodoc) - ========================================================================== */ - -/* Function/Class/Method Containers */ -dl.py.function, -dl.py.class, -dl.py.method, -dl.py.attribute { - background: var(--mx-gray-50); - border: 1px solid var(--mx-gray-200); - border-left: 4px solid var(--mx-red-primary); - border-radius: var(--mx-radius-lg); - padding: 1.5rem; - margin: 2rem 0; - box-shadow: var(--mx-shadow-sm); - transition: all var(--mx-transition-base); -} - -dl.py.function:hover, -dl.py.class:hover, -dl.py.method:hover { - box-shadow: var(--mx-shadow-md); -} - -[data-theme="dark"] dl.py.function, -[data-theme="dark"] dl.py.class, -[data-theme="dark"] dl.py.method { - background: var(--mx-gray-800); - border-color: var(--mx-gray-700); -} - -/* Signature */ -dt.sig { - font-family: var(--pst-font-family-monospace); - font-size: 1rem; - color: var(--mx-red-primary); - background: rgba(220, 38, 38, 0.05); - padding: 0.75rem; - border-radius: var(--mx-radius-md); - margin-bottom: 1rem; - font-weight: 500; -} - -/* Parameters */ -.sig-param { - color: var(--mx-gray-700); -} - -[data-theme="dark"] .sig-param { - color: var(--mx-gray-300); -} - -/* Return Type */ -.sig-return { - color: var(--mx-success); -} - -/* ========================================================================== - 11. SEARCH - ========================================================================== */ - -/* Search Input */ -.bd-search .form-control { - border-radius: var(--mx-radius-full) !important; - border: 2px solid var(--mx-gray-200) !important; - padding: 0.625rem 1.25rem !important; - transition: all var(--mx-transition-base); - font-size: 0.9rem; -} - -.bd-search .form-control:focus { - border-color: var(--mx-red-primary) !important; - box-shadow: 0 0 0 4px var(--mx-red-glow) !important; - outline: none; -} - -[data-theme="dark"] .bd-search .form-control { - border-color: var(--mx-gray-700) !important; - background: var(--mx-gray-800) !important; -} - -/* Search Keyboard Shortcut */ -.search-button__kbd-shortcut { - background: var(--mx-red-light) !important; - color: var(--mx-red-primary) !important; - border: 1px solid rgba(220, 38, 38, 0.2) !important; - border-radius: var(--mx-radius-sm) !important; - font-weight: 600; -} - -/* ========================================================================== - 12. PAGINATION & NAVIGATION - ========================================================================== */ - -/* Previous/Next Navigation */ -.prev-next-area { - margin-top: 4rem; - padding-top: 2rem; - border-top: 2px solid var(--mx-gray-100); -} - -[data-theme="dark"] .prev-next-area { - border-top-color: var(--mx-gray-700); -} - -.prev-next-area a { - border: 1px solid var(--mx-gray-200) !important; - border-radius: var(--mx-radius-lg) !important; - padding: 1.5rem !important; - transition: all var(--mx-transition-base) !important; - background: white; -} - -[data-theme="dark"] .prev-next-area a { - background: var(--mx-gray-800); - border-color: var(--mx-gray-700) !important; -} - -.prev-next-area a:hover { - border-color: var(--mx-red-primary) !important; - box-shadow: 0 8px 24px var(--mx-red-glow) !important; - transform: translateY(-4px); -} - -.prev-next-area .prev-next-title { - color: var(--mx-red-primary) !important; - font-family: var(--pst-font-family-heading); - font-weight: 600; - font-size: 1.125rem; -} - -/* ========================================================================== - 13. BUTTONS - ========================================================================== */ - -/* Primary Buttons */ -.btn-primary { - background: linear-gradient(135deg, var(--mx-red-primary), var(--mx-red-dark)) !important; - border: none !important; - color: white !important; - font-family: var(--pst-font-family-heading); - font-weight: 600; - padding: 0.625rem 1.5rem; - border-radius: var(--mx-radius-md); - transition: all var(--mx-transition-base); - box-shadow: var(--mx-shadow-sm); -} - -.btn-primary:hover, -.btn-primary:focus { - transform: translateY(-2px); - box-shadow: 0 6px 20px var(--mx-red-glow) !important; -} - -.btn-primary:active { - transform: translateY(0); -} - -/* Outline Buttons */ -.btn-outline-primary { - color: var(--mx-red-primary) !important; - border: 2px solid var(--mx-red-primary) !important; - background: transparent !important; - font-weight: 600; - border-radius: var(--mx-radius-md); - transition: all var(--mx-transition-base); -} - -.btn-outline-primary:hover { - background: var(--mx-red-primary) !important; - color: white !important; - transform: translateY(-2px); - box-shadow: 0 6px 20px var(--mx-red-glow); -} - -/* ========================================================================== - 14. FOOTER - ========================================================================== */ - -.bd-footer { - margin-top: 6rem; - padding: 3rem 0 2rem; - border-top: 2px solid var(--mx-gray-100); - font-size: 0.875rem; -} - -[data-theme="dark"] .bd-footer { - border-top-color: var(--mx-gray-700); -} - -.footer-items__end { - color: var(--mx-gray-500); -} - -.footer-items__end strong { - color: var(--mx-red-primary); - font-weight: 600; -} - -/* ========================================================================== - 15. CUSTOM COMPONENTS - ========================================================================== */ - -/* Hero Section */ -.mx-hero { - text-align: center; - padding: 5rem 2rem; - background: radial-gradient(circle at center, - var(--mx-red-glow) 0%, - transparent 70%); - border-radius: var(--mx-radius-xl); - margin: 3rem 0; -} - -/* Grid Layout Helper */ -.mx-grid-2 { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1.5rem; - margin: 2rem 0; -} - -@media (max-width: 768px) { - .mx-grid-2 { - grid-template-columns: 1fr; - } -} - -/* Card Component */ -.mx-box { - padding: 2rem; - border-radius: var(--mx-radius-lg); - background: white; - border: 1px solid var(--mx-gray-200); - box-shadow: var(--mx-shadow-sm); - transition: all var(--mx-transition-base); -} - -.mx-box:hover { - box-shadow: var(--mx-shadow-md); - transform: translateY(-4px); -} - -[data-theme="dark"] .mx-box { - background: var(--mx-gray-800); - border-color: var(--mx-gray-700); -} - -/* Accent Border */ -.mx-box-accent { - border-bottom: 4px solid var(--mx-red-primary); -} - -/* ========================================================================== - 16. VERSION SWITCHER & DROPDOWNS - ========================================================================== */ - -.bd-version-switcher__button { - border-radius: var(--mx-radius-md) !important; - border: 1px solid var(--mx-gray-200) !important; - transition: all var(--mx-transition-base); -} - -[data-theme="dark"] .bd-version-switcher__button { - border-color: var(--mx-gray-700) !important; -} - -.bd-version-switcher__button:hover { - background: var(--mx-red-light) !important; - color: var(--mx-red-primary) !important; - border-color: var(--mx-red-primary) !important; -} - -/* ========================================================================== - 17. ANNOUNCEMENT BANNER - ========================================================================== */ - -.bd-header-announcement { - background: linear-gradient(135deg, var(--mx-red-dark), var(--mx-red-primary)) !important; - color: white !important; - font-weight: 600; - padding: 0.75rem; - text-align: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -/* ========================================================================== - 18. RESPONSIVE DESIGN - ========================================================================== */ - -@media (max-width: 992px) { - h1 { - font-size: 2rem; - } - - h2 { - font-size: 1.65rem; - } - - h3 { - font-size: 1.35rem; - } - - .bd-main { - padding-top: 1rem; - } - - .mx-hero { - padding: 3rem 1.5rem; - } -} - -@media (max-width: 768px) { - h1 { - font-size: 1.75rem; - } - - h2 { - font-size: 1.5rem; - } - - .admonition { - padding: 1.25rem !important; - } - - table.docutils { - font-size: 0.85rem; - } - - .prev-next-area a { - padding: 1.25rem !important; - } -} - -/* ========================================================================== - 19. PRINT STYLES - ========================================================================== */ - -@media print { - - .bd-header, - .bd-sidebar-primary, - .bd-sidebar-secondary, - .bd-footer, - .prev-next-area { - display: none !important; - } - - article { - max-width: 100% !important; - } - - .admonition { - page-break-inside: avoid; - } -} - -/* ========================================================================== - 20. ACCESSIBILITY IMPROVEMENTS - ========================================================================== */ - -/* Focus Visible States */ -a:focus-visible, -button:focus-visible, -input:focus-visible { - outline: 3px solid var(--mx-red-primary); - outline-offset: 2px; - border-radius: var(--mx-radius-sm); -} - -/* Skip to Content Link */ -.skip-link { - position: absolute; - top: -40px; - left: 0; - background: var(--mx-red-primary); - color: white; - padding: 0.5rem 1rem; - text-decoration: none; - border-radius: 0 0 var(--mx-radius-md) 0; - z-index: 100; -} - -.skip-link:focus { - top: 0; -} - -/* Reduced Motion */ -@media (prefers-reduced-motion: reduce) { - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} - -/* ========================================================================== - END OF MANAGERX PREMIUM DOCS THEME - ========================================================================== */ \ No newline at end of file diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico deleted file mode 100644 index 950a896..0000000 Binary files a/docs/source/_static/favicon.ico and /dev/null differ diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 0f231d8..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,84 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -import os -import sys - -sys.path.insert(0, os.path.abspath('../src')) -sys.setrecursionlimit(2500) - -# -- Project information ----------------------------------------------------- -project = 'ManagerX' -copyright = '2026 ManagerX Development' -author = 'ManagerX Development' -release = '2.0.0' -version = '2.0' # Kurzversion -language = 'de' - -# -- General configuration --------------------------------------------------- -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', - 'sphinx.ext.intersphinx', - 'sphinx.ext.viewcode', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.githubpages', - 'sphinx.ext.autosummary', - 'sphinx_autodoc_typehints', - 'myst_parser', - 'sphinx_copybutton', -] - -autosummary_generate = True -todo_include_todos = True - -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# Intersphinx Mapping (für Cross-Referenzen) -intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), - 'requests': ('https://docs.python-requests.org/en/latest/', None), -} - -# Markdown Einstellungen -myst_enable_extensions = [ - "colon_fence", - "deflist", - "html_admonition", - "html_image", -] - -# -- Options for HTML output ------------------------------------------------- -html_theme = 'pydata_sphinx_theme' -html_static_path = ['_static'] -html_css_files = [ - 'custom.css', -] -html_favicon = '_static/favicon.ico' -html_theme_options = { - "icon_links": [ - { - "name": "GitHub", - "url": "https://github.com/ManagerX-Development/ManagerX", - "icon": "fa-brands fa-square-github", - "type": "fontawesome", - }, - { - "name": "Website", - "url": "https://managerx-bot.de", - "icon": "fa-solid fa-globe", - "type": "fontawesome", - }, - { - "name": "Instagram", - "url": "https://www.instagram.com/managerx_development", - "icon": "fa-brands fa-instagram", - "type": "fontawesome", - } - ], - -} \ No newline at end of file diff --git a/docs/source/dev_guide/api/getting_started.rst b/docs/source/dev_guide/api/getting_started.rst deleted file mode 100644 index 2b92ac7..0000000 --- a/docs/source/dev_guide/api/getting_started.rst +++ /dev/null @@ -1,8 +0,0 @@ -Getting Start with the API! -========================================================== - -Getting Start with the API is easy. You can use the API to get information about the bot and its features. You can also use the API to get information about the bot's features. You can also use the API to get information about the bot's features. - -first read this: - -`→ Open Publics Endpoints `_ \ No newline at end of file diff --git a/docs/source/dev_guide/api/publics_endpoints.rst b/docs/source/dev_guide/api/publics_endpoints.rst deleted file mode 100644 index f994e6d..0000000 --- a/docs/source/dev_guide/api/publics_endpoints.rst +++ /dev/null @@ -1,15 +0,0 @@ -Publics Endpoints -========================================================== - -All Publics Endpoints of ManagerX. - -+------------+------------+------------+--------------------------------------+ -| Endpoint | Method | Description | -+============+============+============+======================================+ -| /v1/managerx/stats | GET | Returns the stats of the bot | -+------------+------------+------------+--------------------------------------+ -| /v1/managerx/leaderboard| GET | Returns the leaderboard of the bot | -+------------+------------+------------+--------------------------------------+ -| /v1/managerx/version | GET | Returns the version of the bot | -+------------+------------+------------+--------------------------------------+ - diff --git a/docs/source/dev_guide/architecture.rst b/docs/source/dev_guide/architecture.rst deleted file mode 100644 index 131f60a..0000000 --- a/docs/source/dev_guide/architecture.rst +++ /dev/null @@ -1,43 +0,0 @@ -========================== -🏗️ System Architecture -========================== - -ManagerX is built with a modern, decoupled architecture to ensure scalability and ease of development. - -1. High-Level Overview -====================== - -The system consists of three main components: - -- **Discord Bot (The Core):** Written in Python using `py-cord`. It handles all interactions with Discord servers, manages the local/MariaDB database, and executes commands. -- **REST API (The Bridge):** A FastAPI server integrated directly into the bot process. It provides live data (Uptime, Stats, Guild settings) to the web dashboard. -- **Web Dashboard (The Interface):** A React-based Single Page Application (SPA) that communicates with the API to provide a visual configuration interface. - -2. Component Breakdown -====================== - -Bot Core --------- -- **Cogs (Plugins):** Features are modularly organized in ``src/bot/cogs``. This allows for easy hot-reloading and independent development of features. -- **Database Layer:** Supports both SQLite (for local dev) and MariaDB (for production). -- **EzCord:** A framework wrapper that simplifies UI components (Embeds, Buttons) and provides automatic logging. - -API (FastAPI) -------------- -- **Real-time Data:** Uses the bot's internal loop to fetch live shard status and server metrics. -- **Authentication:** Uses Discord OAuth2 to verify user identity and permissions. - -Frontend (React) ----------------- -- **Framework:** Vite for fast builds and HMR. -- **Styling:** Tailwind CSS with a "Glassmorphism" design system. -- **Components:** Radix UI for accessible primitives and Framer Motion for smooth animations. - -3. Execution Flow -================= - -1. **User Action:** A user clicks "Save" on the Dashboard. -2. **Frontend:** Sends a POST request to the API with the new configuration. -3. **API:** Validates the authentication token and role permissions. -4. **Bot:** Updates the internal database and applies changes (e.g., updating a welcome message or clearing the warning list). -5. **Discord:** The next time a member joins, the bot retrieves the updated data from the database and executes the new logic. diff --git a/docs/source/dev_guide/index.rst b/docs/source/dev_guide/index.rst deleted file mode 100644 index f7982bd..0000000 --- a/docs/source/dev_guide/index.rst +++ /dev/null @@ -1,24 +0,0 @@ -=========================== -👨‍💻 Developer Guide -=========================== - -Welcome to the development section of ManagerX. This guide is intended for developers who want to self-host the bot, extend its functionality, or contribute to the core codebase. - -.. toctree:: - :maxdepth: 2 - :caption: Content: - - architecture - installation - api/index - contributing/index - ---- - -💡 Core Technology Stack -======================== - -- **Backend:** Python 3.11+ using `py-cord` and `ezcord`. -- **API:** FastAPI with Uvicorn. -- **Frontend:** React + TypeScript + Vite + Tailwind CSS. -- **Database:** MariaDB (Recommended) or SQLite. \ No newline at end of file diff --git a/docs/source/dev_guide/installation.rst b/docs/source/dev_guide/installation.rst deleted file mode 100644 index 2c2c795..0000000 --- a/docs/source/dev_guide/installation.rst +++ /dev/null @@ -1,79 +0,0 @@ -========================= -💻 Installation Guide -========================= - -This guide explains how to set up a self-hosted instance of ManagerX. - -Prerequisites -============= - -- **Python:** 3.11 or higher. -- **Node.js:** v18 or higher (for the frontend). -- **Database:** MariaDB (Recommended) or SQLite. -- **Discord Developer Account:** To create your bot application. - -1. Clone the Repository -======================= - -.. code-block:: bash - - git clone https://github.com/ManagerX-Development/ManagerX.git - cd ManagerX - -2. Backend Setup -================ - -1. Create a virtual environment: - - .. code-block:: bash - - python -m venv .venv - source .venv/bin/activate # Windows: .venv\Scripts\activate - -2. Install dependencies: - - .. code-block:: bash - - pip install -r requirements/base.txt - -3. Configure environment variables: - Copy ``config/.env.example`` to ``config/.env`` and fill in your: - - ``TOKEN`` (Discord Bot Token) - - ``DB_TYPE`` (mariadb or sqlite) - - ``DB_HOST``, ``DB_USER``, etc. - -3. Frontend Setup -================= - -1. Install Node dependencies: - - .. code-block:: bash - - npm install - -2. Build the production bundle: - - .. code-block:: bash - - npm run build - -4. Starting the Bot -=================== - -Run the main entry point: - -.. code-block:: bash - - python main.py - -The bot will start, and the FastAPI webserver for the dashboard will run on the configured port (default: 8000). - ---- - -🚀 Production Deployment -======================== - -For production, we recommend using: -- **PM2** or **Systemd** to keep the bot process alive. -- **Nginx** or **Traefik** as a reverse proxy for the API and static frontend files. -- **MariaDB** for reliable data storage. diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index beced82..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,91 +0,0 @@ -======================================== -🤖 ManagerX Dokumentation -======================================== - -**Die ultimative Management-Lösung für deinen Discord-Server** - -Leistungsstark. Modular. Open Source. - -.. image:: https://img.shields.io/badge/Version-2.0.0-e11d48?style=for-the-badge - :alt: Version 2.0.0 - :target: https://github.com/ManagerX-Development/ManagerX/releases - -.. image:: https://img.shields.io/badge/Python-3.11+-green.svg?style=for-the-badge - :alt: Python 3.11+ - -.. image:: https://img.shields.io/badge/Lizenz-GPL--3.0-yellow.svg?style=for-the-badge - :alt: GPL-3.0 Lizenz - :target: https://github.com/ManagerX-Development/ManagerX/blob/main/LICENSE - -ManagerX ist ein umfassender Discord-Bot, der für modernes Server-Management entwickelt wurde. Mit fortschrittlicher Moderation, Entertainment-Funktionen, detaillierten Statistiken und einem modernen Web-Dashboard bringt ManagerX deinen Server auf das nächste Level. - ---- - -📖 Schnellzugriff -================ - -ManagerX ist in zwei Hauptbereiche unterteilt: - -**👥 Benutzer-Handbuch** ------------------------- - -Alles, was du wissen musst, um ManagerX auf deinem Server zu nutzen: - -- Erste Schritte & Installation -- Befehlsübersicht -- Feature-Erklärungen -- Dashboard-Konfiguration - -`→ Zum Benutzer-Handbuch `_ - -**👨‍💻 Entwickler-Dokumentation** ----------------------------------- - -Für Entwickler, die ManagerX erweitern oder selbst hosten möchten: - -- Architektur-Übersicht -- Installation & Setup -- API-Referenz -- Mitwirken am Projekt - -`→ Zur Entwickler-Dokumentation `_ - ---- - -🎮 Kern-Features -================ - -- 🛡️ **Moderation** — Anti-Spam, Warnungen und automatische Bestrafungen. -- 📊 **Detaillierte Statistiken** — Level-System, Aktivitäten und Leaderboards. -- ⚙️ **Einfache Verwaltung** — Ein modernes Web-Dashboard für alle Einstellungen. -- 🌍 **Global Chat** — Vernetze deinen Server mit anderen Communities. - ---- - -🆘 Hilfe & Support -================= - -Solltest du Fragen haben oder auf Probleme stoßen: - -* **Support-Server:** Tritt unserem `Discord-Server `_ bei. -* **Bug melden:** Erstelle ein Issue auf `GitHub `_. -* **FAQ:** Schau in unsere :doc:`user_guide/index` Sektion. - ---- - -**© 2026 ManagerX Development** -*Version 2.0.0 | Letztes Update: April 2026* - -.. toctree:: - :maxdepth: 2 - :hidden: - :caption: 👥 Benutzer-Handbuch: - - user_guide/index - -.. toctree:: - :maxdepth: 2 - :hidden: - :caption: 👨‍💻 Entwickler-Dokumentation: - - dev_guide/index \ No newline at end of file diff --git a/docs/source/user_guide/dashboard.rst b/docs/source/user_guide/dashboard.rst deleted file mode 100644 index 695b90c..0000000 --- a/docs/source/user_guide/dashboard.rst +++ /dev/null @@ -1,39 +0,0 @@ -======================= -🖥️ Web Dashboard -======================= - -The ManagerX Web Dashboard is your central control panel for everything bot-related. It provides a sleek, modern interface to manage your server without typing complex commands. - -1. Access & Login -================= - -Visit `dashboard.managerx-bot.de `_ and log in via your Discord account. We only request basic identification and the "Guilds" permission to show you the servers you own. - -2. Server Selection -=================== - -On the main page, you will see all servers where you have **Manage Server** permissions. - -- **Manage:** If ManagerX is already on the server, click this to enter the settings. -- **Invite:** If the bot is missing, use this button to add it. - -3. Module Management -==================== - -The dashboard is divided into several modules: - -- **Sidebar:** Navigate between Overview, General Settings, and detailed feature tabs. -- **Save Changes:** Most changes are applied in real-time, but some "Global" settings require hitting the "Save" button at the bottom of the page. - -4. Advanced Features -==================== - -- **Stats Overview:** View live graphs of your server's growth and activity. -- **Permissions Bridge:** The dashboard respects your Discord role hierarchy. Only members with the "ManagerX Team Role" (configurable) can access sensitive server settings. -- **Responsive Design:** You can manage your server just as easily from your mobile phone as from your desktop. - -💡 Common Issues -================ - -- **Server not showing?** Make sure you have the "Manage Server" permission and try logging out and back in. -- **Settings not saving?** Ensure the bot has the "Administrator" permission or high enough roles to modify the requested settings. diff --git a/docs/source/user_guide/getting_started.rst b/docs/source/user_guide/getting_started.rst deleted file mode 100644 index ef9b1f1..0000000 --- a/docs/source/user_guide/getting_started.rst +++ /dev/null @@ -1,54 +0,0 @@ -======================= -🚀 Getting Started -======================= - -Setting up ManagerX is designed to be as simple as possible. Follow this guide to get your bot up and running in minutes. - -1. Invite the Bot -================ - -First, you need to invite ManagerX to your Discord server. - -- Use the official `Invite Link `_. -- Ensure you have **Manage Server** permissions on the target server. -- Leave the requested permissions checked to ensure all modules (like Moderation and Auto-Role) work correctly. - -2. Run the Setup Command -======================== - -Once the bot joins, it will automatically try to initialize. You can also manually trigger the setup: - -.. code-block:: none - - /setup - -This command will: -- Check for required permissions. -- Initialize the local server database. -- Create a default configuration. - -3. Access the Dashboard -======================= - -Most of ManagerX's features are configured through our web interface. - -1. Visit `ManagerX Dashboard `_. -2. Log in with your Discord account. -3. Select your server from the list. -4. Start configuring modules like **Anti-Spam**, **Welcome Messages**, and **Levels**. - -4. Core Commands -================ - -Here are a few commands to get you started: - -- ``/help`` - Shows all available command categories. -- ``/settings`` - Quick access to server configuration. -- ``/stats`` - View your current server's activity. - -Next Steps -========== - -- **Setup Moderation:** Head over to the :doc:`moderation` guide. -- **Configure Levels:** Check the :doc:`levels` section to engage your members. -- **Customization:** Learn how to change the bot's look in :doc:`dashboard`. diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst deleted file mode 100644 index dd3d592..0000000 --- a/docs/source/user_guide/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -===================== -👥 User Guide -===================== - -Welcome to the ManagerX User Guide! This section covers everything you need to know about using ManagerX on your server, from the initial invitation to advanced configuration. - -.. toctree:: - :maxdepth: 2 - :caption: Content: - - getting_started - moderation - levels - management - dashboard - faq/index - ---- - -🚀 Quick Links -============== - -- **Getting Started:** New here? Start with the :doc:`getting_started` guide. -- **Moderation:** Learn how to keep your server safe in the :doc:`moderation` section. -- **Engagement:** Discover the :doc:`levels` and rewards system. -- **Settings:** Use the :doc:`dashboard` to configure every detail. - -💡 Need Help? -============== - -If you don't find what you're looking for, feel free to join our `Support Discord `_ or check our :doc:`faq/index`. diff --git a/docs/source/user_guide/levels.rst b/docs/source/user_guide/levels.rst deleted file mode 100644 index 3519fd6..0000000 --- a/docs/source/user_guide/levels.rst +++ /dev/null @@ -1,44 +0,0 @@ -======================= -📈 Levels & Engagement -======================= - -The ManagerX Level System rewards your members for being active in your community. It encourages high-quality discussions and long-term engagement. - -1. How it works -=============== - -Members earn **Experience Points (XP)** for various activities: - -- **Text Messages:** Random amount of XP (typically 15-25) per message sent. -- **Voice Chat:** XP earned per minute spent in a voice channel (active members only, muted/deafened users earn less). -- **Cooldown:** There is a 60-second cooldown between XP gains from messages to prevent spamming. - -2. Levels and Roles -=================== - -As members accumulate XP, they reach higher levels. You can configure **Role Rewards** that are automatically granted upon reaching specific levels. - -- **Example:** Level 10 -> "Active Member" role, Level 50 -> "Server Veteran" role. -- **Stacking:** Choose whether users keep their previous reward roles or only hold the highest one. - -3. Commands -=========== - -- ``/rank [user]`` - View your current level, total XP, and progress to the next level. -- ``/leaderboard`` - See the top active members of the server. -- ``/profile`` - View your customizable user card. - -4. Configuration -================ - -Visit the **Dashboard -> Levels** tab to: - -- **Toggle Modules:** Turn the entire level system on or off. -- **Set Multipliers:** Grant extra XP in specific channels or for specific roles (e.g., Boosters). -- **Manage Roles:** Add, edit, or remove role rewards. -- **Level Up Messages:** Choose where level-up notifications are sent (Current channel, Private DM, or a dedicated channel). - -💡 Tip -====== - -You can use "XP-Excluded Channels" to prevent users from farming levels in bot-command or spam channels. diff --git a/docs/source/user_guide/management.rst b/docs/source/user_guide/management.rst deleted file mode 100644 index 852626c..0000000 --- a/docs/source/user_guide/management.rst +++ /dev/null @@ -1,55 +0,0 @@ -======================== -⚙️ Server Management -======================== - -ManagerX automates repetitive server tasks so you can focus on building your community. - -1. Welcome & Goodbye -==================== - -First impressions matter! Use the Welcome module to greet new members. - -- **Custom Messages:** Use placeholders like ``{user}``, ``{server}``, and ``{member_count}``. -- **Direct Messages:** Send a private greeting to new members. -- **Auto-Roles:** Automatically grant roles to users when they join (e.g., a "Member" role). -- **Delayed Roles:** Grant roles after a set time to bypass Discord's "Member Screening". - -**Configuration:** -Dashboard -> **Welcome** & **Auto-Role** tabs. - -2. Global Chat Network -====================== - -Connect your server's channels to other communities across the ManagerX network. - -- **Cross-Server Sync:** Messages sent in your designated channel appear on all other connected servers. -- **Smart Filtering:** Anti-spam and bad-word filters apply globally. -- **Staff Support:** ManagerX staff monitors the global chat to ensure safety. - -**Setup:** -1. Choose a channel. -2. Go to **Dashboard -> Global Chat**. -3. Enable sync for that channel. - -3. Temporary Voice Channels (Temp-VC) -===================================== - -Allow your members to create their own private voice channels on demand. - -- **Join-to-Create:** When a user joins a specific "Generator" channel, a new private channel is created for them. -- **Ownership:** The creator can rename the channel and set member limits. -- **Auto-Cleanup:** The channel is automatically deleted once the last person leaves. - -**Setup:** -Go to **Dashboard -> Temp-VC** and designate your generator category/channel. - -4. Auto-Delete -============== - -Keep your channels clean by automatically deleting messages after a certain time. - -- **Use Cases:** Commands channels, media-only channels, or temporary announcements. -- **Filters:** Delete only bot messages, only images, or all messages. - -**Setup:** -Dashboard -> **Auto-Delete**. diff --git a/docs/source/user_guide/moderation.rst b/docs/source/user_guide/moderation.rst deleted file mode 100644 index a891e3e..0000000 --- a/docs/source/user_guide/moderation.rst +++ /dev/null @@ -1,60 +0,0 @@ -======================= -🛡️ Moderation -======================= - -ManagerX provides a suite of advanced moderation tools designed to keep your community safe with minimal manual effort. - -1. Anti-Spam System -=================== - -Our intelligent anti-spam system monitors messages in real-time to detect and prevent disruptive behavior. - -- **Fast Message Detection:** Detects users sending too many messages in a short interval. -- **Link Filter:** Blocks unauthorized links and protects against phishing. -- **Caps Filter:** Automatically warns or deletes messages with excessive uppercase letters. -- **Mention Spam:** Prevents users from mass-tagging members or roles. - -**Configuration:** -Enabled/Disabled via the **Dashboard -> Anti-Spam** tab. - -2. Warning System -================= - -ManagerX uses a tiered warning system to handle rule-breakers fairly. - -- ``/warn `` - Issue a formal warning. -- ``/warnings `` - View a user's warning history. -- ``/clearwarn `` - Remove warnings. - -**Auto-Mod Actions:** -You can configure "Warn Thresholds" in the Dashboard. For example: -- **3 Warnings:** 1-hour Timeout. -- **5 Warnings:** Temporary Ban (24h). -- **10 Warnings:** Permanent Ban. - -3. Triage Tools (Kicks & Bans) -============================== - -Classic moderation commands with a focus on speed and logging: - -- ``/ban [reason] [delete_messages_days]`` -- ``/kick [reason]`` -- ``/timeout [reason]`` -- ``/unban `` - -4. Audit Logging -================ - -The logging system tracks every significant event on your server to ensure accountability. - -**Monitored Events:** -- Message edits and deletions. -- Member joins and leaves. -- Role changes. -- Voice channel activity. -- Moderation actions (Warns, Bans, etc.). - -**How to Setup Logs:** -1. Create a private channel for logs. -2. Go to **Dashboard -> Logging**. -3. Select your channel and choose which events to track. diff --git a/main.py b/main.py index 44a4699..2236a9a 100644 --- a/main.py +++ b/main.py @@ -49,7 +49,10 @@ config = config_loader.load() # API Routes & Translation -from src.api.dashboard.routes import set_bot_instance, dashboard_main_router, router_public +from src.api.dashboard.routes import set_bot_instance, dashboard_main_router +from src.api.public.routes import router as public_router +from src.api.admin.routes import router as admin_router +from fastapi import APIRouter from mxmariadb import TranslationHandler, BlacklistDatabase colorama_init(autoreset=True) @@ -88,9 +91,13 @@ allow_headers=["*"], ) -# Dashboard-Routes einbinden -app.include_router(dashboard_main_router) -app.include_router(router_public) +# API v1 Router erstellen und Sub-Router einbinden +api_v1 = APIRouter(prefix="/v1") +api_v1.include_router(dashboard_main_router) # /v1/dashboard/... +api_v1.include_router(public_router) # /v1/public/... +api_v1.include_router(admin_router) # /v1/admin/... + +app.include_router(api_v1) # CMS Media Uploads als statische Dateien bereitstellen _uploads_dir = BASEDIR / "public" / "uploads" / "cms" diff --git a/mxmariadb/cms_db.py b/mxmariadb/cms_db.py index 5893e7a..80fd620 100644 --- a/mxmariadb/cms_db.py +++ b/mxmariadb/cms_db.py @@ -72,8 +72,19 @@ async def init_db(self): uploader_id BIGINT NOT NULL, uploader_name VARCHAR(100), is_stock BOOLEAN DEFAULT FALSE, + folder VARCHAR(100) DEFAULT 'general', uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX(uploader_id) + INDEX(uploader_id), + INDEX(folder) + ) + """) + + # Media Folders table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_media_folders ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) @@ -82,13 +93,24 @@ async def init_db(self): "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS excerpt TEXT NULL", "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS cover_image VARCHAR(500) NULL", "ALTER TABLE cms_posts ADD COLUMN IF NOT EXISTS view_count INT DEFAULT 0", - "ALTER TABLE cms_media ADD COLUMN IF NOT EXISTS is_stock BOOLEAN DEFAULT FALSE" + "ALTER TABLE cms_media ADD COLUMN IF NOT EXISTS is_stock BOOLEAN DEFAULT FALSE", + "ALTER TABLE cms_media ADD COLUMN IF NOT EXISTS folder VARCHAR(100) DEFAULT 'general'" ]: try: await cur.execute(col_def) except Exception: - pass # Column already exists or unsupported syntax + pass + # Bot Performance & Growth Stats + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bot_daily_stats ( + date DATE PRIMARY KEY, + guild_count INT, + user_count INT, + command_count INT, + avg_latency FLOAT + ) + """) # Revision history table await cur.execute(""" CREATE TABLE IF NOT EXISTS cms_revisions ( @@ -313,18 +335,18 @@ async def get_revision_by_id(self, revision_id: int): # ───────────────────────────────────────── async def create_media(self, filename: str, original_name: str, mime_type: str, - size_bytes: int, uploader_id: int, uploader_name: str, is_stock: bool = False): + size_bytes: int, uploader_id: int, uploader_name: str, is_stock: bool = False, folder: str = 'general'): async with self.pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT INTO cms_media - (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock)) + (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock, folder) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (filename, original_name, mime_type, size_bytes, uploader_id, uploader_name, is_stock, folder)) await conn.commit() return True - async def get_media(self, limit: int = 100, is_stock: bool = None): + async def get_media(self, limit: int = 100, is_stock: bool = None, folder: str = None): async with self.pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: query = "SELECT * FROM cms_media" @@ -333,6 +355,10 @@ async def get_media(self, limit: int = 100, is_stock: bool = None): if is_stock is not None: query += " WHERE is_stock = %s" params.append(is_stock) + + if folder: + query += (" AND " if "WHERE" in query else " WHERE ") + "folder = %s" + params.append(folder) query += " ORDER BY uploaded_at DESC LIMIT %s" params.append(limit) @@ -340,10 +366,15 @@ async def get_media(self, limit: int = 100, is_stock: bool = None): await cur.execute(query, tuple(params)) return await cur.fetchall() - async def update_media(self, media_id: int, is_stock: bool): + async def update_media(self, media_id: int, **kwargs): + if not kwargs: return False async with self.pool.acquire() as conn: async with conn.cursor() as cur: - await cur.execute("UPDATE cms_media SET is_stock = %s WHERE id = %s", (is_stock, media_id)) + fields = [f"{k} = %s" for k in kwargs.keys()] + params = list(kwargs.values()) + params.append(media_id) + query = f"UPDATE cms_media SET {', '.join(fields)} WHERE id = %s" + await cur.execute(query, tuple(params)) await conn.commit() return True @@ -357,6 +388,29 @@ async def delete_media(self, media_id: int): await conn.commit() return row["filename"] if row else None + async def get_folders(self): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_media_folders ORDER BY name ASC") + return await cur.fetchall() + + async def create_folder(self, name: str): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("INSERT IGNORE INTO cms_media_folders (name) VALUES (%s)", (name,)) + await conn.commit() + return True + + async def delete_folder(self, name: str): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + # Ordner löschen + await cur.execute("DELETE FROM cms_media_folders WHERE name = %s", (name,)) + # Bilder im Ordner zurück auf 'general' setzen + await cur.execute("UPDATE cms_media SET folder = 'general' WHERE folder = %s", (name,)) + await conn.commit() + return True + # ───────────────────────────────────────── # CHANGELOG # ───────────────────────────────────────── @@ -602,3 +656,48 @@ async def delete_feedback(self, feedback_id: int): await conn.commit() return True + # ───────────────────────────────────────── + # PERFORMANCE & GROWTH + # ───────────────────────────────────────── + + async def log_daily_stats(self, guild_count: int, user_count: int, command_count: int, avg_latency: float): + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + # Safety: Ensure table exists + await cur.execute(""" + CREATE TABLE IF NOT EXISTS bot_daily_stats ( + date DATE PRIMARY KEY, + guild_count INT, + user_count INT, + command_count INT, + avg_latency FLOAT + ) + """) + + await cur.execute(""" + INSERT INTO bot_daily_stats (date, guild_count, user_count, command_count, avg_latency) + VALUES (CURDATE(), %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + guild_count = %s, + user_count = %s, + command_count = %s, + avg_latency = %s + """, (guild_count, user_count, command_count, avg_latency, + guild_count, user_count, command_count, avg_latency)) + await conn.commit() + + async def get_historical_stats(self, days: int = 30): + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT + DATE_FORMAT(date, '%%Y-%%m-%%d') as date, + guild_count, + user_count, + command_count, + avg_latency + FROM bot_daily_stats + ORDER BY date ASC LIMIT %s + """, (days,)) + return await cur.fetchall() + diff --git a/mxmariadb/stats_db.py b/mxmariadb/stats_db.py index 1908aa1..892fd14 100644 --- a/mxmariadb/stats_db.py +++ b/mxmariadb/stats_db.py @@ -530,5 +530,74 @@ async def get_weekly_stats(self, guild_id: int) -> list: logger.error(f"get_weekly_stats fehlgeschlagen: {e}") return [] + async def get_active_voice_count(self) -> 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 COUNT(*) FROM active_voice_sessions') + row = await cur.fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"get_active_voice_count fehlgeschlagen: {e}") + return 0 + + async def clear_active_voice_sessions(self): + 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('DELETE FROM active_voice_sessions') + await conn.commit() + except Exception as e: + logger.error(f"clear_active_voice_sessions fehlgeschlagen: {e}") + + async def get_dashboard_analytics(self) -> dict: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + # 1. Interactions in last 24 hours + await cur.execute(''' + SELECT COUNT(*) FROM command_usage + WHERE used_at > DATE_SUB(NOW(), INTERVAL 1 DAY) + ''') + interactions_24h = (await cur.fetchone())[0] or 0 + + # 2. Unique active servers in last 24 hours + await cur.execute(''' + SELECT COUNT(DISTINCT guild_id) FROM command_usage + WHERE used_at > DATE_SUB(NOW(), INTERVAL 1 DAY) + ''') + active_servers_24h = (await cur.fetchone())[0] or 0 + + # 3. Top active server in last 24 hours + await cur.execute(''' + SELECT guild_id, COUNT(*) as count + FROM command_usage + WHERE used_at > DATE_SUB(NOW(), INTERVAL 1 DAY) + GROUP BY guild_id + ORDER BY count DESC + LIMIT 1 + ''') + top_guild_row = await cur.fetchone() + top_guild_id = top_guild_row[0] if top_guild_row else None + + return { + "interactions_24h": interactions_24h, + "active_servers_24h": active_servers_24h, + "top_guild_id": top_guild_id + } + except Exception as e: + logger.error(f"get_dashboard_analytics fehlgeschlagen: {e}") + return { + "interactions_24h": 0, + "active_servers_24h": 0, + "top_guild_id": None + } + def close(self): logger.info("StatsDB.close() aufgerufen (Pool wird über MariaConnector.close() geschlossen).") \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6c3a0b9..9daa8f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,20 +50,22 @@ "react-day-picker": "10.0.1", "react-dom": "19.2.6", "react-helmet-async": "3.0.0", - "react-hook-form": "7.76.0", - "react-markdown": "10.1.0", - "react-resizable-panels": "4.11.1", - "react-router-dom": "7.15.1", - "recharts": "3.8.1", - "rehype-autolink-headings": "7.1.0", - "rehype-highlight": "7.0.2", - "rehype-katex": "7.0.1", - "rehype-raw": "7.0.0", - "rehype-slug": "6.0.0", - "remark-gfm": "4.0.1", - "remark-math": "6.0.0", - "remark-toc": "9.0.0", - "shiki": "4.1.0", + + "react-hook-form": "7.71.2", + "react-is": "^19.2.6", + "react-markdown": "^10.1.0", + "react-resizable-panels": "4.7.2", + "react-router-dom": "7.13.1", + "recharts": "^3.8.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-toc": "^9.0.0", + "shiki": "^4.0.2", "sonner": "2.0.7", "tailwind-merge": "3.6.0", "tailwindcss-animate": "1.0.7", @@ -7813,6 +7815,12 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 0bfa87f..058884a 100644 --- a/package.json +++ b/package.json @@ -55,20 +55,21 @@ "react-day-picker": "10.0.1", "react-dom": "19.2.6", "react-helmet-async": "3.0.0", - "react-hook-form": "7.76.0", - "react-markdown": "10.1.0", - "react-resizable-panels": "4.11.1", - "react-router-dom": "7.15.1", - "recharts": "3.8.1", - "rehype-autolink-headings": "7.1.0", - "rehype-highlight": "7.0.2", - "rehype-katex": "7.0.1", - "rehype-raw": "7.0.0", - "rehype-slug": "6.0.0", - "remark-gfm": "4.0.1", - "remark-math": "6.0.0", - "remark-toc": "9.0.0", - "shiki": "4.1.0", + "react-hook-form": "7.71.2", + "react-is": "^19.2.6", + "react-markdown": "^10.1.0", + "react-resizable-panels": "4.7.2", + "react-router-dom": "7.13.1", + "recharts": "^3.8.1", + "rehype-autolink-headings": "^7.1.0", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "remark-toc": "^9.0.0", + "shiki": "^4.0.2", "sonner": "2.0.7", "tailwind-merge": "3.6.0", "tailwindcss-animate": "1.0.7", diff --git a/requirements/docs_req.txt b/requirements/docs_req.txt index c593d1e..e69de29 100644 --- a/requirements/docs_req.txt +++ b/requirements/docs_req.txt @@ -1,7 +0,0 @@ -# You need to install Sphinx with `pip install sphinx` and these extensions to build the docs -pydata-sphinx-theme # for a modern documentation theme -sphinx-autodoc-typehints # for better type hinting support -myst-parser # for Markdown support -sphinx-copybutton # adds copy buttons to code blocks -sphinx-autobuild # optional: live preview during development - # (remove this before pushing; ReadTheDocs doesn't need it) \ No newline at end of file diff --git a/src/api/admin/routes.py b/src/api/admin/routes.py new file mode 100644 index 0000000..4ae64fe --- /dev/null +++ b/src/api/admin/routes.py @@ -0,0 +1,206 @@ +from fastapi import APIRouter, HTTPException, Depends +from src.api.dashboard.dependencies import get_current_user, get_bot +from src.api.dashboard.schemas import BlacklistAddRequest +from src.api.dashboard.cms.utils import is_admin +from src.bot.core.config import BotConfig +import discord +import psutil +import os + +router = APIRouter( + prefix="/admin", + tags=["admin"] +) + + + +@router.get("/global-stats") +async def get_admin_global_stats(user: dict = Depends(get_current_user)): + """Fetches global bot stats and CMS stats for the admin dashboard.""" + bot = get_bot() + if bot is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + # Simple check for admin + is_bot_admin = user.get("id") == "cms_admin" + if not is_bot_admin: + try: + owners = getattr(BotConfig.security, 'bot_owners', []) + if int(user.get("id", 0)) in owners: + is_bot_admin = True + except: pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + try: + from mxmariadb import CMSDatabase + db = CMSDatabase() + await db.ensure_connection() + posts = await db.get_posts(published_only=False) + + return { + "success": True, + "data": { + "totalGuilds": len(bot.guilds), + "totalUsers": len(bot.users), + "totalPosts": len(posts), + "apiLatency": f"{round(bot.latency * 1000)}ms", + "uptime": str(discord.utils.utcnow() - getattr(bot, 'start_time', discord.utils.utcnow())).split('.')[0] + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/blacklist") +async def get_admin_blacklist(user: dict = Depends(get_current_user)): + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + data = await db.get_all_blacklisted() + return {"success": True, "data": data} + +@router.post("/blacklist") +async def add_admin_blacklist(data: BlacklistAddRequest, user: dict = Depends(get_current_user)): + target_id = data.user_id + reason = data.reason + if not target_id: + raise HTTPException(status_code=400, detail="Target User ID is required") + + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.add_to_blacklist(target_id, reason, user["id"], user.get("username", "Admin")) + return {"success": success} + +@router.delete("/blacklist/{target_id}") +async def remove_admin_blacklist(target_id: str, user: dict = Depends(get_current_user)): + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.remove_from_blacklist(target_id) + return {"success": True} + +@router.get("/global-chat/logs") +async def get_global_chat_logs(user: dict = Depends(get_current_user)): + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + query = "SELECT * FROM message_log ORDER BY timestamp DESC LIMIT 50" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@router.get("/global-chat/blacklist") +async def get_global_chat_blacklist(user: dict = Depends(get_current_user)): + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + query = "SELECT * FROM globalchat_blacklist ORDER BY banned_at DESC" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@router.get("/top-commands") +async def get_admin_top_commands(user: dict = Depends(get_current_user)): + from mxmariadb import StatsDB + db = StatsDB() + await db.ensure_connection() + data = await db.get_top_commands(limit=5) + return {"success": True, "data": data} + +@router.get("/performance/live") +async def get_performance_live(user: dict = Depends(get_current_user)): + """Admin: Get real-time performance data (CPU, RAM, Latency, Active Voice).""" + # Simple check for admin + is_bot_admin = user.get("id") == "cms_admin" + try: + owners = getattr(BotConfig.security, 'bot_owners', []) + if int(user.get("id", 0)) in owners: + is_bot_admin = True + except: pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + bot = get_bot() + process = psutil.Process(os.getpid()) + + active_voice = 0 + try: + from mxmariadb import StatsDB + db = StatsDB() + await db.ensure_connection() + active_voice = await db.get_active_voice_count() + except Exception as e: + print(f"Error getting active voice count: {e}") + + return { + "success": True, + "data": { + "cpu": psutil.cpu_percent(interval=None), + "ram": process.memory_info().rss / (1024 * 1024), # MB + "latency": round(bot.latency * 1000) if bot else 0, + "activeVoice": active_voice, + "timestamp": discord.utils.utcnow().isoformat() + } + } + +@router.get("/performance/analytics") +async def get_performance_analytics(user: dict = Depends(get_current_user)): + """Admin: Get deep dashboard analytics (Top commands, interactions, active servers, top active server).""" + is_bot_admin = user.get("id") == "cms_admin" + try: + owners = getattr(BotConfig.security, 'bot_owners', []) + if int(user.get("id", 0)) in owners: + is_bot_admin = True + except: pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + bot = get_bot() + + try: + from mxmariadb import StatsDB + db = StatsDB() + await db.ensure_connection() + + # 1. Fetch top 5 commands + top_commands = await db.get_top_commands(limit=5) + + # 2. Fetch 24h dashboard statistics (interactions, active servers, top active guild_id) + db_stats = await db.get_dashboard_analytics() + + # Map top guild ID to actual guild name + top_guild_name = "Keine Aktivität" + top_guild_id = db_stats.get("top_guild_id") + if top_guild_id and bot: + try: + guild = bot.get_guild(int(top_guild_id)) + if guild: + top_guild_name = guild.name + else: + top_guild_name = f"Server ID {top_guild_id}" + except Exception as e: + top_guild_name = f"Server ID {top_guild_id}" + + return { + "success": True, + "data": { + "top_commands": top_commands, + "interactions_24h": db_stats.get("interactions_24h", 0), + "active_servers_24h": db_stats.get("active_servers_24h", 0), + "top_guild": top_guild_name + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/performance/history") +async def get_performance_history(days: int = 7, user: dict = Depends(get_current_user)): + """Admin: Get historical growth data.""" + # Admin check omitted for brevity but should be there in prod + from mxmariadb import CMSDatabase + db = CMSDatabase() + await db.ensure_connection() + data = await db.get_historical_stats(days=days) + return {"success": True, "data": data} diff --git a/src/api/dashboard/admin_routes.py b/src/api/dashboard/admin_routes.py deleted file mode 100644 index 12d0d59..0000000 --- a/src/api/dashboard/admin_routes.py +++ /dev/null @@ -1,109 +0,0 @@ -from fastapi import APIRouter, Request, HTTPException, Depends -from src.api.dashboard.auth_routes import get_current_user -from .cms.utils import is_admin -from src.bot.core.config import BotConfig -import discord - -router = APIRouter( - prefix="/admin", - tags=["admin"] -) - -# Shared bot instance access (imported from .routes) -def get_bot(): - from .routes import bot_instance - return bot_instance - -@router.get("/global-stats") -async def get_admin_global_stats(user: dict = Depends(get_current_user)): - """Fetches global bot stats and CMS stats for the admin dashboard.""" - bot = get_bot() - if bot is None: - raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") - - # Simple check for admin - is_bot_admin = user.get("id") == "cms_admin" - if not is_bot_admin: - try: - owners = getattr(BotConfig.security, 'bot_owners', []) - if int(user.get("id", 0)) in owners: - is_bot_admin = True - except: pass - - if not is_bot_admin: - raise HTTPException(status_code=403, detail="Not authorized") - - try: - from mxmariadb import CMSDatabase - db = CMSDatabase() - await db.ensure_connection() - posts = await db.get_posts(published_only=False) - - return { - "success": True, - "data": { - "totalGuilds": len(bot.guilds), - "totalUsers": len(bot.users), - "totalPosts": len(posts), - "apiLatency": f"{round(bot.latency * 1000)}ms", - "uptime": str(discord.utils.utcnow() - getattr(bot, 'start_time', discord.utils.utcnow())).split('.')[0] - } - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/blacklist") -async def get_admin_blacklist(user: dict = Depends(get_current_user)): - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - data = await db.get_all_blacklisted() - return {"success": True, "data": data} - -@router.post("/blacklist") -async def add_admin_blacklist(request: Request, user: dict = Depends(get_current_user)): - data = await request.json() - target_id = data.get("user_id") - reason = data.get("reason", "Kein Grund angegeben") - if not target_id: - raise HTTPException(status_code=400, detail="Target User ID is required") - - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - success = await db.add_to_blacklist(target_id, reason, user["id"], user.get("username", "Admin")) - return {"success": success} - -@router.delete("/blacklist/{target_id}") -async def remove_admin_blacklist(target_id: str, user: dict = Depends(get_current_user)): - from mxmariadb import BlacklistDatabase - db = BlacklistDatabase() - await db.ensure_connection() - success = await db.remove_from_blacklist(target_id) - return {"success": True} - -@router.get("/global-chat/logs") -async def get_global_chat_logs(user: dict = Depends(get_current_user)): - from mxmariadb import GlobalChatDatabase - db = GlobalChatDatabase() - await db.ensure_connection() - query = "SELECT * FROM message_log ORDER BY timestamp DESC LIMIT 50" - data = await db.fetch_all(query) - return {"success": True, "data": data} - -@router.get("/global-chat/blacklist") -async def get_global_chat_blacklist(user: dict = Depends(get_current_user)): - from mxmariadb import GlobalChatDatabase - db = GlobalChatDatabase() - await db.ensure_connection() - query = "SELECT * FROM globalchat_blacklist ORDER BY banned_at DESC" - data = await db.fetch_all(query) - return {"success": True, "data": data} - -@router.get("/top-commands") -async def get_admin_top_commands(user: dict = Depends(get_current_user)): - from mxmariadb import StatsDB - db = StatsDB() - await db.ensure_connection() - data = await db.get_top_commands(limit=5) - return {"success": True, "data": data} diff --git a/src/api/dashboard/auth_routes.py b/src/api/dashboard/auth_routes.py index 938ef3f..f4a87f3 100644 --- a/src/api/dashboard/auth_routes.py +++ b/src/api/dashboard/auth_routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Request, HTTPException, Security, status, Depends +from fastapi import APIRouter, Request, Response, HTTPException, Depends from fastapi.responses import RedirectResponse import httpx import jwt @@ -6,20 +6,15 @@ import time from urllib.parse import urlencode -from pydantic import BaseModel +from .schemas import EmailLoginRequest, CallbackRequest +from .dependencies import get_current_user, get_bot, JWT_SECRET, ALGORITHM router = APIRouter( prefix="/auth", tags=["auth"] ) -class EmailLoginRequest(BaseModel): - email: str - password: str - # JWT Setup -JWT_SECRET = os.getenv("JWT_SECRET", "fallback-secret") -ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days # Discord OAuth Setup @@ -28,9 +23,6 @@ class EmailLoginRequest(BaseModel): REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI", "http://localhost:8080/dash/auth/callback") DASHBOARD_URL = os.getenv("DASHBOARD_URL", "http://localhost:8080") -# We import bot_instance dynamically or keep a local ref if passed -# Removed top level import to prevent circular import - def create_access_token(data: dict): to_encode = data.copy() expire = time.time() + (ACCESS_TOKEN_EXPIRE_MINUTES * 60) @@ -38,29 +30,9 @@ def create_access_token(data: dict): encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM) return encoded_jwt -def get_current_user(request: Request): - """Dependency to get the current user from the Authorization header.""" - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Not authenticated") - - token = auth_header.split(" ")[1] - - try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) - user_id: str = payload.get("sub") - if user_id is None: - raise HTTPException(status_code=401, detail="Invalid token") - return {"id": user_id, "username": payload.get("username", ""), "avatar": payload.get("avatar", "")} - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token has expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Invalid token") - @router.get("/login") async def login(): """Generates the Discord OAuth2 Authorization URL and redirects the user.""" - # We want to respond to the dashboard frontend, passing the code back to the frontend. params = { "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, @@ -76,7 +48,7 @@ async def login(): login_attempts = {} # {ip: {"count": 0, "last_attempt": 0}} @router.post("/login/email") -async def login_email(request: Request, data: EmailLoginRequest): +async def login_email(request: Request, data: EmailLoginRequest, response: Response): """CMS Admin Login using Email and Password with Brute Force protection.""" client_ip = request.client.host now = time.time() @@ -84,14 +56,12 @@ async def login_email(request: Request, data: EmailLoginRequest): # 1. Check Rate Limit if client_ip in login_attempts: attempt_data = login_attempts[client_ip] - # If more than 5 failed attempts in the last 15 minutes if attempt_data["count"] >= 5 and (now - attempt_data["last_attempt"]) < 900: wait_time = int(900 - (now - attempt_data["last_attempt"])) raise HTTPException( status_code=429, detail=f"Zu viele Fehlversuche. Bitte warte {wait_time // 60} Minuten." ) - # Reset if the last attempt was long ago if (now - attempt_data["last_attempt"]) > 900: login_attempts[client_ip] = {"count": 0, "last_attempt": now} @@ -99,42 +69,49 @@ async def login_email(request: Request, data: EmailLoginRequest): admin_pass = os.getenv("CMS_ADMIN_PASSWORD") if data.email == admin_email and data.password == admin_pass: - # Success: Clear attempts if client_ip in login_attempts: del login_attempts[client_ip] - # Generate JWT for the admin jwt_token = create_access_token({ "sub": "cms_admin", "username": "Lenny (CMS Admin)", "avatar": "https://cdn.discordapp.com/embed/avatars/0.png" }) - # 4. Security Alert: Notify owners via Discord try: - from src.api.dashboard.routes import bot_instance + bot = get_bot() from src.bot.core.config import BotConfig owners = getattr(BotConfig.security, 'bot_owners', []) - if bot_instance: + if bot: alert_msg = ( - "⚠️ **Sicherheits-Alarm: Admin-Login** ⚠️\n\n" - f"Ein Login in die Admin-Zentrale wurde soeben durchgeführt.\n" + "⚠️ **Sicherheits-Alarm: Admin-Login** ⚠️\n\n" + f"Ein Login in die Admin-Zentrale wurde soeben durchgeführt.\n" f"**E-Mail:** `{data.email}`\n" f"**IP-Adresse:** `{client_ip}`\n" f"**Zeitpunkt:** \n\n" - "Falls du das nicht warst, ändere sofort dein Passwort in der `.env`!" + "Falls du das nicht warst, ändere sofort dein Passwort in der `.env`!" ) for owner_id in owners: - owner = bot_instance.get_user(int(owner_id)) + owner = bot.get_user(int(owner_id)) if owner: await owner.send(alert_msg) except Exception as e: print(f"[ERROR] Failed to send security alert: {e}") + secure = request.url.scheme == "https" + response.set_cookie( + key="access_token", + value=jwt_token, + httponly=True, + samesite="lax", + secure=secure, + max_age=60 * 24 * 7 * 60, + path="/" + ) + return { "access_token": jwt_token, - "token_type": "bearer", "user": { "id": "cms_admin", "username": "Lenny (CMS Admin)", @@ -143,29 +120,22 @@ async def login_email(request: Request, data: EmailLoginRequest): } } - # 2. Failure Logic - # Update attempts if client_ip not in login_attempts: login_attempts[client_ip] = {"count": 0, "last_attempt": now} login_attempts[client_ip]["count"] += 1 login_attempts[client_ip]["last_attempt"] = now - # 3. Synthetic Delay (Brakes for Bots) time.sleep(1.5) - - raise HTTPException(status_code=401, detail="Ungültige E-Mail oder Passwort") + raise HTTPException(status_code=401, detail="Ungültige E-Mail oder Passwort") @router.post("/callback") -async def callback(request: Request): +async def callback(data: CallbackRequest, request: Request, response: Response): """Exchanges code for a token and creates a JWT session.""" - data = await request.json() - code = data.get("code") - + code = data.code if not code: raise HTTPException(status_code=400, detail="No code provided") - # Exchange code for token async with httpx.AsyncClient() as client: token_data = { "client_id": CLIENT_ID, @@ -175,9 +145,7 @@ async def callback(request: Request): "redirect_uri": REDIRECT_URI } - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } + headers = {"Content-Type": "application/x-www-form-urlencoded"} try: token_res = await client.post("https://discord.com/api/oauth2/token", data=token_data, headers=headers) @@ -187,39 +155,53 @@ async def callback(request: Request): except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to exchange token: {e}") - # Get user info user_res = await client.get("https://discord.com/api/users/@me", headers={ "Authorization": f"Bearer {access_token}" }) user_json = user_res.json() user_id = user_json.get("id") - # Verify if user has admin permissions on any guild bot is in (we handle actual guilds in /me) - # For now, just generate JWT jwt_token = create_access_token({ "sub": user_id, "username": user_json.get("username"), "avatar": user_json.get("avatar") }) + secure = request.url.scheme == "https" + response.set_cookie( + key="access_token", + value=jwt_token, + httponly=True, + samesite="lax", + secure=secure, + max_age=60 * 24 * 7 * 60, + path="/" + ) + + response.set_cookie( + key="discord_token", + value=access_token, + httponly=True, + samesite="lax", + secure=secure, + max_age=60 * 24 * 7 * 60, + path="/" + ) + return { "access_token": jwt_token, - "token_type": "bearer", "user": { "id": str(user_id), "username": user_json.get("username"), "avatar": user_json.get("avatar") - }, - "discord_token": access_token # Send discord token to frontend to fetch guilds + } } @router.get("/me") -async def get_me(request: Request, user: dict = Depends(get_current_user)): +async def get_me(request: Request, user: dict = Depends(get_current_user), bot = Depends(get_bot)): """Returns the user along with guilds they manage that the bot is also in.""" - from src.api.dashboard.routes import bot_instance from src.bot.core.config import BotConfig - # Global Admin Check is_bot_admin = False if user.get("id") == "cms_admin": is_bot_admin = True @@ -232,14 +214,8 @@ async def get_me(request: Request, user: dict = Depends(get_current_user)): except: pass - # Update user object with admin status user["isAdmin"] = is_bot_admin - - auth_header = request.headers.get("Authorization") - if not auth_header: - raise HTTPException(status_code=401) - - discord_token = request.headers.get("X-Discord-Token") + discord_token = request.cookies.get("discord_token") or request.headers.get("X-Discord-Token") user_guilds = [] if discord_token: @@ -255,7 +231,7 @@ async def get_me(request: Request, user: dict = Depends(get_current_user)): if is_manageable: guild_id = int(g.get("id")) - if bot_instance and bot_instance.get_guild(guild_id): + if bot and bot.get_guild(guild_id): user_guilds.append({ "id": str(guild_id), "name": g.get("name"), @@ -267,3 +243,10 @@ async def get_me(request: Request, user: dict = Depends(get_current_user)): "user": user, "guilds": user_guilds } + +@router.post("/logout") +async def logout(response: Response): + """Logs out by clearing the access_token and discord_token cookies.""" + response.delete_cookie("access_token", path="/") + response.delete_cookie("discord_token", path="/") + return {"success": True} diff --git a/src/api/dashboard/cms/media.py b/src/api/dashboard/cms/media.py index 1615f59..b117163 100644 --- a/src/api/dashboard/cms/media.py +++ b/src/api/dashboard/cms/media.py @@ -37,6 +37,7 @@ async def upload_media( user_id, username = get_requester_info(request, user) form_data = await request.form() stock_flag = form_data.get("is_stock") == "true" or is_stock + folder = form_data.get("folder", "general") await db.create_media( filename=unique_name, @@ -45,33 +46,64 @@ async def upload_media( size_bytes=len(content), uploader_id=user_id, uploader_name=username, - is_stock=stock_flag + is_stock=stock_flag, + folder=folder ) public_url = f"/uploads/cms/{unique_name}" return {"success": True, "url": public_url, "filename": unique_name, "is_stock": stock_flag} @router.get("/media") -async def list_media(request: Request, is_stock: bool = None, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: list uploaded media files, optionally filtered by stock status.""" +async def list_media(request: Request, is_stock: bool = None, folder: str = None, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: list uploaded media files, optionally filtered by stock status or folder.""" if not is_admin(request, user): raise HTTPException(status_code=403, detail="Not authorized") try: - media = await db.get_media(is_stock=is_stock) + media = await db.get_media(is_stock=is_stock, folder=folder) for m in media: m["url"] = f"/uploads/cms/{m['filename']}" return {"success": True, "data": media} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@router.get("/folders") +async def list_folders(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: list all custom media folders.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + folders = await db.get_folders() + return {"success": True, "data": folders} + +@router.post("/folders") +async def create_folder(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: create a new media folder.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + data = await request.json() + name = data.get("name") + if not name: + raise HTTPException(status_code=400, detail="Folder name required") + await db.create_folder(name) + return {"success": True} + +@router.delete("/folders/{name}") +async def delete_folder(name: str, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: delete a media folder.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + if name == "general": + raise HTTPException(status_code=400, detail="Cannot delete general folder") + await db.delete_folder(name) + return {"success": True} + @router.put("/media/{media_id}") -async def update_media_stock(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): - """Admin: toggle is_stock flag for media.""" +async def update_media(media_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: update media properties (is_stock, folder, etc.).""" if not is_admin(request, user): raise HTTPException(status_code=403, detail="Not authorized") data = await request.json() - success = await db.update_media(media_id, data.get("is_stock", False)) + success = await db.update_media(media_id, **data) if not success: raise HTTPException(status_code=500, detail="Failed to update media") return {"success": True} diff --git a/src/api/dashboard/cms/utils.py b/src/api/dashboard/cms/utils.py index e133887..88ca38e 100644 --- a/src/api/dashboard/cms/utils.py +++ b/src/api/dashboard/cms/utils.py @@ -24,9 +24,6 @@ def slugify(text: str) -> str: async def get_maybe_user(request: Request): """Optional JWT user – returns None if unauthenticated.""" - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - return None try: return get_current_user(request) except Exception: diff --git a/src/api/dashboard/dependencies.py b/src/api/dashboard/dependencies.py new file mode 100644 index 0000000..f1cef7d --- /dev/null +++ b/src/api/dashboard/dependencies.py @@ -0,0 +1,77 @@ +from fastapi import HTTPException, Depends, Request +import jwt +import os + +JWT_SECRET = os.getenv("JWT_SECRET", "fallback-secret") +ALGORITHM = "HS256" + +def get_bot(): + """Dependency to get the global bot instance.""" + from src.api.dashboard.routes import bot_instance + if not bot_instance: + raise HTTPException(status_code=503, detail="Bot not ready") + return bot_instance + +def get_current_user(request: Request): + """Dependency to get the current user from the access_token cookie or Authorization header.""" + token = request.cookies.get("access_token") + if not token: + # Fallback to Authorization header for backward compatibility + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid token") + return {"id": user_id, "username": payload.get("username", ""), "avatar": payload.get("avatar", "")} + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token has expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + +# Database Dependencies +def get_welcome_db(): + from mxmariadb import WelcomeDatabase + db = WelcomeDatabase() + return db + +def get_antispam_db(): + from mxmariadb import AntiSpamDatabase + db = AntiSpamDatabase() + return db + +def get_globalchat_db(): + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + return db + +def get_level_db(): + from mxmariadb import LevelDatabase + db = LevelDatabase() + return db + +def get_logging_db(): + from mxmariadb import LoggingDatabase + db = LoggingDatabase() + return db + +def get_autorole_db(): + from mxmariadb import AutoRoleDatabase + db = AutoRoleDatabase() + return db + +def get_autodelete_db(): + from mxmariadb import AutoDeleteDB + db = AutoDeleteDB() + return db + +def get_tempvc_db(): + from mxmariadb import TempVCDatabase + db = TempVCDatabase() + return db diff --git a/src/api/dashboard/guild_routes.py b/src/api/dashboard/guild_routes.py index 99a2ca0..4371a2c 100644 --- a/src/api/dashboard/guild_routes.py +++ b/src/api/dashboard/guild_routes.py @@ -1,19 +1,14 @@ -from fastapi import APIRouter, Request, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends from datetime import timedelta import discord -from src.api.dashboard.auth_routes import get_current_user +from .dependencies import get_current_user, get_bot router = APIRouter( prefix="/guilds", tags=["guilds"] ) -def get_bot(): - from .routes import bot_instance - return bot_instance - -async def check_guild_perms(guild_id: int, user_id: int): - bot = get_bot() +async def check_guild_perms(guild_id: int, user_id: int, bot): if bot is None: raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") @@ -34,14 +29,14 @@ async def check_guild_perms(guild_id: int, user_id: int): return guild, member @router.get("/{guild_id}/channels") -async def get_guild_channels(guild_id: int, user: dict = Depends(get_current_user)): - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_guild_channels(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) channels = [{"id": str(c.id), "name": c.name} for c in guild.text_channels] return {"channels": channels} @router.get("/{guild_id}/roles") -async def get_guild_roles(guild_id: int, user: dict = Depends(get_current_user)): - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_guild_roles(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) roles = [ {"id": str(r.id), "name": r.name, "color": str(r.color)} for r in guild.roles @@ -50,21 +45,20 @@ async def get_guild_roles(guild_id: int, user: dict = Depends(get_current_user)) return {"roles": roles} @router.get("/{guild_id}/categories") -async def get_guild_categories(guild_id: int, user: dict = Depends(get_current_user)): - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_guild_categories(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) categories = [{"id": str(c.id), "name": c.name} for c in guild.categories] return {"categories": categories} @router.get("/{guild_id}/voice_channels") -async def get_guild_voice_channels(guild_id: int, user: dict = Depends(get_current_user)): - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_guild_voice_channels(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) channels = [{"id": str(c.id), "name": c.name} for c in guild.voice_channels] return {"channels": channels} @router.get("/{guild_id}/stats") -async def get_guild_stats(guild_id: int, user: dict = Depends(get_current_user)): - bot = get_bot() - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_guild_stats(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) today_dt = discord.utils.utcnow() today_str = today_dt.strftime('%Y-%m-%d') @@ -145,12 +139,11 @@ def calc_trend(today, yesterday): return {"total_members": guild.member_count, "online_members": 0} @router.get("/{guild_id}/mega-data") -async def get_mega_data(guild_id: int, user: dict = Depends(get_current_user)): - bot = get_bot() - guild, _ = await check_guild_perms(guild_id, int(user["id"])) +async def get_mega_data(guild_id: int, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + guild, _ = await check_guild_perms(guild_id, int(user["id"]), bot) try: - stats = await get_guild_stats(guild_id, user) + stats = await get_guild_stats(guild_id, user, bot) from mxmariadb import WelcomeDatabase, AntiSpamDatabase, GlobalChatDatabase, LevelDatabase, LoggingDatabase, ManagementDatabase level_active = LevelDatabase().get_guild_config(guild_id).get("enabled", False) if LevelDatabase().get_guild_config(guild_id) else False diff --git a/src/api/dashboard/management_routes.py b/src/api/dashboard/management_routes.py index d6212eb..40fa885 100644 --- a/src/api/dashboard/management_routes.py +++ b/src/api/dashboard/management_routes.py @@ -1,20 +1,21 @@ -from fastapi import APIRouter, Request, HTTPException, Depends -from src.api.dashboard.auth_routes import get_current_user +from fastapi import APIRouter, HTTPException, Depends from mxmariadb import ManagementDatabase import discord from datetime import datetime +from .dependencies import get_current_user, get_bot +from .schemas import AutoResponderAddRequest, ApplicationQuestionsUpdate + router = APIRouter( prefix="/management", tags=["management"], dependencies=[Depends(get_current_user)] ) -async def send_management_notification(guild_id: int, module_name: str, user_name: str): +async def send_management_notification(bot, guild_id: int, module_name: str, user_name: str): """Helper for dashboard notifications.""" - from src.api.dashboard.routes import bot_instance - if not bot_instance: return - guild = bot_instance.get_guild(guild_id) + if not bot: return + guild = bot.get_guild(guild_id) if not guild: return target_channel = guild.system_channel or guild.text_channels[0] @@ -43,18 +44,17 @@ async def get_autoresponder(guild_id: int): raise HTTPException(status_code=500, detail=str(e)) @router.post("/{guild_id}/autoresponder") -async def add_autoresponder(guild_id: int, request: Request, user: dict = Depends(get_current_user)): - data = await request.json() +async def add_autoresponder(guild_id: int, data: AutoResponderAddRequest, user: dict = Depends(get_current_user), bot = Depends(get_bot)): db = ManagementDatabase() try: await db.ensure_connection() await db.add_auto_response( guild_id, - data.get("keyword"), - data.get("response"), - data.get("match_type", "partial") + data.keyword, + data.response, + data.match_type ) - await send_management_notification(guild_id, "Auto-Responder", user.get("username")) + await send_management_notification(bot, guild_id, "Auto-Responder", user.get("username")) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -76,7 +76,6 @@ async def get_newssync(guild_id: int): try: await db.ensure_connection() all_channels = await db.get_sync_channels() - # Filter for this guild guild_syncs = [c for c in all_channels if c['guild_id'] == guild_id] return {"success": True, "data": guild_syncs} except Exception as e: @@ -94,16 +93,15 @@ async def get_app_questions(guild_id: int): raise HTTPException(status_code=500, detail=str(e)) @router.post("/{guild_id}/applications") -async def set_app_questions(guild_id: int, request: Request, user: dict = Depends(get_current_user)): - data = await request.json() - questions = data.get("questions", []) # List of strings +async def set_app_questions(guild_id: int, data: ApplicationQuestionsUpdate, user: dict = Depends(get_current_user), bot = Depends(get_bot)): + questions = data.questions db = ManagementDatabase() try: await db.ensure_connection() await db.clear_questions(guild_id) for i, q_text in enumerate(questions): await db.add_question(guild_id, q_text, i) - await send_management_notification(guild_id, "Bewerbungssystem", user.get("username")) + await send_management_notification(bot, guild_id, "Bewerbungssystem", user.get("username")) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index 0c4d383..456d633 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -4,12 +4,7 @@ from .user_routes import router as user_router from .management_routes import router as management_router from .cms import router as cms_router -from .admin_routes import router as admin_router from .guild_routes import router as guild_router -from .public_routes import router as public_router - -# Wir erstellen einen Router, den wir später in die Haupt-App einbinden -router_public = public_router # Global Bot-Referenz (wird später in main.py gesetzt) bot_instance = None @@ -30,5 +25,4 @@ def set_bot_instance(bot): dashboard_main_router.include_router(user_router) dashboard_main_router.include_router(management_router) dashboard_main_router.include_router(cms_router) -dashboard_main_router.include_router(admin_router) dashboard_main_router.include_router(guild_router) diff --git a/src/api/dashboard/schemas.py b/src/api/dashboard/schemas.py new file mode 100644 index 0000000..de16ac9 --- /dev/null +++ b/src/api/dashboard/schemas.py @@ -0,0 +1,79 @@ +from pydantic import BaseModel +from typing import Optional, List, Dict, Any, Union + +# ============================================================================= +# Auth Models +# ============================================================================= +class EmailLoginRequest(BaseModel): + email: str + password: str + +class CallbackRequest(BaseModel): + code: str + +# ============================================================================= +# Settings Models +# ============================================================================= +class GeneralSettingsUpdate(BaseModel): + language: Optional[str] = None + user_role_id: Optional[Union[str, int]] = None + team_role_id: Optional[Union[str, int]] = None + +class WelcomeSettingsUpdate(BaseModel): + channel_id: Optional[Union[str, int]] = None + auto_role_id: Optional[Union[str, int]] = None + +class AntiSpamSettingsUpdate(BaseModel): + log_channel_id: Optional[Union[str, int]] = None + max_messages: Optional[int] = 5 + time_frame: Optional[int] = 10 + +class GlobalChatSettingsUpdate(BaseModel): + channel_id: Optional[Union[str, int]] = None + filter_enabled: Optional[bool] = None + nsfw_filter: Optional[bool] = None + embed_color: Optional[str] = None + +class LoggingSettingsUpdate(BaseModel): + channel_id: Optional[Union[str, int]] = None + +class AutoRoleSettingsUpdate(BaseModel): + role_id: Optional[Union[str, int]] = None + +class AutoDeleteItem(BaseModel): + channel_id: Union[str, int] + duration: int + exclude_pinned: Optional[bool] = True + exclude_bots: Optional[bool] = False + +class TempVCSettingsUpdate(BaseModel): + creator_channel_id: Optional[Union[str, int]] = None + category_id: Optional[Union[str, int]] = None + auto_delete_time: Optional[int] = 0 + ui_enabled: Optional[bool] = False + ui_prefix: Optional[str] = "🔧" + +# ============================================================================= +# Admin Models +# ============================================================================= +class BlacklistAddRequest(BaseModel): + user_id: str + reason: Optional[str] = "Kein Grund angegeben" + +# ============================================================================= +# Management Models +# ============================================================================= +class AutoResponderAddRequest(BaseModel): + keyword: str + response: str + match_type: Optional[str] = "partial" + +class ApplicationQuestionsUpdate(BaseModel): + questions: List[str] + +# ============================================================================= +# User Models +# ============================================================================= +class UserSettingsUpdate(BaseModel): + language: Optional[str] = None + is_private: Optional[bool] = None diff --git a/src/api/dashboard/settings_routes.py b/src/api/dashboard/settings_routes.py index 0b955c0..e0abbba 100644 --- a/src/api/dashboard/settings_routes.py +++ b/src/api/dashboard/settings_routes.py @@ -1,18 +1,27 @@ -from fastapi import APIRouter, Request, HTTPException, Security, status, Depends -from src.api.dashboard.auth_routes import get_current_user -from mxmariadb import WelcomeDatabase, AntiSpamDatabase, GlobalChatDatabase, LevelDatabase, LoggingDatabase, AutoDeleteDB, AutoRoleDatabase, TempVCDatabase +from fastapi import APIRouter, HTTPException, Depends +from typing import List, Dict, Any import discord from datetime import datetime +from .dependencies import ( + get_current_user, get_bot, get_welcome_db, get_antispam_db, + get_globalchat_db, get_level_db, get_logging_db, + get_autorole_db, get_autodelete_db, get_tempvc_db +) +from .schemas import ( + GeneralSettingsUpdate, WelcomeSettingsUpdate, AntiSpamSettingsUpdate, + GlobalChatSettingsUpdate, LoggingSettingsUpdate, AutoRoleSettingsUpdate, + AutoDeleteItem, TempVCSettingsUpdate +) + router = APIRouter( prefix="/settings", tags=["settings"], dependencies=[Depends(get_current_user)] ) -async def send_dashboard_notification(guild_id: int, module_name: str, user_name: str, channel_id: int = None): +async def send_dashboard_notification(bot_instance, guild_id: int, module_name: str, user_name: str, channel_id: int = None): """Helper to send a notification to a Discord channel when settings are saved.""" - from src.api.dashboard.routes import bot_instance if not bot_instance: return @@ -22,7 +31,6 @@ async def send_dashboard_notification(guild_id: int, module_name: str, user_name # Try to find a suitable channel if none provided if not channel_id: - # For general settings, we might use a system channel or first available target_channel = guild.system_channel or guild.text_channels[0] else: target_channel = guild.get_channel(channel_id) @@ -46,21 +54,19 @@ async def send_dashboard_notification(guild_id: int, module_name: str, user_name print(f"Failed to send dashboard notification: {e}") @router.get("/{guild_id}") -async def get_settings(guild_id: int): +async def get_settings(guild_id: int, bot = Depends(get_bot)): """Fetch settings for a specific guild.""" - from src.api.dashboard.routes import bot_instance - - if not bot_instance or not hasattr(bot_instance, 'settings_db'): + if not hasattr(bot, 'settings_db'): raise HTTPException(status_code=503, detail="Bot database not ready") try: - guild_settings = bot_instance.settings_db.get_guild_settings(guild_id) if hasattr(bot_instance.settings_db, 'get_guild_settings') else {} + guild_settings = bot.settings_db.get_guild_settings(guild_id) if hasattr(bot.settings_db, 'get_guild_settings') else {} guild_lang = guild_settings.get("language", "de") return { "success": True, "data": { - "bot_name": bot_instance.user.name, + "bot_name": bot.user.name, "prefix": "!" , "auto_mod": True, "welcome_message": False, @@ -73,59 +79,43 @@ async def get_settings(guild_id: int): raise HTTPException(status_code=500, detail=str(e)) @router.post("/{guild_id}") -async def update_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_settings(guild_id: int, data: GeneralSettingsUpdate, user: dict = Depends(get_current_user), bot = Depends(get_bot)): """Update general settings for a specific guild.""" - from src.api.dashboard.routes import bot_instance - - if not bot_instance or not hasattr(bot_instance, 'settings_db'): + if not hasattr(bot, 'settings_db'): raise HTTPException(status_code=503, detail="Bot database not ready") - data = await request.json() - try: - # Update logic - update_data = {} - if "language" in data: - update_data["language"] = data["language"] - if "user_role_id" in data: - update_data["user_role_id"] = int(data["user_role_id"]) if data["user_role_id"] else None - if "team_role_id" in data: - update_data["team_role_id"] = int(data["team_role_id"]) if data["team_role_id"] else None + update_data = data.model_dump(exclude_unset=True) + if "user_role_id" in update_data and update_data["user_role_id"] is not None: + update_data["user_role_id"] = int(update_data["user_role_id"]) + if "team_role_id" in update_data and update_data["team_role_id"] is not None: + update_data["team_role_id"] = int(update_data["team_role_id"]) - if update_data and hasattr(bot_instance.settings_db, 'update_guild_settings'): - bot_instance.settings_db.update_guild_settings(guild_id, **update_data) + if update_data and hasattr(bot.settings_db, 'update_guild_settings'): + bot.settings_db.update_guild_settings(guild_id, **update_data) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Allgemein", user_name) + await send_dashboard_notification(bot, guild_id, "Allgemein", user_name) return {"success": True, "message": "Settings updated"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save settings: {e}") # --- Welcome Module Routes --- - @router.get("/{guild_id}/channels") -async def get_guild_channels(guild_id: int): +async def get_guild_channels(guild_id: int, bot = Depends(get_bot)): """Returns a list of text channels for the guild.""" - from src.api.dashboard.routes import bot_instance - if not bot_instance: - raise HTTPException(status_code=503, detail="Bot not ready") - - guild = bot_instance.get_guild(guild_id) + guild = bot.get_guild(guild_id) if not guild: raise HTTPException(status_code=404, detail="Guild not found") - channels = [ - {"id": str(c.id), "name": c.name} - for c in guild.text_channels - ] + channels = [{"id": str(c.id), "name": c.name} for c in guild.text_channels] return {"success": True, "channels": channels} @router.get("/{guild_id}/welcome") -async def get_welcome_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_welcome_settings(guild_id: int, db = Depends(get_welcome_db)): """Fetch welcome-specific settings.""" - db = WelcomeDatabase() try: - await db.init_db() # Sicherstellen dass Tabellen existieren + await db.init_db() settings = await db.get_welcome_settings(guild_id) if settings and "channel_id" in settings and settings["channel_id"]: settings["channel_id"] = str(settings["channel_id"]) @@ -137,40 +127,34 @@ async def get_welcome_settings(guild_id: int, user: dict = Depends(get_current_u raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/welcome") -async def update_welcome_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_welcome_settings(guild_id: int, data: WelcomeSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_welcome_db), bot = Depends(get_bot)): """Update welcome-specific settings.""" - data = await request.json() - db = WelcomeDatabase() - - if "channel_id" in data and data["channel_id"]: - data["channel_id"] = int(data["channel_id"]) - if "auto_role_id" in data and data["auto_role_id"]: - data["auto_role_id"] = int(data["auto_role_id"]) + update_data = data.model_dump(exclude_unset=True) + if "channel_id" in update_data and update_data["channel_id"]: + update_data["channel_id"] = int(update_data["channel_id"]) + if "auto_role_id" in update_data and update_data["auto_role_id"]: + update_data["auto_role_id"] = int(update_data["auto_role_id"]) try: await db.init_db() - success = await db.update_welcome_settings(guild_id, **data) + success = await db.update_welcome_settings(guild_id, **update_data) if success: user_name = user.get("username", "Unbekannter User") - from src.api.dashboard.routes import bot_instance - if bot_instance: - cog = bot_instance.get_cog("WelcomeSystem") - if cog and hasattr(cog, 'invalidate_cache'): - cog.invalidate_cache(guild_id) + cog = bot.get_cog("WelcomeSystem") + if cog and hasattr(cog, 'invalidate_cache'): + cog.invalidate_cache(guild_id) - channel_id = data.get("channel_id") - await send_dashboard_notification(guild_id, "Welcome System", user_name, channel_id) + channel_id = update_data.get("channel_id") + await send_dashboard_notification(bot, guild_id, "Welcome System", user_name, channel_id) return {"success": success} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save welcome settings: {e}") # --- AntiSpam Module Routes --- - @router.get("/{guild_id}/antispam") -async def get_antispam_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_antispam_settings(guild_id: int, db = Depends(get_antispam_db)): """Fetch AntiSpam-specific settings.""" - db = AntiSpamDatabase() try: await db.init_db() settings = await db.get_spam_settings(guild_id) @@ -181,36 +165,29 @@ async def get_antispam_settings(guild_id: int, user: dict = Depends(get_current_ raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/antispam") -async def update_antispam_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_antispam_settings(guild_id: int, data: AntiSpamSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_antispam_db), bot = Depends(get_bot)): """Update AntiSpam-specific settings.""" - data = await request.json() - db = AntiSpamDatabase() - - if "log_channel_id" in data and data["log_channel_id"]: - data["log_channel_id"] = int(data["log_channel_id"]) - try: await db.init_db() + log_channel_id = int(data.log_channel_id) if data.log_channel_id else None success = await db.set_spam_settings( guild_id, - max_messages=data.get("max_messages", 5), - time_frame=data.get("time_frame", 10), - log_channel_id=data.get("log_channel_id") + max_messages=data.max_messages, + time_frame=data.time_frame, + log_channel_id=log_channel_id ) if success: user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Anti-Spam", user_name, data.get("log_channel_id")) + await send_dashboard_notification(bot, guild_id, "Anti-Spam", user_name, log_channel_id) return {"success": success} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save AntiSpam settings: {e}") # --- GlobalChat Module Routes --- - @router.get("/{guild_id}/globalchat") -async def get_globalchat_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_globalchat_settings(guild_id: int, db = Depends(get_globalchat_db)): """Fetch GlobalChat-specific settings.""" - db = GlobalChatDatabase() try: await db.init_db() settings = await db.get_guild_settings(guild_id) @@ -221,69 +198,60 @@ async def get_globalchat_settings(guild_id: int, user: dict = Depends(get_curren raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/globalchat") -async def update_globalchat_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_globalchat_settings(guild_id: int, data: GlobalChatSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_globalchat_db), bot = Depends(get_bot)): """Update GlobalChat-specific settings.""" - data = await request.json() - db = GlobalChatDatabase() - try: await db.init_db() success = True user_name = user.get("username", "Unbekannter User") - new_channel_id = data.get("channel_id") - if new_channel_id: + new_channel_id = data.channel_id + if new_channel_id is not None: success = await db.set_globalchat_channel(guild_id, int(new_channel_id)) - for key in ["filter_enabled", "nsfw_filter", "embed_color"]: - if key in data: - await db.update_guild_setting(guild_id, key, data[key]) + if data.filter_enabled is not None: + await db.update_guild_setting(guild_id, "filter_enabled", data.filter_enabled) + if data.nsfw_filter is not None: + await db.update_guild_setting(guild_id, "nsfw_filter", data.nsfw_filter) + if data.embed_color is not None: + await db.update_guild_setting(guild_id, "embed_color", data.embed_color) if success: - await send_dashboard_notification(guild_id, "Global Chat", user_name, int(new_channel_id) if new_channel_id else None) + await send_dashboard_notification(bot, guild_id, "Global Chat", user_name, int(new_channel_id) if new_channel_id else None) return {"success": success} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save GlobalChat settings: {e}") # --- LevelSystem Module Routes --- - @router.get("/{guild_id}/levels") -async def get_level_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_level_settings(guild_id: int, db = Depends(get_level_db)): """Fetch LevelSystem settings.""" - db = LevelDatabase() try: await db.init_db() - # Hinweis: LevelDatabase verwendet get_guild_config statt get_guild_settings settings = await db.get_guild_config(guild_id) return {"success": True, "data": settings or {}} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/levels") -async def update_level_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_level_settings(guild_id: int, data: Dict[str, Any], user: dict = Depends(get_current_user), db = Depends(get_level_db), bot = Depends(get_bot)): """Update LevelSystem settings.""" - data = await request.json() - db = LevelDatabase() try: await db.init_db() - # Hinweis: LevelDatabase verwendet set_guild_config statt update_guild_settings await db.set_guild_config(guild_id, **data) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Level-System", user_name) + await send_dashboard_notification(bot, guild_id, "Level-System", user_name) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save level settings: {e}") # --- Logging Module Routes --- - @router.get("/{guild_id}/logging") -async def get_logging_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_logging_settings(guild_id: int, db = Depends(get_logging_db)): """Fetch Logging settings.""" - db = LoggingDatabase() try: await db.init_db() - # LoggingDatabase liefert ein Dict von Typ -> ChannelID channels = await db.get_all_log_channels(guild_id) settings = {"channel_id": str(channels.get("general")) if channels.get("general") else None} return {"success": True, "data": settings} @@ -291,31 +259,25 @@ async def get_logging_settings(guild_id: int, user: dict = Depends(get_current_u raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/logging") -async def update_logging_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_logging_settings(guild_id: int, data: LoggingSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_logging_db), bot = Depends(get_bot)): """Update Logging settings.""" - data = await request.json() - db = LoggingDatabase() - try: await db.init_db() - if "channel_id" in data and data["channel_id"]: - await db.set_log_channel(guild_id, int(data["channel_id"])) + if data.channel_id is not None: + await db.set_log_channel(guild_id, int(data.channel_id)) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Server-Log", user_name, int(data["channel_id"]) if data.get("channel_id") else None) + await send_dashboard_notification(bot, guild_id, "Server-Log", user_name, int(data.channel_id) if data.channel_id else None) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save logging settings: {e}") # --- AutoRole Module Routes --- - @router.get("/{guild_id}/autorole") -async def get_autorole_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_autorole_settings(guild_id: int, db = Depends(get_autorole_db)): """Fetch AutoRole settings.""" - db = AutoRoleDatabase() try: await db.init_db() - # AutoRole liefert eine Liste von Rollen roles = await db.get_all_autoroles(guild_id) settings = {} if roles: @@ -327,69 +289,57 @@ async def get_autorole_settings(guild_id: int, user: dict = Depends(get_current_ raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/autorole") -async def update_autorole_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_autorole_settings(guild_id: int, data: AutoRoleSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_autorole_db), bot = Depends(get_bot)): """Update AutoRole settings.""" - data = await request.json() - db = AutoRoleDatabase() - try: await db.init_db() - if "role_id" in data and data["role_id"]: - # Existierende entfernen und neu setzen (vereinfacht für Dashboard) + if data.role_id is not None: roles = await db.get_all_autoroles(guild_id) for r in roles: await db.remove_autorole(r["autorole_id"]) - await db.add_autorole(guild_id, int(data["role_id"])) + await db.add_autorole(guild_id, int(data.role_id)) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Auto-Role", user_name) + await send_dashboard_notification(bot, guild_id, "Auto-Role", user_name) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save autorole settings: {e}") # --- AutoDelete Module Routes --- - @router.get("/{guild_id}/autodelete") -async def get_autodelete_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_autodelete_settings(guild_id: int, db = Depends(get_autodelete_db)): """Fetch AutoDelete settings.""" - db = AutoDeleteDB() try: await db.init_db() settings = await db.get_all() - # Filter für die aktuelle Guild (Note: autodelete table might need guild_id for better filtering) + # TODO: Filter by guild if the DB supports it return {"success": True, "data": settings or []} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/autodelete") -async def update_autodelete_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_autodelete_settings(guild_id: int, items: List[AutoDeleteItem], user: dict = Depends(get_current_user), db = Depends(get_autodelete_db), bot = Depends(get_bot)): """Update AutoDelete settings.""" - data = await request.json() - db = AutoDeleteDB() try: await db.init_db() - # In MariaDB Version heißt es add_autodelete - for item in data: - if "channel_id" in item and "duration" in item: - await db.add_autodelete( - int(item["channel_id"]), - int(item["duration"]), - item.get("exclude_pinned", True), - item.get("exclude_bots", False) - ) + for item in items: + await db.add_autodelete( + int(item.channel_id), + item.duration, + item.exclude_pinned, + item.exclude_bots + ) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Auto-Delete", user_name) + await send_dashboard_notification(bot, guild_id, "Auto-Delete", user_name) return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save autodelete settings: {e}") # --- TempVC Module Routes --- - @router.get("/{guild_id}/tempvc") -async def get_tempvc_settings(guild_id: int, user: dict = Depends(get_current_user)): +async def get_tempvc_settings(guild_id: int, db = Depends(get_tempvc_db)): """Fetch TempVC-specific settings.""" - db = TempVCDatabase() try: await db.init_db() settings = await db.get_tempvc_settings(guild_id) @@ -415,26 +365,23 @@ async def get_tempvc_settings(guild_id: int, user: dict = Depends(get_current_us raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/{guild_id}/tempvc") -async def update_tempvc_settings(guild_id: int, request: Request, user: dict = Depends(get_current_user)): +async def update_tempvc_settings(guild_id: int, data: TempVCSettingsUpdate, user: dict = Depends(get_current_user), db = Depends(get_tempvc_db), bot = Depends(get_bot)): """Update TempVC-specific settings.""" - data = await request.json() - db = TempVCDatabase() - try: await db.init_db() - creator_channel_id = int(data.get("creator_channel_id")) if data.get("creator_channel_id") else 0 - category_id = int(data.get("category_id")) if data.get("category_id") else 0 - auto_delete_time = int(data.get("auto_delete_time", 0)) + creator_channel_id = int(data.creator_channel_id) if data.creator_channel_id else 0 + category_id = int(data.category_id) if data.category_id else 0 + auto_delete_time = int(data.auto_delete_time) if data.auto_delete_time else 0 if creator_channel_id and category_id: await db.set_tempvc_settings(guild_id, creator_channel_id, category_id, auto_delete_time) - ui_enabled = bool(data.get("ui_enabled", False)) - ui_prefix = data.get("ui_prefix", "🔧") + ui_enabled = bool(data.ui_enabled) + ui_prefix = data.ui_prefix or "🔧" await db.set_ui_settings(guild_id, ui_enabled, ui_prefix) user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "TempVC System", user_name, creator_channel_id or None) + await send_dashboard_notification(bot, guild_id, "TempVC System", user_name, creator_channel_id or None) return {"success": True} except Exception as e: diff --git a/src/api/dashboard/user_routes.py b/src/api/dashboard/user_routes.py index fd252c5..0e69d78 100644 --- a/src/api/dashboard/user_routes.py +++ b/src/api/dashboard/user_routes.py @@ -1,11 +1,13 @@ -from fastapi import APIRouter, Request, HTTPException, Depends -from src.api.dashboard.auth_routes import get_current_user +from fastapi import APIRouter, HTTPException, Depends from mxmariadb import SettingsDB, StatsDB, EconomyDatabase import discord import sqlite3 import os from pathlib import Path +from .dependencies import get_current_user, get_bot +from .schemas import UserSettingsUpdate + # Paths to databases BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent DATA_DIR = BASE_DIR / "data" @@ -19,9 +21,8 @@ ) @router.get("/settings") -async def get_user_settings(user: dict = Depends(get_current_user)): +async def get_user_settings(user: dict = Depends(get_current_user), bot = Depends(get_bot)): """Fetch user settings from SettingsDB.""" - from src.api.dashboard.routes import bot_instance settings_db = SettingsDB() try: user_id = int(user["id"]) @@ -80,8 +81,8 @@ async def get_user_settings(user: dict = Depends(get_current_user)): guild_name = "Unknown Server" guild_icon = None - if bot_instance: - guild = bot_instance.get_guild(guild_id) + if bot: + guild = bot.get_guild(guild_id) if guild: guild_name = guild.name guild_icon = guild.icon.url if guild.icon else None @@ -122,24 +123,23 @@ async def get_user_settings(user: dict = Depends(get_current_user)): raise HTTPException(status_code=500, detail=f"Database error: {e}") @router.post("/settings") -async def update_user_settings(request: Request, user: dict = Depends(get_current_user)): +async def update_user_settings(data: UserSettingsUpdate, user: dict = Depends(get_current_user)): """Update user settings in SettingsDB.""" - data = await request.json() settings_db = SettingsDB() try: user_id = int(user["id"]) # Update language in SettingsDB if provided - if "language" in data: - settings_db.set_user_language(user_id, data["language"]) + if data.language is not None: + settings_db.set_user_language(user_id, data.language) # Update privacy in StatsDB if provided - if "is_private" in data: + if data.is_private is not None: stats_db = StatsDB() async with stats_db.lock: stats_db.cursor.execute( "UPDATE global_user_levels SET is_private = ? WHERE user_id = ?", - (1 if data["is_private"] else 0, user_id) + (1 if data.is_private else 0, user_id) ) stats_db.conn.commit() diff --git a/src/api/dashboard/public_routes.py b/src/api/public/routes.py similarity index 89% rename from src/api/dashboard/public_routes.py rename to src/api/public/routes.py index 5e4f829..0f589cc 100644 --- a/src/api/dashboard/public_routes.py +++ b/src/api/public/routes.py @@ -1,19 +1,15 @@ -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, HTTPException, Depends import discord from typing import List, Optional +from src.api.dashboard.dependencies import get_bot router = APIRouter( - prefix="/v1/managerx", + prefix="/public", tags=["public"] ) -def get_bot(): - from .routes import bot_instance - return bot_instance - @router.get("/stats") -async def get_stats(request: Request): - bot = get_bot() +async def get_stats(bot = Depends(get_bot)): if bot is None: raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") @@ -41,9 +37,8 @@ async def get_stats(request: Request): raise HTTPException(status_code=500, detail=str(e)) @router.get("/leaderboard") -async def get_leaderboard(limit: int = 50): +async def get_leaderboard(limit: int = 50, bot = Depends(get_bot)): from mxmariadb import StatsDB - bot = get_bot() if bot is None: raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") diff --git a/src/bot/cogs/guild/globalchat.py b/src/bot/cogs/guild/globalchat.py index 4411be4..bbf9e16 100644 --- a/src/bot/cogs/guild/globalchat.py +++ b/src/bot/cogs/guild/globalchat.py @@ -1,1120 +1,4 @@ -# Copyright (c) 2025 OPPRO.NET Network -import discord -from discord.ext import commands, tasks -from discord import slash_command, Option, SlashCommandGroup -from mxmariadb import GlobalChatDatabase -import asyncio -import logging -import re -import time -from typing import List, Optional, Dict, Tuple -import aiohttp -import io -import json -import random -from datetime import datetime, timedelta -import ezcord -from collections import defaultdict -from discord.ui import Container -db = GlobalChatDatabase() -logger = logging.getLogger(__name__) - - -class GlobalChatConfig: - 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'] - 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', 'porn', 'sex', 'xxx', 'nude', 'hentai', - 'dick', 'pussy', 'cock', 'tits', 'ass', 'fuck' - ] - - -class MediaHandler: - def __init__(self, config: GlobalChatConfig): - self.config = config - - def validate_attachments(self, attachments: List[discord.Attachment]) -> Tuple[bool, str, List[discord.Attachment]]: - 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: - if attachment.size > max_size_bytes: - return False, f"Datei '{attachment.filename}' ist zu groß (max. {self.config.MAX_FILE_SIZE_MB}MB)", [] - file_ext = attachment.filename.split('.')[-1].lower() if '.' in attachment.filename else '' - all_allowed = ( - self.config.ALLOWED_IMAGE_FORMATS + self.config.ALLOWED_VIDEO_FORMATS + - self.config.ALLOWED_AUDIO_FORMATS + self.config.ALLOWED_DOCUMENT_FORMATS - ) - if file_ext and file_ext not in all_allowed: - return False, f"Dateiformat '.{file_ext}' nicht erlaubt", [] - valid_attachments.append(attachment) - return True, "", valid_attachments - - def categorize_attachment(self, attachment: discord.Attachment) -> str: - if not attachment.filename or '.' not in attachment.filename: - return 'other' - file_ext = attachment.filename.split('.')[-1].lower() - if file_ext in self.config.ALLOWED_IMAGE_FORMATS: - return 'image' - elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: - return 'video' - elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: - return 'audio' - elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: - return 'document' - return 'other' - - def get_attachment_icon(self, attachment: discord.Attachment) -> str: - icons = {'image': '🖼️', 'video': '🎥', 'audio': '🎵', 'document': '📄', 'other': '📎'} - return icons.get(self.categorize_attachment(attachment), '📎') - - def format_file_size(self, size_bytes: int) -> str: - for unit in ['B', 'KB', 'MB']: - if size_bytes < 1024.0: - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024.0 - return f"{size_bytes:.1f} GB" - - -class MessageValidator: - def __init__(self, config: GlobalChatConfig): - self.config = config - self.media_handler = MediaHandler(config) - self._compile_patterns() - - def _compile_patterns(self): - self.invite_pattern = re.compile(self.config.DISCORD_INVITE_PATTERN) - self.url_pattern = re.compile(self.config.URL_PATTERN) - - # ✅ 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" - - if await db.is_blacklisted('user', message.author.id): - return False, "User auf Blacklist" - if await db.is_blacklisted('guild', message.guild.id): - return False, "Guild auf Blacklist" - - if not message.content and not message.attachments and not message.stickers: - return False, "Leere Nachricht" - - if message.content: - content_length = len(message.content.strip()) - 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)" - - if message.attachments: - valid, reason, _ = self.media_handler.validate_attachments(message.attachments) - if not valid: - return False, f"Ungültige Anhänge: {reason}" - - 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}" - - 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]: - if not content: - return False, "" - if self.invite_pattern.search(content): - return True, "Discord Invite" - return False, "" - - def check_nsfw_content(self, content: str) -> bool: - if not content: - return False - content_lower = content.lower() - for keyword in self.config.NSFW_KEYWORDS: - if re.search(r'\b' + re.escape(keyword) + r'\b', content_lower): - return True - return False - - def clean_content(self, content: str) -> str: - if not content: - return "" - content = content.replace('@everyone', '@everyone').replace('@here', '@here') - content = re.sub(r'<@&(\d+)>', r'@role', content) - return content - - -class EmbedBuilder: - def __init__(self, config: GlobalChatConfig, bot=None): - self.config = config - self.media_handler = MediaHandler(config) - 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]]]: - if attachment_data is None: - attachment_data = [] - - content = self._clean_content(message.content) - embed_color = self._parse_color(settings.get('embed_color', self.config.DEFAULT_EMBED_COLOR)) - - if content: - description = f"{content}" - elif message.attachments or message.stickers or attachment_data: - description = "📎 *Medien-Nachricht*" - else: - description = "" - - embed = discord.Embed(description=description, color=embed_color, timestamp=message.created_at) - author_text, badges = self._build_author_info(message.author) - - from mxmariadb import EconomyDatabase - eco_db = EconomyDatabase() - overrides = eco_db.get_equipped_overrides(message.author.id) - if 'color' in overrides: - embed_color = self._parse_color(overrides['color']) - embed.color = embed_color - if 'emoji' in overrides: - 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_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) - - if message.reference: - try: - replied_msg = message.reference.resolved - 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: - ref_channel = self.bot.get_channel(ref_chan_id) - if not ref_channel and message.guild: - try: - ref_channel = message.guild.get_channel(ref_chan_id) - except Exception: - ref_channel = None - if not ref_channel: - ref_channel = message.channel - if ref_channel: - try: - replied_msg = await ref_channel.fetch_message(message.reference.message_id) - except Exception: - replied_msg = None - - if isinstance(replied_msg, discord.Message): - preview = replied_msg.content or "" - if not preview and replied_msg.embeds: - try: - preview = replied_msg.embeds[0].description or "" - except Exception: - preview = "" - if not preview: - if replied_msg.attachments: - preview = f"📎 {len(replied_msg.attachments)} Datei(en)" - elif replied_msg.stickers: - preview = "🎨 Sticker" - else: - preview = "*(Leere Nachricht)*" - - preview = self._clean_content(preview) - preview_short = (preview[:200] + "...") if len(preview) > 200 else preview - - author_display = None - try: - if replied_msg.author and replied_msg.author.id == getattr(self.bot, 'user', None).id and replied_msg.embeds: - emb = replied_msg.embeds[0] - if emb.author and emb.author.name: - author_display = emb.author.name - except Exception: - author_display = None - - if not author_display: - try: - author_display = replied_msg.author.display_name - except Exception: - author_display = "Unbekannter User" - - origin = None - try: - if getattr(replied_msg, 'guild', None) and getattr(replied_msg, 'channel', None): - origin = f"{replied_msg.guild.name} • #{replied_msg.channel.name}" - except Exception: - origin = None - - reply_field = f"**{author_display}:** {preview_short}" - if origin: - reply_field += f"\n_{origin}_" - embed.add_field(name="↩️ Antwort (Vorschau)", value=reply_field, inline=False) - except Exception: - pass - - 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]]: - if attachment_data is None: - attachment_data = [] - attachment_bytes: List[Tuple[str, bytes]] = [] - if attachment_data: - attachment_bytes.extend(self._process_downloaded_attachments(embed, attachment_data)) - if message.stickers: - self._process_stickers(embed, message.stickers) - 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]]: - attachment_bytes: List[Tuple[str, bytes]] = [] - images, videos, audios, documents, others = [], [], [], [], [] - - for filename, data, content_type in attachment_data: - category = self._get_attachment_category(filename, content_type) - if category == 'image': - images.append((filename, data)) - 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)) - - if images: - embed.set_image(url=f"attachment://{images[0][0]}") - for filename, data in images: - attachment_bytes.append((filename, data)) - if len(images) > 1: - embed.add_field(name="🖼️ Weitere Bilder", value=f"_{len(images)-1} zusätzliche Bilder angehängt._", inline=False) - - if videos: - video_links = [] - for video_name, video_data in videos: - video_links.append(f"🎥 {video_name} ({self.media_handler.format_file_size(len(video_data))})") - attachment_bytes.append((video_name, video_data)) - embed.add_field(name="🎬 Videos", value="\n".join(video_links[:3]), inline=False) - - if audios: - audio_links = [] - for audio_name, audio_data in audios: - 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 documents: - doc_links = [] - for doc_name, doc_data in documents: - doc_links.append(f"📄 {doc_name} ({self.media_handler.format_file_size(len(doc_data))})") - attachment_bytes.append((doc_name, doc_data)) - embed.add_field(name="📄 Dokumente", value="\n".join(doc_links[:3]), inline=False) - - if others: - other_links = [] - for other_name, other_data in others: - other_links.append(f"📎 {other_name} ({self.media_handler.format_file_size(len(other_data))})") - attachment_bytes.append((other_name, other_data)) - 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]): - 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) - 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]): - if not embeds: - return - link_embeds = [] - for embed in embeds: - if embed.type not in ['image', 'video', 'gifv'] and (embed.title or embed.description or embed.url): - title = embed.title or "Unbekannter Link" - description = (embed.description[:100] + "...") if embed.description else "" - url = embed.url or "" - link_embeds.append(f"**[{title}]({url})**\n_{description}_") - if link_embeds: - main_embed.add_field(name="🔗 Verlinkte Inhalte", value="\n\n".join(link_embeds), inline=False) - - def _get_attachment_category(self, filename: str, content_type: str) -> str: - if content_type.startswith('image/'): - return 'image' - elif content_type.startswith('video/'): - return 'video' - elif content_type.startswith('audio/'): - return 'audio' - if not filename or '.' not in filename: - return 'other' - file_ext = filename.split('.')[-1].lower() - if file_ext in self.config.ALLOWED_IMAGE_FORMATS: - return 'image' - elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: - return 'video' - elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: - return 'audio' - elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: - return 'document' - return 'other' - - def _clean_content(self, content: str) -> str: - if not content: - return "" - 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: - try: - 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]]: - badges, roles = [], [] - if author.id in self.config.BOT_OWNERS: - badges.append("👑") - roles.append("Bot Owner") - if author.guild_permissions.administrator: - badges.append("⚡") - roles.append("Admin") - elif author.guild_permissions.manage_guild: - badges.append("🔧") - roles.append("Mod") - if hasattr(author, 'premium_since') and author.premium_since: - badges.append("💎") - roles.append("Booster") - badge_text = " ".join(badges) - display = author.display_name - 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: - def __init__(self, bot, config: GlobalChatConfig, embed_builder: EmbedBuilder): - self.bot = bot - self.config = config - self.embed_builder = embed_builder - self._cached_channels: Optional[List[int]] = None - - async def _get_all_active_channels(self) -> List[int]: - 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]: - try: - 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]], view: discord.ui.View = None) -> bool: - try: - channel = self.bot.get_channel(channel_id) - if not channel: - try: - channel = await self.bot.fetch_channel(channel_id) - except Exception: - logger.warning(f"⚠️ Channel {channel_id} konnte nicht abgerufen werden.") - return False - - 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 - - files = [] - if attachment_bytes: - for filename, data in attachment_bytes: - try: - files.append(discord.File(io.BytesIO(data), filename=filename)) - except Exception as e: - logger.warning(f"⚠️ Error creating file {filename}: {e}") - - max_retries = 3 - for attempt in range(max_retries): - try: - if files: - await channel.send(embed=embed, files=files, view=view) - else: - await channel.send(embed=embed, view=view) - return True - except (ConnectionResetError, aiohttp.ClientConnectorError, asyncio.TimeoutError) as e: - logger.warning(f"❌ Sendefehler (Retry {attempt+1}/{max_retries}) in {channel_id}: {e}") - await asyncio.sleep(1 + attempt * 2) - except discord.Forbidden: - logger.warning(f"❌ Bot hat Senderechte in {channel_id} verloren.") - 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 - - 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 - -class GlobalChatReportView(discord.ui.View): - def __init__(self, message_id: int, author_id: int, guild_id: int): - super().__init__(timeout=None) - self.message_id = message_id - self.author_id = author_id - self.guild_id = guild_id - - @discord.ui.button(label="Melden", style=discord.ButtonStyle.secondary, emoji="🚩", custom_id="gc_report") - async def report_button(self, button: discord.ui.Button, interaction: discord.Interaction): - # Notify staff (owners) - owners = [1093555256689959005, 1427994077332373554] - embed = discord.Embed( - title="⚠️ GlobalChat Meldung", - description=f"Eine Nachricht wurde gemeldet.\n" - f"**Sender ID:** `{self.author_id}`\n" - f"**Nachricht ID:** `{self.message_id}`\n" - f"**Server ID:** `{self.guild_id}`", - color=discord.Color.orange(), - timestamp=discord.utils.utcnow() - ) - embed.set_footer(text=f"Gemeldet von: {interaction.user} ({interaction.user.id})") - - for owner_id in owners: - try: - owner = await interaction.client.fetch_user(owner_id) - await owner.send(embed=embed) - except: pass - - await interaction.response.send_message("✅ Danke! Die Nachricht wurde an das Moderations-Team weitergeleitet.", ephemeral=True) - - async def send_global_message(self, message: discord.Message, attachment_data: List[Tuple[str, bytes, str]] = None) -> Tuple[int, int]: - 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, failed_sends = 0, 0 - - # Reporting View - view = GlobalChatReportView(message.id, message.author.id, message.guild.id) - - # Batching (split into groups of 10 to reduce lag) - batch_size = 10 - for i in range(0, len(active_channels), batch_size): - current_batch = active_channels[i:i + batch_size] - task_list = [self._send_to_channel(channel_id, embed, files_to_upload, view) for channel_id in current_batch] - results = await asyncio.gather(*task_list, return_exceptions=True) - - for result in results: - if result is True: - successful_sends += 1 - else: - failed_sends += 1 - - await asyncio.sleep(0.1) # Prevents hitting rate limits too hard - - return successful_sends, failed_sends - - async def send_global_broadcast_message(self, embed: discord.Embed) -> Tuple[int, int]: - active_channels = await self._get_all_active_channels() - 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): - globalchat = SlashCommandGroup("globalchat", "GlobalChat Verwaltung") - - def __init__(self, bot): - self.bot = bot - self.config = GlobalChatConfig() - self.validator = MessageValidator(self.config) - self.embed_builder = EmbedBuilder(self.config, bot) - self.message_cooldown = commands.CooldownMapping.from_cooldown( - self.config.RATE_LIMIT_MESSAGES, - self.config.RATE_LIMIT_SECONDS, - commands.BucketType.user - ) - self._cached_channels = None - self.sender = GlobalChatSender(self.bot, self.config, self.embed_builder) - self.cleanup_task.start() - self.bot.loop.create_task(GlobalChatDatabase().create_tables()) - - @tasks.loop(hours=12) - async def cleanup_task(self): - 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): - if not message.guild or message.author.bot: - return - - # ✅ 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 - - # ✅ await hinzugefügt - settings = await db.get_guild_settings(message.guild.id) - - # ✅ 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})") - if any(keyword in reason for keyword in ["Blacklist", "NSFW", "Gefilterte", "Ungültige Anhänge", "zu groß"]): - try: - await message.add_reaction("❌") - if "Ungültige Anhänge" in reason or "zu groß" in reason: - await message.reply( - f"❌ **Fehler:** {reason}\n" - f"**Max. Größe:** {self.config.MAX_FILE_SIZE_MB}MB pro Datei\n" - f"**Max. Anhänge:** {self.config.MAX_ATTACHMENTS}", - delete_after=7 - ) - await asyncio.sleep(2) - await message.delete() - except (discord.Forbidden, discord.NotFound): - pass - return - - from mxmariadb import EconomyDatabase - eco_db = EconomyDatabase() - 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: - 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 - if can_earn: - eco_db.add_global_coins(message.author.id, random.randint(5, 15)) - eco_db.update_last_message(message.author.id) - - bucket = self.message_cooldown.get_bucket(message) - retry_after = bucket.update_rate_limit() - if retry_after: - try: - await message.add_reaction("⏰") - await asyncio.sleep(2) - await message.delete() - logger.debug(f"⏰ Nachricht von {message.author.id} wegen Rate Limit entfernt.") - except (discord.Forbidden, discord.NotFound): - pass - return - - attachment_data: List[Tuple[str, bytes, str]] = [] - if message.attachments: - try: - await message.channel.trigger_typing() - for attachment in message.attachments: - 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}") - attachment_data = [] - - try: - await message.delete() - except discord.Forbidden: - logger.warning(f"⚠️ Keine Permissions zum Löschen der Original-Nachricht in {message.channel.id}") - except discord.NotFound: - pass - - 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") - async def setup_globalchat( - self, - ctx: discord.ApplicationContext, - channel: discord.TextChannel = Option(discord.TextChannel, "Der GlobalChat-Channel", required=True) - ): - if not ctx.author.guild_permissions.manage_guild: - await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) - return - - 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") - - if missing_perms: - await ctx.respond( - f"❌ Mir fehlen wichtige Berechtigungen in {channel.mention}:\n" + - "\n".join([f"• {p}" for p in missing_perms]), - ephemeral=True - ) - return - - try: - # ✅ await hinzugefügt - await db.set_globalchat_channel(ctx.guild.id, channel.id) - self.sender._cached_channels = await self.sender._fetch_all_channels() - - container = Container() - 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() - container.add_text( - "**Unterstützte Features:**\n" - "• 🖼️ Bilder, 🎥 Videos, 🎵 Audio\n" - "• 📄 Dokumente (Office, PDF, Archive)\n" - "• 🎨 Discord Sticker\n" - "• 🔗 Automatische Link-Previews\n" - "• ↩️ Reply auf andere Nachrichten\n\n" - "**Nächste Schritte:**\n" - "• `/globalchat settings` - Einstellungen anpassen\n" - "• `/globalchat stats` - Statistiken anzeigen\n" - "• `/globalchat media-info` - Medien-Limits anzeigen" - ) - 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") - async def remove_globalchat(self, ctx: discord.ApplicationContext): - if not ctx.author.guild_permissions.manage_guild: - await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) - return - - 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: - # ✅ 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" - f"Es sind nun noch **{len(self.sender._cached_channels)}** Server verbunden.", - ephemeral=True - ) - except Exception as e: - logger.error(f"❌ Remove-Fehler: {e}", exc_info=True) - await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) - - @globalchat.command(name="settings", description="Verwaltet Server-spezifische GlobalChat-Einstellungen") - async def settings_globalchat( - self, - ctx: discord.ApplicationContext, - filter_enabled: Optional[bool] = Option(bool, "Content-Filter aktivieren/deaktivieren", 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) - ): - if not ctx.author.guild_permissions.manage_guild: - await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) - return - - 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 = [] - # ✅ await hinzugefügt für alle update_guild_setting Aufrufe - if filter_enabled is not None: - 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 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: - 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 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 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 - - embed = discord.Embed( - title="✅ GlobalChat Einstellungen aktualisiert", - description="\n".join(updated), - color=discord.Color.green() - ) - await ctx.respond(embed=embed, ephemeral=True) - - @globalchat.command(name="ban", description="🔨 Bannt einen User oder Server vom GlobalChat") - async def globalchat_ban( - self, - ctx: discord.ApplicationContext, - entity_id: str = Option(str, "ID des Users oder Servers (Guild-ID)", required=True), - entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True), - reason: str = Option(str, "Grund für den Ban", required=True), - duration: Optional[int] = Option(int, "Dauer in Stunden (optional, permanent wenn leer)", required=False) - ): - if ctx.author.id not in self.config.BOT_OWNERS: - await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) - return - - try: - entity_id_int = int(entity_id) - except ValueError: - await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) - return - - try: - # ✅ 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 - - duration_text = f"{duration} Stunden" if duration else "Permanent" - embed = discord.Embed(title="🔨 GlobalChat-Ban verhängt", color=discord.Color.red(), timestamp=datetime.utcnow()) - embed.add_field(name="Typ", value=entity_type.title(), inline=True) - embed.add_field(name="ID", value=f"`{entity_id_int}`", inline=True) - embed.add_field(name="Dauer", value=duration_text, inline=True) - embed.add_field(name="Grund", value=reason, inline=False) - embed.add_field(name="Von", value=ctx.author.mention, inline=True) - if duration: - expires = datetime.utcnow() + timedelta(hours=duration) - embed.add_field(name="Läuft ab", value=f"", inline=True) - await ctx.respond(embed=embed) - logger.info(f"🔨 Ban: {entity_type} {entity_id_int} | Grund: {reason} | Dauer: {duration_text} | Von: {ctx.author.id}") - except Exception as e: - logger.error(f"❌ Ban-Fehler: {e}", exc_info=True) - await ctx.respond("❌ Ein Fehler ist aufgetreten beim Bannen!", ephemeral=True) - - @globalchat.command(name="unban", description="🔓 Entfernt einen User oder Server von der GlobalChat-Blacklist") - async def globalchat_unban( - self, - ctx: discord.ApplicationContext, - entity_id: str = Option(str, "ID des Users oder Servers", required=True), - entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True) - ): - if ctx.author.id not in self.config.BOT_OWNERS: - await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) - return - - try: - entity_id_int = int(entity_id) - except ValueError: - await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) - return - - try: - # ✅ 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 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.", - color=discord.Color.green(), - timestamp=datetime.utcnow() - ) - await ctx.respond(embed=embed) - logger.info(f"🔓 Unban: {entity_type} {entity_id_int} | Von: {ctx.author.id}") - else: - await ctx.respond("❌ Fehler beim Entfernen von der Blacklist!", ephemeral=True) - except Exception as e: - logger.error(f"❌ Unban-Fehler: {e}", exc_info=True) - await ctx.respond("❌ Ein Fehler ist aufgetreten beim Unbannen!", ephemeral=True) - - @globalchat.command(name="info", description="Zeigt Informationen über den GlobalChat") - async def globalchat_info(self, ctx: discord.ApplicationContext): - 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=( - "Ein serverübergreifendes Chat-System mit vollständigem Medien-Support.\n\n" - f"**📊 Aktuell verbunden:** **{len(active_servers)}** Server\n\n" - "**🎯 Hauptfeatures:**\n" - "• Nachrichten werden an alle verbundenen Server gesendet\n" - "• Vollständiger Medien-Support (Bilder, Videos, Audio, Dokumente)\n" - "• Discord Sticker und Link-Previews\n" - "• Reply-Unterstützung mit Kontext\n" - "• Automatische Moderation und Filter\n" - "• Rate-Limiting gegen Spam\n" - "• Individuelle Server-Einstellungen" - ), - color=discord.Color.blue(), - timestamp=datetime.utcnow() - ) - embed.add_field( - name="📁 Unterstützte Medien (Details: `/globalchat media-info`)", - value="• 🖼️ Bilder\n• 🎥 Videos\n• 🎵 Audio\n• 📄 Dokumente (PDF, Office, Archive)", - inline=True - ) - embed.add_field( - name="🛡️ Moderation", - value=( - f"• **Content-Filter:** {'✅ 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") - async def globalchat_stats(self, ctx: discord.ApplicationContext): - 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 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.add_field(name="🌍 Verbundene Server", value=f"**{len(active_servers)}**", inline=True) - embed.add_field(name="👥 Gebannte User", value=f"**{user_bans}**", inline=True) - embed.add_field(name="🛡️ Gebannte Server", value=f"**{guild_bans}**", inline=True) - embed.add_field(name="⏳ Cache-Dauer", value=f"{self.config.CACHE_DURATION} Sekunden", inline=True) - embed.add_field(name="📜 Protokoll Bereinigung", value=f"Alle {self.config.CLEANUP_DAYS} Tage", inline=True) - embed.add_field(name="⏰ Rate-Limit", value=f"{self.config.RATE_LIMIT_MESSAGES} Nachrichten / {self.config.RATE_LIMIT_SECONDS} Sekunden", inline=True) - await ctx.respond(embed=embed, ephemeral=True) - - @globalchat.command(name="media-info", description="Zeigt Details zu Medien-Limits und erlaubten Formaten") - async def globalchat_media_info(self, ctx: discord.ApplicationContext): - 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() - ) - 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\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 - ) - embed.add_field(name="🖼️ Bilder", value=", ".join(self.config.ALLOWED_IMAGE_FORMATS).upper(), inline=True) - embed.add_field(name="🎥 Videos", value=", ".join(self.config.ALLOWED_VIDEO_FORMATS).upper(), inline=True) - embed.add_field(name="🎵 Audio", value=", ".join(self.config.ALLOWED_AUDIO_FORMATS).upper(), inline=True) - embed.add_field(name="📄 Dokumente/Archive", value=", ".join(self.config.ALLOWED_DOCUMENT_FORMATS).upper(), inline=False) - await ctx.respond(embed=embed, ephemeral=True) - - @globalchat.command(name="help", description="Zeigt die Hilfe-Seite für GlobalChat") - async def globalchat_help(self, ctx: discord.ApplicationContext): - embed = discord.Embed( - title="❓ GlobalChat Hilfe & Übersicht", - description="Übersicht aller verfügbaren Commands und Features.", - color=discord.Color.blue(), - timestamp=datetime.utcnow() - ) - embed.add_field( - name="⚙️ Setup & Verwaltung", - value="`/globalchat setup` - Channel einrichten\n`/globalchat remove` - Channel entfernen\n`/globalchat settings` - Einstellungen anpassen", - inline=False - ) - embed.add_field( - name="📊 Informationen", - value="`/globalchat info` - Allgemeine Infos\n`/globalchat stats` - Statistiken anzeigen\n`/globalchat media-info` - Medien-Details\n`/globalchat help` - Diese Hilfe", - inline=False - ) - if ctx.author.id in self.config.BOT_OWNERS: - embed.add_field( - name="🛡️ Moderation (Bot Owner)", - value="`/globalchat ban` - User/Server bannen\n`/globalchat unban` - User/Server entbannen", - inline=False - ) - embed.add_field( - name="🧪 Test & Debug (Bot Owner)", - value="`/globalchat test-media` - Medien-Test\n`/globalchat broadcast` - Nachricht an alle senden\n`/globalchat reload-cache` - Cache neu laden\n`/globalchat debug` - Debug-Info", - inline=False - ) - await ctx.respond(embed=embed, ephemeral=True) - - @globalchat.command(name="test-media", description="🧪 Test-Command für Medien-Upload und -Anzeige") - async def globalchat_test_media(self, ctx: discord.ApplicationContext): - 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 - - embed = discord.Embed( - title="🧪 GlobalChat Medien-Test", - description=( - "Dieser Test zeigt dir, welche Medien-Typen erfolgreich übermittelt werden können.\n\n" - "**Unterstützte Medien:**\n• Bilder, Videos, Audio, Dokumente\n• Discord Sticker\n• Antworten auf andere Nachrichten\n\n" - "**So testest du:**\n" - f"1. Gehe zu <#{channel_id}> und sende eine Nachricht mit Anhängen.\n" - "2. Die Nachricht erscheint auf allen verbundenen Servern.\n\n" - "Probiere verschiedene Kombinationen aus!" - ), - color=discord.Color.green(), - timestamp=datetime.utcnow() - ) - embed.add_field( - name="📊 Aktuelle Limits", - 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,\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") - async def globalchat_broadcast( - self, - ctx: discord.ApplicationContext, - title: str = Option(str, "Der Titel der Broadcast-Nachricht", required=True), - message: str = Option(str, "Die Nachricht selbst", required=True) - ): - if ctx.author.id not in self.config.BOT_OWNERS: - await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) - return - - await ctx.defer(ephemeral=True) - try: - 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) - - successful, failed = await self.sender.send_global_broadcast_message(embed) - - 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**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 ''}", - inline=False - ) - await ctx.respond(embed=result_embed, ephemeral=True) - 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)") - async def globalchat_reload_cache(self, ctx: discord.ApplicationContext): - if ctx.author.id not in self.config.BOT_OWNERS: - await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) - return - - await ctx.defer(ephemeral=True) - try: - old_count = len(self.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\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)") - async def globalchat_debug(self, ctx: discord.ApplicationContext): - if ctx.author.id not in self.config.BOT_OWNERS: - await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) - return - - await ctx.defer(ephemeral=True) - try: - cached_channels = len(self.sender._cached_channels or []) - # ✅ 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" - f"• Guilds: `{len(self.bot.guilds)}`\n" - f"• Uptime: ``\n\n" - f"**GlobalChat-Status:**\n" - f"• Aktive Channels (Cache): `{cached_channels}`\n" - f"• DB Settings Einträge: `{len(all_settings)}`\n" - f"• Cleanup Task: `{'Aktiv' if self.cleanup_task.is_running() else 'Inaktiv'}`\n" - f"• Gebannte User/Server: `{user_bans} / {guild_bans}`" - ) - - embed = discord.Embed( - title="🐛 GlobalChat Debug-Informationen", - description=debug_info, - color=discord.Color.orange(), - timestamp=datetime.utcnow() - ) - await ctx.respond(embed=embed, ephemeral=True) - except Exception as e: - logger.error(f"❌ Debug Fehler: {e}", exc_info=True) - await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) - +from .globalchat_pkg._cog import GlobalChat def setup(bot): - bot.add_cog(GlobalChat(bot)) \ No newline at end of file + bot.add_cog(GlobalChat(bot)) diff --git a/src/bot/cogs/guild/globalchat_pkg/__init__.py b/src/bot/cogs/guild/globalchat_pkg/__init__.py new file mode 100644 index 0000000..5442a3a --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/__init__.py @@ -0,0 +1 @@ +# Defines this directory as a Python package diff --git a/src/bot/cogs/guild/globalchat_pkg/_cog.py b/src/bot/cogs/guild/globalchat_pkg/_cog.py new file mode 100644 index 0000000..630e6ac --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_cog.py @@ -0,0 +1,613 @@ +import discord +from discord.ext import commands, tasks +from discord import slash_command, Option, SlashCommandGroup +import ezcord +import asyncio +import logging +import random +from datetime import datetime, timedelta +from typing import Optional + +from mxmariadb import GlobalChatDatabase +from ._config import GlobalChatConfig +from ._validator import MessageValidator +from ._embeds import EmbedBuilder +from ._sender import GlobalChatSender + +logger = logging.getLogger(__name__) +db = GlobalChatDatabase() + +class GlobalChat(ezcord.Cog): + globalchat = SlashCommandGroup("globalchat", "GlobalChat Verwaltung") + + def __init__(self, bot): + self.bot = bot + self.config = GlobalChatConfig() + self.validator = MessageValidator(self.config) + self.embed_builder = EmbedBuilder(self.config, bot) + self.message_cooldown = commands.CooldownMapping.from_cooldown( + self.config.RATE_LIMIT_MESSAGES, + self.config.RATE_LIMIT_SECONDS, + commands.BucketType.user + ) + self.sender = GlobalChatSender(self.bot, self.config, self.embed_builder) + self.cleanup_task.start() + self.bot.loop.create_task(db.create_tables()) + + @tasks.loop(hours=12) + async def cleanup_task(self): + await self.sender._get_all_active_channels() + logger.info("🧠 GlobalChat: Channel-Cache neu geladen.") + + @discord.message_command(name="Nachricht melden") + async def report_message_context(self, ctx: discord.ApplicationContext, message: discord.Message): + """Kontextmenü-Befehl zum Melden einer GlobalChat-Nachricht.""" + if message.author.id != self.bot.user.id or not message.embeds: + await ctx.respond("❌ Das ist keine gültige GlobalChat-Nachricht.", ephemeral=True) + return + + embed = message.embeds[0] + footer_text = embed.footer.text if embed.footer else "" + + if not footer_text or "ID:" not in footer_text: + await ctx.respond("❌ Das ist keine gültige GlobalChat-Nachricht.", ephemeral=True) + return + + owners = getattr(self.config, 'BOT_OWNERS', [1093555256689959005, 1427994077332373554]) + report_embed = discord.Embed( + title="⚠️ GlobalChat Meldung (App-Command)", + description=f"Eine Nachricht wurde über das Kontextmenü gemeldet.\n\n" + f"**Gemeldete Nachricht Infos:**\n" + f"**Autor:** {embed.author.name if embed.author else 'Unbekannt'}\n" + f"**Herkunft (Footer):** `{footer_text}`\n" + f"**Melder:** {ctx.author.mention} (`{ctx.author.id}`)\n" + f"**Gemeldet auf Server:** {ctx.guild.name} (`{ctx.guild.id}`)", + color=discord.Color.red(), + timestamp=discord.utils.utcnow() + ) + + if embed.description: + report_embed.add_field(name="Nachrichten-Inhalt", value=embed.description[:1024], inline=False) + + if embed.image: + report_embed.set_image(url=embed.image.url) + + success_count = 0 + for owner_id in owners: + try: + owner = await self.bot.fetch_user(owner_id) + await owner.send(embed=report_embed) + success_count += 1 + except Exception: pass + + if success_count > 0: + await ctx.respond("✅ Danke! Die Nachricht wurde an das Moderations-Team weitergeleitet.", ephemeral=True) + else: + await ctx.respond("⚠️ Die Nachricht wurde erfasst, konnte aber aktuell keinem Admin zugestellt werden.", ephemeral=True) + + @ezcord.Cog.listener() + async def on_message(self, message: discord.Message): + if not message.guild or message.author.bot: + return + + global_chat_channel_id = await db.get_globalchat_channel(message.guild.id) + if message.channel.id != global_chat_channel_id: + return + + settings = await db.get_guild_settings(message.guild.id) + is_valid, reason = await self.validator.validate_message(message, settings) + + if not is_valid: + logger.debug(f"❌ Nachricht abgelehnt: {reason} (User: {message.author.id})") + if any(keyword in reason for keyword in ["Blacklist", "NSFW", "Gefilterte", "Ungültige Anhänge", "zu groß"]): + try: + await message.add_reaction("❌") + if "Ungültige Anhänge" in reason or "zu groß" in reason: + await message.reply( + f"❌ **Fehler:** {reason}\n" + f"**Max. Größe:** {self.config.MAX_FILE_SIZE_MB}MB pro Datei\n" + f"**Max. Anhänge:** {self.config.MAX_ATTACHMENTS}", + delete_after=7 + ) + await asyncio.sleep(2) + await message.delete() + except (discord.Forbidden, discord.NotFound): + pass + return + + from mxmariadb import EconomyDatabase + eco_db = EconomyDatabase() + user_info = await eco_db.get_user_economy_info(message.author.id) + last_msg_raw = user_info.get('last_message_at') if user_info else None + + can_earn = True + if last_msg_raw: + try: + 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 + if can_earn: + await eco_db.add_global_coins(message.author.id, random.randint(5, 15)) + await eco_db.update_last_message(message.author.id) + + bucket = self.message_cooldown.get_bucket(message) + retry_after = bucket.update_rate_limit() + if retry_after: + try: + await message.add_reaction("⏰") + await asyncio.sleep(2) + await message.delete() + logger.debug(f"⏰ Nachricht von {message.author.id} wegen Rate Limit entfernt.") + except (discord.Forbidden, discord.NotFound): + pass + return + + attachment_data = [] + if message.attachments: + try: + await message.channel.trigger_typing() + for attachment in message.attachments: + 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}") + attachment_data = [] + + try: + await message.delete() + except discord.Forbidden: + logger.warning(f"⚠️ Keine Permissions zum Löschen der Original-Nachricht in {message.channel.id}") + except discord.NotFound: + pass + + 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}") + + @globalchat.command(name="setup", description="Richtet einen GlobalChat-Channel ein") + async def setup_globalchat( + self, + ctx: discord.ApplicationContext, + channel: discord.TextChannel = Option(discord.TextChannel, "Der GlobalChat-Channel", required=True) + ): + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + 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") + + if missing_perms: + await ctx.respond( + f"❌ Mir fehlen wichtige Berechtigungen in {channel.mention}:\n" + + "\n".join([f"• {p}" for p in missing_perms]), + ephemeral=True + ) + return + + try: + await db.set_globalchat_channel(ctx.guild.id, channel.id) + self.sender._cached_channels = await self.sender._fetch_all_channels() + + from discord.ui import Container + container = Container() + 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() + container.add_text( + "**Unterstützte Features:**\n" + "• 🖼️ Bilder, 🎥 Videos, 🎵 Audio\n" + "• 📄 Dokumente (Office, PDF, Archive)\n" + "• 🎨 Discord Sticker\n" + "• 🔗 Automatische Link-Previews\n" + "• ↩️ Reply auf andere Nachrichten\n\n" + "**Nächste Schritte:**\n" + "• `/globalchat settings` - Einstellungen anpassen\n" + "• `/globalchat stats` - Statistiken anzeigen\n" + "• `/globalchat media-info` - Medien-Limits anzeigen" + ) + 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") + async def remove_globalchat(self, ctx: discord.ApplicationContext): + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + 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: + 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" + f"Es sind nun noch **{len(self.sender._cached_channels)}** Server verbunden.", + ephemeral=True + ) + except Exception as e: + logger.error(f"❌ Remove-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) + + @globalchat.command(name="settings", description="Verwaltet Server-spezifische GlobalChat-Einstellungen") + async def settings_globalchat( + self, + ctx: discord.ApplicationContext, + filter_enabled: Optional[bool] = Option(bool, "Content-Filter aktivieren/deaktivieren", 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) + ): + if not ctx.author.guild_permissions.manage_guild: + await ctx.respond("❌ Du benötigst die **Server verwalten** Berechtigung!", ephemeral=True) + return + + 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 + + import re + updated = [] + if filter_enabled is not None: + 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 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: + 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 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 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 + + embed = discord.Embed( + title="✅ GlobalChat Einstellungen aktualisiert", + description="\n".join(updated), + color=discord.Color.green() + ) + await ctx.respond(embed=embed, ephemeral=True) + + @globalchat.command(name="ban", description="🔨 Bannt einen User oder Server vom GlobalChat") + async def globalchat_ban( + self, + ctx: discord.ApplicationContext, + entity_id: str = Option(str, "ID des Users oder Servers (Guild-ID)", required=True), + entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True), + reason: str = Option(str, "Grund für den Ban", required=True), + duration: Optional[int] = Option(int, "Dauer in Stunden (optional, permanent wenn leer)", required=False) + ): + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + try: + entity_id_int = int(entity_id) + except ValueError: + await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) + return + + try: + 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 + + duration_text = f"{duration} Stunden" if duration else "Permanent" + embed = discord.Embed(title="🔨 GlobalChat-Ban verhängt", color=discord.Color.red(), timestamp=datetime.utcnow()) + embed.add_field(name="Typ", value=entity_type.title(), inline=True) + embed.add_field(name="ID", value=f"`{entity_id_int}`", inline=True) + embed.add_field(name="Dauer", value=duration_text, inline=True) + embed.add_field(name="Grund", value=reason, inline=False) + embed.add_field(name="Von", value=ctx.author.mention, inline=True) + if duration: + expires = datetime.utcnow() + timedelta(hours=duration) + embed.add_field(name="Läuft ab", value=f"", inline=True) + await ctx.respond(embed=embed) + logger.info(f"🔨 Ban: {entity_type} {entity_id_int} | Grund: {reason} | Dauer: {duration_text} | Von: {ctx.author.id}") + except Exception as e: + logger.error(f"❌ Ban-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten beim Bannen!", ephemeral=True) + + @globalchat.command(name="unban", description="🔓 Entfernt einen User oder Server von der GlobalChat-Blacklist") + async def globalchat_unban( + self, + ctx: discord.ApplicationContext, + entity_id: str = Option(str, "ID des Users oder Servers", required=True), + entity_type: str = Option(str, "Typ der Entität", choices=["user", "guild"], required=True) + ): + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + try: + entity_id_int = int(entity_id) + except ValueError: + await ctx.respond("❌ Ungültige ID. Erwarte eine Zahl.", ephemeral=True) + return + + try: + if not 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 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.", + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + await ctx.respond(embed=embed) + logger.info(f"🔓 Unban: {entity_type} {entity_id_int} | Von: {ctx.author.id}") + else: + await ctx.respond("❌ Fehler beim Entfernen von der Blacklist!", ephemeral=True) + except Exception as e: + logger.error(f"❌ Unban-Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten beim Unbannen!", ephemeral=True) + + @globalchat.command(name="info", description="Zeigt Informationen über den GlobalChat") + async def globalchat_info(self, ctx: discord.ApplicationContext): + active_servers = await self.sender._get_all_active_channels() + guild_settings = await db.get_guild_settings(ctx.guild.id) + + embed = discord.Embed( + title="🌍 GlobalChat - Vollständiger Medien-Support", + description=( + "Ein serverübergreifendes Chat-System mit vollständigem Medien-Support.\n\n" + f"**📊 Aktuell verbunden:** **{len(active_servers)}** Server\n\n" + "**🎯 Hauptfeatures:**\n" + "• Nachrichten werden an alle verbundenen Server gesendet\n" + "• Vollständiger Medien-Support (Bilder, Videos, Audio, Dokumente)\n" + "• Discord Sticker und Link-Previews\n" + "• Reply-Unterstützung mit Kontext\n" + "• Automatische Moderation und Filter\n" + "• Rate-Limiting gegen Spam\n" + "• Individuelle Server-Einstellungen" + ), + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + embed.add_field( + name="📁 Unterstützte Medien (Details: `/globalchat media-info`)", + value="• 🖼️ Bilder\n• 🎥 Videos\n• 🎵 Audio\n• 📄 Dokumente (PDF, Office, Archive)", + inline=True + ) + embed.add_field( + name="🛡️ Moderation", + value=( + f"• **Content-Filter:** {'✅ 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") + async def globalchat_stats(self, ctx: discord.ApplicationContext): + 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 = 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.add_field(name="🌍 Verbundene Server", value=f"**{len(active_servers)}**", inline=True) + embed.add_field(name="👥 Gebannte User", value=f"**{user_bans}**", inline=True) + embed.add_field(name="🛡️ Gebannte Server", value=f"**{guild_bans}**", inline=True) + embed.add_field(name="⏳ Cache-Dauer", value=f"{self.config.CACHE_DURATION} Sekunden", inline=True) + embed.add_field(name="📜 Protokoll Bereinigung", value=f"Alle {self.config.CLEANUP_DAYS} Tage", inline=True) + embed.add_field(name="⏰ Rate-Limit", value=f"{self.config.RATE_LIMIT_MESSAGES} Nachrichten / {self.config.RATE_LIMIT_SECONDS} Sekunden", inline=True) + await ctx.respond(embed=embed, ephemeral=True) + + @globalchat.command(name="media-info", description="Zeigt Details zu Medien-Limits und erlaubten Formaten") + async def globalchat_media_info(self, ctx: discord.ApplicationContext): + 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() + ) + 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\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 + ) + embed.add_field(name="🖼️ Bilder", value=", ".join(self.config.ALLOWED_IMAGE_FORMATS).upper(), inline=True) + embed.add_field(name="🎥 Videos", value=", ".join(self.config.ALLOWED_VIDEO_FORMATS).upper(), inline=True) + embed.add_field(name="🎵 Audio", value=", ".join(self.config.ALLOWED_AUDIO_FORMATS).upper(), inline=True) + embed.add_field(name="📄 Dokumente/Archive", value=", ".join(self.config.ALLOWED_DOCUMENT_FORMATS).upper(), inline=False) + await ctx.respond(embed=embed, ephemeral=True) + + @globalchat.command(name="help", description="Zeigt die Hilfe-Seite für GlobalChat") + async def globalchat_help(self, ctx: discord.ApplicationContext): + embed = discord.Embed( + title="❓ GlobalChat Hilfe & Übersicht", + description="Übersicht aller verfügbaren Commands und Features.", + color=discord.Color.blue(), + timestamp=datetime.utcnow() + ) + embed.add_field( + name="⚙️ Setup & Verwaltung", + value="`/globalchat setup` - Channel einrichten\n`/globalchat remove` - Channel entfernen\n`/globalchat settings` - Einstellungen anpassen", + inline=False + ) + embed.add_field( + name="📊 Informationen", + value="`/globalchat info` - Allgemeine Infos\n`/globalchat stats` - Statistiken anzeigen\n`/globalchat media-info` - Medien-Details\n`/globalchat help` - Diese Hilfe", + inline=False + ) + if ctx.author.id in self.config.BOT_OWNERS: + embed.add_field( + name="🛡️ Moderation (Bot Owner)", + value="`/globalchat ban` - User/Server bannen\n`/globalchat unban` - User/Server entbannen", + inline=False + ) + embed.add_field( + name="🧪 Test & Debug (Bot Owner)", + value="`/globalchat test-media` - Medien-Test\n`/globalchat broadcast` - Nachricht an alle senden\n`/globalchat reload-cache` - Cache neu laden\n`/globalchat debug` - Debug-Info", + inline=False + ) + await ctx.respond(embed=embed, ephemeral=True) + + @globalchat.command(name="test-media", description="🧪 Test-Command für Medien-Upload und -Anzeige") + async def globalchat_test_media(self, ctx: discord.ApplicationContext): + 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 + + embed = discord.Embed( + title="🧪 GlobalChat Medien-Test", + description=( + "Dieser Test zeigt dir, welche Medien-Typen erfolgreich übermittelt werden können.\n\n" + "**Unterstützte Medien:**\n• Bilder, Videos, Audio, Dokumente\n• Discord Sticker\n• Antworten auf andere Nachrichten\n\n" + "**So testest du:**\n" + f"1. Gehe zu <#{channel_id}> und sende eine Nachricht mit Anhängen.\n" + "2. Die Nachricht erscheint auf allen verbundenen Servern.\n\n" + "Probiere verschiedene Kombinationen aus!" + ), + color=discord.Color.green(), + timestamp=datetime.utcnow() + ) + embed.add_field( + name="📊 Aktuelle Limits", + 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,\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") + async def globalchat_broadcast( + self, + ctx: discord.ApplicationContext, + title: str = Option(str, "Der Titel der Broadcast-Nachricht", required=True), + message: str = Option(str, "Die Nachricht selbst", required=True) + ): + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + try: + 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) + + successful, failed = await self.sender.send_global_broadcast_message(embed) + + 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**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 ''}", + inline=False + ) + await ctx.respond(embed=result_embed, ephemeral=True) + 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)") + async def globalchat_reload_cache(self, ctx: discord.ApplicationContext): + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + try: + old_count = len(self.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\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)") + async def globalchat_debug(self, ctx: discord.ApplicationContext): + if ctx.author.id not in self.config.BOT_OWNERS: + await ctx.respond("❌ Nur Bot-Owner können diesen Befehl nutzen.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) + try: + cached_channels = len(self.sender._cached_channels or []) + 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" + f"• Guilds: `{len(self.bot.guilds)}`\n" + f"• Uptime: ``\n\n" + f"**GlobalChat-Status:**\n" + f"• Aktive Channels (Cache): `{cached_channels}`\n" + f"• DB Settings Einträge: `{len(all_settings)}`\n" + f"• Cleanup Task: `{'Aktiv' if self.cleanup_task.is_running() else 'Inaktiv'}`\n" + f"• Gebannte User/Server: `{user_bans} / {guild_bans}`" + ) + + embed = discord.Embed( + title="🐛 GlobalChat Debug-Informationen", + description=debug_info, + color=discord.Color.orange(), + timestamp=datetime.utcnow() + ) + await ctx.respond(embed=embed, ephemeral=True) + except Exception as e: + logger.error(f"❌ Debug Fehler: {e}", exc_info=True) + await ctx.respond("❌ Ein Fehler ist aufgetreten!", ephemeral=True) diff --git a/src/bot/cogs/guild/globalchat_pkg/_config.py b/src/bot/cogs/guild/globalchat_pkg/_config.py new file mode 100644 index 0000000..2a3795f --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_config.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025 OPPRO.NET Network + +class GlobalChatConfig: + 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'] + 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', 'porn', 'sex', 'xxx', 'nude', 'hentai', + 'dick', 'pussy', 'cock', 'tits', 'ass', 'fuck' + ] diff --git a/src/bot/cogs/guild/globalchat_pkg/_embeds.py b/src/bot/cogs/guild/globalchat_pkg/_embeds.py new file mode 100644 index 0000000..3d412e7 --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_embeds.py @@ -0,0 +1,259 @@ +import discord +import re +from typing import Dict, List, Tuple +from ._config import GlobalChatConfig +from ._media import MediaHandler + +class EmbedBuilder: + def __init__(self, config: GlobalChatConfig, bot=None): + self.config = config + self.media_handler = MediaHandler(config) + 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]]]: + if attachment_data is None: + attachment_data = [] + + content = self._clean_content(message.content) + embed_color = self._parse_color(settings.get('embed_color', self.config.DEFAULT_EMBED_COLOR)) + + if content: + description = f"{content}" + elif message.attachments or message.stickers or attachment_data: + description = "📎 *Medien-Nachricht*" + else: + description = "" + + embed = discord.Embed(description=description, color=embed_color, timestamp=message.created_at) + author_text, badges = self._build_author_info(message.author) + + from mxmariadb import EconomyDatabase + eco_db = EconomyDatabase() + overrides = await eco_db.get_equipped_overrides(message.author.id) + if 'color' in overrides: + embed_color = self._parse_color(overrides['color']) + embed.color = embed_color + if 'emoji' in overrides: + 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_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) + + if message.reference: + try: + replied_msg = message.reference.resolved + 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: + ref_channel = self.bot.get_channel(ref_chan_id) + if not ref_channel and message.guild: + try: + ref_channel = message.guild.get_channel(ref_chan_id) + except Exception: + ref_channel = None + if not ref_channel: + ref_channel = message.channel + if ref_channel: + try: + replied_msg = await ref_channel.fetch_message(message.reference.message_id) + except Exception: + replied_msg = None + + if isinstance(replied_msg, discord.Message): + preview = replied_msg.content or "" + if not preview and replied_msg.embeds: + try: + preview = replied_msg.embeds[0].description or "" + except Exception: + preview = "" + if not preview: + if replied_msg.attachments: + preview = f"📎 {len(replied_msg.attachments)} Datei(en)" + elif replied_msg.stickers: + preview = "🎨 Sticker" + else: + preview = "*(Leere Nachricht)*" + + preview = self._clean_content(preview) + preview_short = (preview[:200] + "...") if len(preview) > 200 else preview + + author_display = None + try: + if replied_msg.author and replied_msg.author.id == getattr(self.bot, 'user', None).id and replied_msg.embeds: + emb = replied_msg.embeds[0] + if emb.author and emb.author.name: + author_display = emb.author.name + except Exception: + author_display = None + + if not author_display: + try: + author_display = replied_msg.author.display_name + except Exception: + author_display = "Unbekannter User" + + origin = None + try: + if getattr(replied_msg, 'guild', None) and getattr(replied_msg, 'channel', None): + origin = f"{replied_msg.guild.name} • #{replied_msg.channel.name}" + except Exception: + origin = None + + reply_text = f"> **{author_display}**\n> {preview_short.replace(chr(10), chr(10)+'> ')}" + if origin: + reply_field_title = f"↩️ Antwort ({origin})" + else: + reply_field_title = "↩️ Antwort" + embed.add_field(name=reply_field_title, value=reply_text, inline=False) + except Exception: + pass + + 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]]: + if attachment_data is None: + attachment_data = [] + attachment_bytes: List[Tuple[str, bytes]] = [] + if attachment_data: + attachment_bytes.extend(self._process_downloaded_attachments(embed, attachment_data)) + if message.stickers: + self._process_stickers(embed, message.stickers) + 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]]: + attachment_bytes: List[Tuple[str, bytes]] = [] + images, videos, audios, documents, others = [], [], [], [], [] + + for filename, data, content_type in attachment_data: + category = self._get_attachment_category(filename, content_type) + if category == 'image': + images.append((filename, data)) + 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)) + + if images: + embed.set_image(url=f"attachment://{images[0][0]}") + for filename, data in images: + attachment_bytes.append((filename, data)) + if len(images) > 1: + embed.add_field(name="🖼️ Weitere Bilder", value=f"_{len(images)-1} zusätzliche Bilder angehängt._", inline=False) + + if videos: + video_links = [] + for video_name, video_data in videos: + video_links.append(f"🎥 {video_name} ({self.media_handler.format_file_size(len(video_data))})") + attachment_bytes.append((video_name, video_data)) + embed.add_field(name="🎬 Videos", value="\n".join(video_links[:3]), inline=False) + + if audios: + audio_links = [] + for audio_name, audio_data in audios: + 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 documents: + doc_links = [] + for doc_name, doc_data in documents: + doc_links.append(f"📄 {doc_name} ({self.media_handler.format_file_size(len(doc_data))})") + attachment_bytes.append((doc_name, doc_data)) + embed.add_field(name="📄 Dokumente", value="\n".join(doc_links[:3]), inline=False) + + if others: + other_links = [] + for other_name, other_data in others: + other_links.append(f"📎 {other_name} ({self.media_handler.format_file_size(len(other_data))})") + attachment_bytes.append((other_name, other_data)) + 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]): + 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) + 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]): + if not embeds: + return + link_embeds = [] + for embed in embeds: + if embed.type not in ['image', 'video', 'gifv'] and (embed.title or embed.description or embed.url): + title = embed.title or "Unbekannter Link" + description = (embed.description[:100] + "...") if embed.description else "" + url = embed.url or "" + link_embeds.append(f"**[{title}]({url})**\n_{description}_") + if link_embeds: + main_embed.add_field(name="🔗 Verlinkte Inhalte", value="\n\n".join(link_embeds), inline=False) + + def _get_attachment_category(self, filename: str, content_type: str) -> str: + if content_type.startswith('image/'): + return 'image' + elif content_type.startswith('video/'): + return 'video' + elif content_type.startswith('audio/'): + return 'audio' + if not filename or '.' not in filename: + return 'other' + file_ext = filename.split('.')[-1].lower() + if file_ext in self.config.ALLOWED_IMAGE_FORMATS: + return 'image' + elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: + return 'video' + elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: + return 'audio' + elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: + return 'document' + return 'other' + + def _clean_content(self, content: str) -> str: + if not content: + return "" + 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: + try: + 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]]: + badges, roles = [], [] + if author.id in self.config.BOT_OWNERS: + badges.append("👑") + roles.append("Bot Owner") + if author.guild_permissions.administrator: + badges.append("⚡") + roles.append("Admin") + elif author.guild_permissions.manage_guild: + badges.append("🔧") + roles.append("Mod") + if hasattr(author, 'premium_since') and author.premium_since: + badges.append("💎") + roles.append("Booster") + badge_text = " ".join(badges) + display = author.display_name + 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 diff --git a/src/bot/cogs/guild/globalchat_pkg/_media.py b/src/bot/cogs/guild/globalchat_pkg/_media.py new file mode 100644 index 0000000..ee69fa5 --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_media.py @@ -0,0 +1,52 @@ +import discord +from typing import List, Tuple +from ._config import GlobalChatConfig + +class MediaHandler: + def __init__(self, config: GlobalChatConfig): + self.config = config + + def validate_attachments(self, attachments: List[discord.Attachment]) -> Tuple[bool, str, List[discord.Attachment]]: + 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: + if attachment.size > max_size_bytes: + return False, f"Datei '{attachment.filename}' ist zu groß (max. {self.config.MAX_FILE_SIZE_MB}MB)", [] + file_ext = attachment.filename.split('.')[-1].lower() if '.' in attachment.filename else '' + all_allowed = ( + self.config.ALLOWED_IMAGE_FORMATS + self.config.ALLOWED_VIDEO_FORMATS + + self.config.ALLOWED_AUDIO_FORMATS + self.config.ALLOWED_DOCUMENT_FORMATS + ) + if file_ext and file_ext not in all_allowed: + return False, f"Dateiformat '.{file_ext}' nicht erlaubt", [] + valid_attachments.append(attachment) + return True, "", valid_attachments + + def categorize_attachment(self, attachment: discord.Attachment) -> str: + if not attachment.filename or '.' not in attachment.filename: + return 'other' + file_ext = attachment.filename.split('.')[-1].lower() + if file_ext in self.config.ALLOWED_IMAGE_FORMATS: + return 'image' + elif file_ext in self.config.ALLOWED_VIDEO_FORMATS: + return 'video' + elif file_ext in self.config.ALLOWED_AUDIO_FORMATS: + return 'audio' + elif file_ext in self.config.ALLOWED_DOCUMENT_FORMATS: + return 'document' + return 'other' + + def get_attachment_icon(self, attachment: discord.Attachment) -> str: + icons = {'image': '🖼️', 'video': '🎥', 'audio': '🎵', 'document': '📄', 'other': '📎'} + return icons.get(self.categorize_attachment(attachment), '📎') + + def format_file_size(self, size_bytes: int) -> str: + for unit in ['B', 'KB', 'MB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} GB" diff --git a/src/bot/cogs/guild/globalchat_pkg/_sender.py b/src/bot/cogs/guild/globalchat_pkg/_sender.py new file mode 100644 index 0000000..e92bfbe --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_sender.py @@ -0,0 +1,116 @@ +import discord +import asyncio +import io +import aiohttp +import logging +from typing import List, Tuple, Optional +from mxmariadb import GlobalChatDatabase +from ._config import GlobalChatConfig +from ._embeds import EmbedBuilder + +logger = logging.getLogger(__name__) +db = GlobalChatDatabase() + +class GlobalChatSender: + def __init__(self, bot, config: GlobalChatConfig, embed_builder: EmbedBuilder): + self.bot = bot + self.config = config + self.embed_builder = embed_builder + self._cached_channels: Optional[List[int]] = None + + async def _get_all_active_channels(self) -> List[int]: + 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]: + try: + 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: + try: + channel = self.bot.get_channel(channel_id) + if not channel: + try: + channel = await self.bot.fetch_channel(channel_id) + except Exception: + logger.warning(f"⚠️ Channel {channel_id} konnte nicht abgerufen werden.") + return False + + 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 + + files = [] + if attachment_bytes: + for filename, data in attachment_bytes: + try: + files.append(discord.File(io.BytesIO(data), filename=filename)) + except Exception as e: + logger.warning(f"⚠️ Error creating file {filename}: {e}") + + max_retries = 3 + for attempt in range(max_retries): + try: + if files: + await channel.send(embed=embed, files=files) + else: + await channel.send(embed=embed) + return True + except (ConnectionResetError, aiohttp.ClientConnectorError, asyncio.TimeoutError) as e: + logger.warning(f"❌ Sendefehler (Retry {attempt+1}/{max_retries}) in {channel_id}: {e}") + await asyncio.sleep(1 + attempt * 2) + except discord.Forbidden: + logger.warning(f"❌ Bot hat Senderechte in {channel_id} verloren.") + 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 + + 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]: + 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, failed_sends = 0, 0 + + # Batching (split into groups of 10 to reduce lag) + batch_size = 10 + for i in range(0, len(active_channels), batch_size): + current_batch = active_channels[i:i + batch_size] + task_list = [self._send_to_channel(channel_id, embed, files_to_upload) for channel_id in current_batch] + results = await asyncio.gather(*task_list, return_exceptions=True) + + for result in results: + if result is True: + successful_sends += 1 + else: + failed_sends += 1 + + await asyncio.sleep(0.1) # Prevents hitting rate limits too hard + + return successful_sends, failed_sends + + async def send_global_broadcast_message(self, embed: discord.Embed) -> Tuple[int, int]: + active_channels = await self._get_all_active_channels() + 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 diff --git a/src/bot/cogs/guild/globalchat_pkg/_validator.py b/src/bot/cogs/guild/globalchat_pkg/_validator.py new file mode 100644 index 0000000..70d95dd --- /dev/null +++ b/src/bot/cogs/guild/globalchat_pkg/_validator.py @@ -0,0 +1,78 @@ +import discord +import re +from typing import Tuple, Dict +from mxmariadb import GlobalChatDatabase +from ._config import GlobalChatConfig +from ._media import MediaHandler + +# Shared database instance for the validator +db = GlobalChatDatabase() + +class MessageValidator: + def __init__(self, config: GlobalChatConfig): + self.config = config + self.media_handler = MediaHandler(config) + self._compile_patterns() + + def _compile_patterns(self): + self.invite_pattern = re.compile(self.config.DISCORD_INVITE_PATTERN) + self.url_pattern = re.compile(self.config.URL_PATTERN) + + async def validate_message(self, message: discord.Message, settings: Dict) -> Tuple[bool, str]: + if message.author.bot: + return False, "Bot-Nachricht" + + if await db.is_blacklisted('user', message.author.id): + return False, "User auf Blacklist" + if await db.is_blacklisted('guild', message.guild.id): + return False, "Guild auf Blacklist" + + if not message.content and not message.attachments and not message.stickers: + return False, "Leere Nachricht" + + if message.content: + content_length = len(message.content.strip()) + 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)" + + if message.attachments: + valid, reason, _ = self.media_handler.validate_attachments(message.attachments) + if not valid: + return False, f"Ungültige Anhänge: {reason}" + + 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}" + + 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]: + if not content: + return False, "" + if self.invite_pattern.search(content): + return True, "Discord Invite" + return False, "" + + def check_nsfw_content(self, content: str) -> bool: + if not content: + return False + content_lower = content.lower() + for keyword in self.config.NSFW_KEYWORDS: + if re.search(r'\b' + re.escape(keyword) + r'\b', content_lower): + return True + return False + + def clean_content(self, content: str) -> str: + if not content: + return "" + content = content.replace('@everyone', '@everyone').replace('@here', '@here') + content = re.sub(r'<@&(\d+)>', r'@role', content) + return content diff --git a/src/bot/cogs/guild/news_sync.py b/src/bot/cogs/guild/news_sync.py index f96fe45..f5f548e 100644 --- a/src/bot/cogs/guild/news_sync.py +++ b/src/bot/cogs/guild/news_sync.py @@ -40,7 +40,9 @@ async def on_message(self, message: discord.Message): if message.channel.id == DEV_MASTER_CHANNEL_ID: targets = [c for c in all_channels if c['sync_group'] == 'dev_news' and not c['is_master']] if targets: - embed = self._build_dev_embed(message) + # Count unique guilds subscribed to dev_news + guild_count = len(set(c['guild_id'] for c in targets)) + embed = self._build_dev_embed(message, guild_count) await self._broadcast(targets, embed, message) return @@ -57,14 +59,18 @@ async def on_message(self, message: discord.Message): embed = self._build_network_embed(message) await self._broadcast(targets, embed, message) - def _build_dev_embed(self, message): + def _build_dev_embed(self, message, guild_count: int = 0): embed = discord.Embed( title="🛠️ **ManagerX Engineering Updates**", description=message.content or "*Bild-Nachricht*", color=discord.Color.gold(), timestamp=message.created_at ) - embed.set_footer(text=f"Official Developer Feed • {message.guild.name}") + footer_text = f"Official Developer Feed • {message.guild.name}" + if guild_count > 0: + footer_text += f" • Guilds: {guild_count}" + + embed.set_footer(text=footer_text) if message.attachments: embed.set_image(url=message.attachments[0].url) return embed diff --git a/src/bot/cogs/user/settings.py b/src/bot/cogs/user/settings.py index e847153..bfda966 100644 --- a/src/bot/cogs/user/settings.py +++ b/src/bot/cogs/user/settings.py @@ -23,7 +23,9 @@ ProfileDB, SettingsDB, AutoDeleteDB, AntiSpamDatabase, TempVCDatabase ) -from mxmariadb import GlobalChatDatabase +from mxmariadb import ( + GlobalChatDatabase, EconomyDatabase +) class Settings(ezcord.Cog): """Cog für Benutzereinstellungen, Sprache und Datenverwaltung.""" @@ -80,18 +82,17 @@ async def get_user_data(self, ctx: discord.ApplicationContext): try: # Daten aus den verschiedenen Modulen sammeln - # Hinweis: Manche Methoden müssen in deinen DB-Klassen existieren - export_data["content"]["settings"] = SettingsDB().get_user_language(uid) - export_data["content"]["profile"] = ProfileDB().get_user_profile(uid) - export_data["content"]["leveling"] = LevelDatabase().get_user_data(uid) - export_data["content"]["global_chat_history"] = global_db.get_user_message_history(uid, limit=50) + export_data["content"]["settings"] = await SettingsDB().get_user_language(uid) + export_data["content"]["profile"] = await ProfileDB().get_profile(uid) + export_data["content"]["economy"] = await EconomyDatabase().get_user_economy_info(uid) + export_data["content"]["global_chat_history"] = await GlobalChatDatabase().get_user_message_history(uid, limit=50) # Moderationsdaten (nur für diesen Server) warn_db = WarnDatabase(".") - export_data["content"]["local_warnings"] = warn_db.get_warnings(ctx.guild.id, uid) + export_data["content"]["local_warnings"] = await warn_db.get_warnings(ctx.guild.id, uid) notes_db = NotesDatabase(".") - export_data["content"]["local_notes"] = notes_db.get_notes(ctx.guild.id, uid) + export_data["content"]["local_notes"] = await notes_db.get_notes(ctx.guild.id, uid) except Exception as e: print(f"Export-Fehler: {e}") @@ -165,12 +166,13 @@ async def confirm_button(self, button: discord.ui.Button, interaction: discord.I try: # Löschung der persönlichen Daten await StatsDB().delete_user_data(uid) - LevelDatabase().delete_user_data(uid) - ProfileDB().delete_user_data(uid) - SettingsDB().delete_user_data(uid) - global_db.delete_user_data(uid) - AntiSpamDatabase().delete_user_data(uid) - TempVCDatabase().delete_user_data(uid) + await LevelDatabase().delete_user_data(uid) + await ProfileDB().delete_user_data(uid) + await SettingsDB().delete_user_data(uid) + await GlobalChatDatabase().delete_user_data(uid) + await AntiSpamDatabase().delete_user_data(uid) + await TempVCDatabase().delete_user_data(uid) + await EconomyDatabase().delete_user_data(uid) # Moderation (Warns/Notes) wird hier NICHT gelöscht! diff --git a/src/bot/cogs/user/stats.py b/src/bot/cogs/user/stats.py index d8d263a..76974a2 100644 --- a/src/bot/cogs/user/stats.py +++ b/src/bot/cogs/user/stats.py @@ -68,6 +68,26 @@ async def before_monthly_reset(self): async def on_ready(self): """Called when the bot is ready and connected to Discord.""" logger.info(f"Enhanced StatsCog ready - Bot connected as {self.bot.user}") + await self.sync_active_voice_sessions() + + async def sync_active_voice_sessions(self): + """Synchronisiert die aktiven Voice-Sessions beim Bot-Start, um tote Sessions zu entfernen.""" + try: + logger.info("Synchronisiere aktive Voice-Sessions...") + # 1. Alle alten Sessions löschen + await self.db.clear_active_voice_sessions() + + # 2. Alle aktuellen Voice-Mitglieder scannen und eintragen + count = 0 + for guild in self.bot.guilds: + for voice_channel in guild.voice_channels: + for member in voice_channel.members: + if not member.bot: + await self.db.start_voice_session(member.id, guild.id, voice_channel.id) + count += 1 + logger.info(f"Erfolgreich {count} aktive Voice-Sessions beim Bot-Start synchronisiert.") + except Exception as e: + logger.error(f"Fehler bei der Voice-Session-Synchronisierung: {e}") @commands.Cog.listener() async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, diff --git a/src/bot/core/dashboard.py b/src/bot/core/dashboard.py index 9b0f39c..cc155ed 100644 --- a/src/bot/core/dashboard.py +++ b/src/bot/core/dashboard.py @@ -21,6 +21,7 @@ def __init__(self, bot, basedir: Path): self.basedir = basedir self.stats_file = basedir / 'data' / 'bot_stats.json' self._task = None + self._last_daily_log = None # Task definieren @tasks.loop(minutes=1) @@ -56,6 +57,19 @@ async def _update_stats(self): # In Datei schreiben with open(self.stats_file, 'w', encoding='utf-8') as f: json.dump(stats, f, indent=4, ensure_ascii=False) + + # Daily Log in Database + today = datetime.now().date() + if self._last_daily_log != today: + if hasattr(self.bot, 'cms_db'): + await self.bot.cms_db.log_daily_stats( + guild_count=stats["stats"]["server_count"], + user_count=stats["stats"]["user_count"], + command_count=stats["stats"]["commands"], + avg_latency=self.bot.latency + ) + self._last_daily_log = today + logger.info(Category.DATABASE, "Tägliche Statistiken geloggt") except Exception as e: logger.error(Category.DISCORD_BOT, f"Dashboard-Update fehlgeschlagen: {e}") diff --git a/src/bot/core/database.py b/src/bot/core/database.py index 749d711..95ca700 100644 --- a/src/bot/core/database.py +++ b/src/bot/core/database.py @@ -9,11 +9,12 @@ from logger import logger, Category try: - from mxmariadb import SettingsDB, StatsDB + from mxmariadb import SettingsDB, StatsDB, CMSDatabase except ImportError as e: logger.critical(Category.DATABASE, f"Database Imports fehlgeschlagen: {e}") SettingsDB = None StatsDB = None + CMSDatabase = None class DatabaseManager: """Verwaltet die Datenbank-Initialisierung""" @@ -44,6 +45,12 @@ def initialize(self, bot) -> bool: bot.stats_db = StatsDB() logger.success(Category.DATABASE, "Stats Database initialized ✓") + if CMSDatabase: + bot.cms_db = CMSDatabase() + # Initialize CMS tables + bot.loop.create_task(bot.cms_db.init_db()) + logger.success(Category.DATABASE, "CMS Database initialized ✓") + return True except Exception as e: diff --git a/src/web/App.tsx b/src/web/App.tsx index 28433fb..eeacd67 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -4,8 +4,9 @@ import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { AuthProvider } from "./components/core/AuthProvider"; import { ErrorBoundary } from "./components/core/ErrorBoundary"; +import { Toaster } from "sonner"; -// Lazy load all route components for better performance +// Lazy load all route components const Index = lazy(() => import("./pages/Index")); const NotFound = lazy(() => import("./pages/NotFound")); const Impressum = lazy(() => import("./pages/Impressum")); @@ -17,7 +18,7 @@ const CommandsPage = lazy(() => import("./pages/CommandsPage")); const TeamPage = lazy(() => import("./pages/TeamPage")); const RoadmapPage = lazy(() => import("./pages/RoadmapPage")); const LeaderboardPage = lazy(() => import("./pages/LeaderboardPage")); -const License = lazy(() => import("./pages/License").then(module => ({ default: module.License }))); +const License = lazy(() => import("./pages/License").then(m => ({ default: m.License }))); const LoginPage = lazy(() => import("./dashboard/auth/LoginPage")); const SettingsPage = lazy(() => import("./dashboard/settings/SettingsPage")); const UserSettingsPage = lazy(() => import("./dashboard/settings/UserSettingsPage")); @@ -27,17 +28,24 @@ const BlogPage = lazy(() => import("./pages/BlogPage")); const CMSPage = lazy(() => import("./dashboard/cms/CMSPage")); const AdminPage = lazy(() => import("./dashboard/admin/AdminPage")); -const queryClient = new QueryClient(); +// QueryClient with sane defaults — avoids hammering the API on every mount +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 2, // 2 min: don't refetch if data is fresh + gcTime: 1000 * 60 * 10, // 10 min: keep unused data in cache + retry: 1, // only 1 retry on failure (default is 3) + refetchOnWindowFocus: false, // don't refetch just because user switched tabs + }, + }, +}); const PageLoader = () => (
(
); -const DashboardRoutes = () => { +// Shared page-transition wrapper +const PageTransition = ({ children }: { children: React.ReactNode }) => { const location = useLocation(); - return ( { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - transition={{ - duration: 0.25, - ease: "easeInOut", - }} + transition={{ duration: 0.2, ease: "easeInOut" }} > - }> - - } /> - } /> - } /> - } /> - } /> - } /> - - + {children} ); }; -const MainRoutes = () => { - const location = useLocation(); - - return ( - - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - ); -}; +// Single unified route tree — no duplication, no double-render +const AppRoutes = () => ( + + }> + + {/* ── Public ── */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> -const AppContent = () => { - const hostname = window.location.hostname; - const isDashboard = hostname.startsWith("dashboard."); + {/* ── Legal ── */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> - // Wenn wir auf der Dashboard Subdomain sind - if (isDashboard) { - return ; - } + {/* ── Auth ── */} + } /> + } /> + } /> - // Normale Webseite (Haupt-Domain) - return ; -}; + {/* ── Dashboard ── */} + } /> + } /> + } /> + } /> + } /> + {/* ── Fallback ── */} + } /> + + + +); const App = () => ( - + + ); -export default App; +export default App; \ No newline at end of file diff --git a/src/web/dashboard/admin/AdminAnalytics.tsx b/src/web/dashboard/admin/AdminAnalytics.tsx index a67db97..fa11ad7 100644 --- a/src/web/dashboard/admin/AdminAnalytics.tsx +++ b/src/web/dashboard/admin/AdminAnalytics.tsx @@ -26,7 +26,7 @@ export default function AdminAnalytics({ onClose }: AdminAnalyticsProps) { const fetchStats = async () => { setLoading(true); try { - const res = await fetch(`${API_URL}/dashboard/admin/top-commands`, { + const res = await fetch(`${API_URL}/admin/top-commands`, { headers: { "Authorization": `Bearer ${token}` } }); const json = await res.json(); diff --git a/src/web/dashboard/admin/AdminBlacklist.tsx b/src/web/dashboard/admin/AdminBlacklist.tsx index 7de8078..06bc80b 100644 --- a/src/web/dashboard/admin/AdminBlacklist.tsx +++ b/src/web/dashboard/admin/AdminBlacklist.tsx @@ -35,7 +35,7 @@ export default function AdminBlacklist({ onClose }: AdminBlacklistProps) { const fetchBlacklist = async () => { try { - const res = await fetch(`${API_URL}/dashboard/admin/blacklist`, { + const res = await fetch(`${API_URL}/admin/blacklist`, { headers: { "Authorization": `Bearer ${token}` } }); const json = await res.json(); @@ -52,7 +52,7 @@ export default function AdminBlacklist({ onClose }: AdminBlacklistProps) { if (!formData.user_id) return toast.error("Bitte eine Discord-ID eingeben"); try { - const res = await fetch(`${API_URL}/dashboard/admin/blacklist`, { + const res = await fetch(`${API_URL}/admin/blacklist`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, @@ -80,7 +80,7 @@ export default function AdminBlacklist({ onClose }: AdminBlacklistProps) { setEntries(entries.filter(e => e.user_id !== userId)); try { - const res = await fetch(`${API_URL}/dashboard/admin/blacklist/${userId}`, { + const res = await fetch(`${API_URL}/admin/blacklist/${userId}`, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` } }); diff --git a/src/web/dashboard/admin/AdminGlobalChat.tsx b/src/web/dashboard/admin/AdminGlobalChat.tsx index e370ece..bd5c0db 100644 --- a/src/web/dashboard/admin/AdminGlobalChat.tsx +++ b/src/web/dashboard/admin/AdminGlobalChat.tsx @@ -40,7 +40,7 @@ export default function AdminGlobalChat({ onClose }: AdminGlobalChatProps) { setLoading(true); try { const endpoint = activeTab === 'logs' ? 'logs' : 'blacklist'; - const res = await fetch(`${API_URL}/dashboard/admin/global-chat/${endpoint}`, { + const res = await fetch(`${API_URL}/admin/global-chat/${endpoint}`, { headers: { "Authorization": `Bearer ${token}` } }); const json = await res.json(); diff --git a/src/web/dashboard/admin/AdminPage.tsx b/src/web/dashboard/admin/AdminPage.tsx index 49dac4e..f12cbce 100644 --- a/src/web/dashboard/admin/AdminPage.tsx +++ b/src/web/dashboard/admin/AdminPage.tsx @@ -47,7 +47,7 @@ const AdminPage = () => { useEffect(() => { const fetchStats = async () => { try { - const res = await fetch(`${API_URL}/dashboard/admin/global-stats`, { + const res = await fetch(`${API_URL}/admin/global-stats`, { headers: { "Authorization": `Bearer ${token}` } diff --git a/src/web/dashboard/auth/LoginPage.tsx b/src/web/dashboard/auth/LoginPage.tsx index 09c20a1..fc73e6c 100644 --- a/src/web/dashboard/auth/LoginPage.tsx +++ b/src/web/dashboard/auth/LoginPage.tsx @@ -1,32 +1,38 @@ import React, { useState, useEffect } from "react"; import { useNavigate, Link, useLocation } from "react-router-dom"; -import { - Mail, - Lock, - LayoutDashboard, - ShieldCheck, - Zap, - ArrowRight, - MessageSquare, - Globe, +import { + Lock, + LayoutDashboard, + ShieldCheck, + Zap, + ArrowRight, + MessageSquare, + Globe, Settings, Sparkles, - Shield + Shield, + Mail } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "../../lib/utils"; +import { motion } from "framer-motion"; import { toast } from "sonner"; import { useAuth } from "../../components/core/AuthProvider"; import { API_URL } from "../../lib/api"; -const FeatureItem = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => ( -
-
- +const FeatureItem = ({ icon: Icon, title, description }: { icon: React.ElementType; title: string; description: string }) => ( +
(e.currentTarget.style.background = "rgba(255,255,255,0.04)")} + onMouseLeave={e => (e.currentTarget.style.background = "transparent")} + > +
+
-

{title}

-

{description}

+

{title}

+

{description}

); @@ -47,12 +53,10 @@ export default function LoginPage() { } }, [location]); - const handleLogin = async () => { + const handleDiscordLogin = async () => { try { - const apiUrl = `${API_URL}/dashboard/auth/login`; - - const res = await fetch(apiUrl); - if (!res.ok) throw new Error("Could not fetch login URL"); + const res = await fetch(`${API_URL}/dashboard/auth/login`); + if (!res.ok) throw new Error("Keine Antwort vom Server"); const data = await res.json(); if (data.url) { window.location.href = data.url; @@ -61,7 +65,7 @@ export default function LoginPage() { } } catch (e) { console.error(e); - toast.error("Fehler beim Verbinden mit dem Authentifizierungs-Server."); + toast.error("Verbindungsfehler zum Authentifizierungs-Server."); } }; @@ -70,19 +74,19 @@ export default function LoginPage() { setLoading(true); try { const res = await fetch(`${API_URL}/dashboard/auth/login/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), }); const data = await res.json(); if (data.access_token) { - login(data.access_token, data.user, undefined, true); // true = session only + login(data.access_token, data.user, undefined, true); toast.success("Admin-Session gestartet!"); navigate("/dash/admin"); } else { toast.error(data.detail || "Login fehlgeschlagen"); } - } catch (e) { + } catch { toast.error("Verbindungsfehler"); } finally { setLoading(false); @@ -90,218 +94,378 @@ export default function LoginPage() { }; return ( -
- {/* Background Decoration */} -
-
-
+
+ {/* Background gradients */} +
+
- {/* Floating Blobs */} -
-
+ {/* Floating blobs */} +
+
-
-
- - {/* Left Side: Branding & Info */} +
+
+ {/* Left: Branding */} -
- -
- +
+ +
+
-

- ManagerX +

+ ManagerX

-

Dashboard

+

Dashboard

-

+

Verwalte dein Universum
- mit Leichtigkeit. + mit Leichtigkeit.

-

+

Erlebe volle Kontrolle über deine Community. Schnell, sicher und intuitiv – direkt in deinem Browser.

-
- - - +
+ + +
-
-
- +
+
+ Dutzende Server weltweit
-
- +
+ Support-Community
- {/* Right Side: Login Card */} + {/* Right: Login Card */} -
- {/* Card Decoration */} -
+
+
-
-
-
- - System-Vorschau -
-

Willkommen zurück

-

Logge dich ein, um dein Dashboard zu verwalten

+ {/* Header */} +
+
+ + System-Vorschau
+

+ Willkommen zurück +

+

+ Logge dich ein, um dein Dashboard zu verwalten +

+
-
- -
- - - -
- Logge dich über Discord ein - -
- -
-
- ODER -
-
+ {/* Discord Button */} + (e.currentTarget.style.background = "#4752C4")} + onMouseLeave={e => (e.currentTarget.style.background = "#5865F2")} + > +
+ + + +
+ Logge dich über Discord ein + +
- {showAdminLogin && ( - -
-
- - setEmail(e.target.value)} - required - className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-medium" - /> -
-
-
-
- - setPassword(e.target.value)} - required - className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-medium" - /> -
-
- - {loading ? "Wird geprüft..." : "Admin Login"} - -
- )} + {/* Divider */} +
+
+ ODER +
+
-
-
- VORSCHAU MODUS -
+ {/* Admin Login (hidden by default) */} + {showAdminLogin && ( + +
+ + setEmail(e.target.value)} + required + style={{ + width: "100%", boxSizing: "border-box", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "1rem", padding: "0.875rem 1rem 0.875rem 3rem", + color: "white", fontSize: "0.875rem", outline: "none", + transition: "border-color 0.2s" + }} + onFocus={e => (e.target.style.borderColor = "rgba(220,38,38,0.5)")} + onBlur={e => (e.target.style.borderColor = "rgba(255,255,255,0.1)")} + />
- -
- - +
+ + setPassword(e.target.value)} + required + style={{ + width: "100%", boxSizing: "border-box", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "1rem", padding: "0.875rem 1rem 0.875rem 3rem", + color: "white", fontSize: "0.875rem", outline: "none", + transition: "border-color 0.2s" + }} + onFocus={e => (e.target.style.borderColor = "rgba(220,38,38,0.5)")} + onBlur={e => (e.target.style.borderColor = "rgba(255,255,255,0.1)")} + />
-
+ + {loading ? "Wird geprüft..." : "Admin Login"} + +
+ )} -
-
- -

- ManagerX fragt nicht nach deinem Passwort. Der Login erfolgt sicher über das offizielle Discord OAuth2 System. -

-
-
+ {/* Preview mode buttons */} +
+
+ VORSCHAU MODUS +
+
+
+ {[ + { icon: Settings, label: "Einstellungen" }, + { icon: LayoutDashboard, label: "Module" } + ].map(({ icon: Icon, label }) => ( + + ))} +
+ + {/* Security notice */} +
+ +

+ ManagerX fragt nicht nach deinem Passwort. + Der Login erfolgt sicher über das offizielle Discord OAuth2 System. +

- {/* Support Links */} + {/* Footer links */} - Datenschutz - Nutzungsbedingungen - Hilfe erhalten + {[ + { to: "/legal/privacy", label: "Datenschutz" }, + { to: "/legal/terms", label: "Nutzungsbedingungen" }, + ].map(({ to, label }) => ( + (e.currentTarget.style.color = "white")} + onMouseLeave={e => (e.currentTarget.style.color = "hsl(240 5% 65%)")} + >{label} + ))} + (e.currentTarget.style.color = "white")} + onMouseLeave={e => (e.currentTarget.style.color = "hsl(240 5% 65%)")} + >Hilfe erhalten -
- {/* Footer Branding */} -
-

+ {/* Footer */} +

+

© 2026 OPPRO.NET DEVELOPMENT | ManagerX Dashboard

+ +
); } diff --git a/src/web/hooks/useStats.ts b/src/web/hooks/useStats.ts index 434254a..829f7c0 100644 --- a/src/web/hooks/useStats.ts +++ b/src/web/hooks/useStats.ts @@ -30,7 +30,7 @@ export const useStats = () => { useEffect(() => { const fetchStats = async () => { try { - const response = await fetch(`${API_URL}/v1/managerx/stats`); + const response = await fetch(`${API_URL}/public/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 index b1567e5..d99a6f4 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -1,5 +1,5 @@ // @ts-expect-error - import.meta is handled by Vite export const API_URL = import.meta.env.VITE_API_URL || (typeof window !== 'undefined' && window.location.hostname === 'localhost' - ? "http://localhost:8040" - : "https://api.managerx-bot.de"); + ? "http://localhost:8040/v1" + : "https://api.managerx-bot.de/v1"); diff --git a/src/web/pages/LeaderboardPage.tsx b/src/web/pages/LeaderboardPage.tsx index 305134b..4602fc1 100644 --- a/src/web/pages/LeaderboardPage.tsx +++ b/src/web/pages/LeaderboardPage.tsx @@ -27,7 +27,7 @@ export const LeaderboardPage = memo(function LeaderboardPage() { useEffect(() => { const fetchLeaderboard = async () => { try { - const response = await fetch(`${API_URL}/v1/managerx/leaderboard`); + const response = await fetch(`${API_URL}/public/leaderboard`); if (response.ok) { const data = await response.json(); if (data.success) { diff --git a/src/web/pages/Status.tsx b/src/web/pages/Status.tsx index 56fc89a..b124b0d 100644 --- a/src/web/pages/Status.tsx +++ b/src/web/pages/Status.tsx @@ -28,7 +28,7 @@ const Status = memo(function Status() { useEffect(() => { const fetchStatus = async () => { try { - const response = await fetch(`${API_URL}/v1/managerx/stats`); + const response = await fetch(`${API_URL}/public/stats`); if (!response.ok) throw new Error("Offline"); const result = await response.json(); diff --git a/vite.config.ts b/vite.config.ts index 5750892..ae5d2c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,7 +24,7 @@ export default defineConfig(({ mode }) => ({ }, }, build: { - outDir: "../../dist", + outDir: "../../build", emptyOutDir: true, // ÄNDERUNG: Wir entfernen 'esbuild' als Minifier, da Vite 8 @@ -42,7 +42,7 @@ export default defineConfig(({ mode }) => ({ }, output: { manualChunks: { - "vendor-react": ["react", "react-dom", "react-router-dom", "react-helmet-async"], + "vendor-react": ["react", "react-dom", "react-router-dom", "react-helmet-async", "react-is"], "vendor-framer": ["framer-motion"], "vendor-ui": [ "@radix-ui/react-accordion",