From 9d91d7120bc10eedde31e8302ee03e890a7eb210 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:57:59 +0100 Subject: [PATCH] modified: .gitignore modified: Dockerfile modified: README.md modified: app.py --- .gitignore | 5 +- Dockerfile | 2 +- README.md | 63 +++------------ app.py | 224 ++++++++--------------------------------------------- 4 files changed, 49 insertions(+), 245 deletions(-) diff --git a/.gitignore b/.gitignore index 014ebbc..9ea1022 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,7 @@ venv.bak/ dmypy.json # Pyre type checker -.pyre/ \ No newline at end of file +.pyre/ + +# Secrets and local cookies +cookies.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7bf6410..f0f7e38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,8 @@ ENV DB_PORT=$DB_PORT ENV DB_NAME=$DB_NAME ENV DB_USER=$DB_USER ENV DB_PASSWORD=$DB_PASSWORD -ENV YTDL_COOKIES_B64=$YTDL_COOKIES_B64 ENV YTDL_COOKIES_FILE=$YTDL_COOKIES_FILE +ENV YTDL_COOKIES_B64=$YTDL_COOKIES_B64 ENV YTDL_COOKIES_FROM_BROWSER=$YTDL_COOKIES_FROM_BROWSER ENV YTDL_UA=$YTDL_UA ENV YTDL_YT_CLIENT=$YTDL_YT_CLIENT diff --git a/README.md b/README.md index b771ce2..ce0aacf 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,18 @@ Ein Discord Bot für Hearts of Iron IV, der über Coolify deployed werden kann. ## Features -- ELO-System (Standard/Competitive), Spiele-Lifecycle (/hoi4create, /hoi4setup, /hoi4end) -- Stats, History und Leaderboard -- Emoji-/Länder-Tag-Unterstützung (tags.txt, emotes.markdown) -- Automatische Rollenvergabe nach ELO (pro Kategorie) -- YouTube Musikplayer (yt-dlp + ffmpeg) mit Queue und Lautstärke -- Docker/Coolify ready +- **Basic Commands**: Ping, Info, Server-Informationen +- **HOI4 Theme**: Speziell für Hearts of Iron IV Communities +- **Docker Support**: Bereit für Deployment über Coolify +- **Error Handling**: Robuste Fehlerbehandlung +- **Logging**: Ausführliche Logs für Debugging -## Verfügbare Befehle (Auszug) +## Verfügbare Befehle -- `/hoi4create ` – Neues Spiel anlegen (type: standard/competitive) -- `/hoi4setup [country]` – Spieler hinzufügen (T1/2/3; country optional als HOI4-Tag) -- `/hoi4end ` – Spiel beenden, ELO berechnen, Rollen syncen -- `/hoi4stats [user]` – ELO, Rank, Win/Draw/Loss -- `/hoi4history [limit] [player] [name] [type]` – Abgeschlossene Spiele filtern -- `/hoi4leaderboard [type] [limit]` – Top ELOs -- Musik: `/join`, `/leave`, `/play `, `/skip`, `/stop`, `/pause`, `/resume`, `/queue`, `/np`, `/volume <0-200>` -- Diagnose: `/webtest [query]` – Testet Web-Konnektivität und yt-dlp-Extraction +- `!ping` - Zeigt die Bot-Latenz an +- `!info` - Zeigt Bot-Informationen an +- `!help_hoi4` - Zeigt alle verfügbaren Befehle an +- `!server_info` - Zeigt Informationen über den aktuellen Server ## Setup für Coolify @@ -43,13 +38,8 @@ Der Bot benötigt folgende Permissions: ### 3. Coolify Deployment 1. **Repository**: Verbinde dein Git Repository mit Coolify -2. **Umgebungsvariablen**: Setze folgende Variablen in Coolify: +2. **Umgebungsvariablen**: Setze folgende Variable in Coolify: - `DISCORD_TOKEN` = Dein Bot Token - - Entweder `DATABASE_URL` (mysql://user:pass@host:port/db) ODER Einzelwerte `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - - Für YouTube (optional, empfohlen bei „Sign in to confirm you’re not a bot“): - - `YTDL_COOKIES_B64` = Base64-kodierte cookies.txt (Netscape-Format), ODER - - `YTDL_COOKIES_FILE` = Pfad zu cookies.txt im Container (z. B. /app/cookies.txt) - - Optional: `YTDL_UA` (User-Agent), `YTDL_YT_CLIENT` (z. B. android/web) 3. **Build Settings**: - Coolify wird automatisch das Dockerfile verwenden @@ -86,37 +76,6 @@ Der Bot benötigt folgende Permissions: python app.py ``` -### YouTube-Cookies bereitstellen (empfohlen) - -YouTube kann Server/Container ohne Cookies blockieren („Sign in to confirm you’re not a bot“). Übergib Cookies im Netscape-Format: - -Variante A – Base64 via Env (einfach in Coolify) - -1. Exportiere cookies.txt aus deinem Browser (Erweiterung „Get cookies.txt“ oder yt-dlp Export aus lokaler Browser-Session). -2. Base64-kodiere die Datei (Windows PowerShell): - -```powershell -[Convert]::ToBase64String([IO.File]::ReadAllBytes("cookies.txt")) | Set-Clipboard -``` - -3. In Coolify `YTDL_COOKIES_B64` setzen (Inhalt aus der Zwischenablage einfügen) und neu deployen. - -Variante B – Datei mounten - -- cookies.txt in den Container mounten (z. B. `/app/cookies.txt`) und `YTDL_COOKIES_FILE=/app/cookies.txt` setzen. - -Optional: `YTDL_UA` setzen (aktueller Browser UA), `YTDL_YT_CLIENT` (z. B. `android`). - -### Web testen (/webtest) - -Mit `/webtest` prüft der Bot: - -- HTTP-Reachability (Google 204, YouTube, Discord API) -- yt-dlp-Extraction mit deiner Query oder einem Default-Suchbegriff -- ob Cookies konfiguriert sind (Datei/B64/Browser) - -So kannst du schnell sehen, ob der Container Internetzugriff hat und ob yt-dlp wegen Cookies scheitert. - ## Docker ### Lokal mit Docker testen diff --git a/app.py b/app.py index 12e9da8..5e1ea87 100644 --- a/app.py +++ b/app.py @@ -11,8 +11,6 @@ from typing import Optional, List, Dict from concurrent.futures import ThreadPoolExecutor import functools import traceback -import aiohttp -import time import base64 from pathlib import Path @@ -374,40 +372,33 @@ except Exception as e: print(f"⚠️ Cookie configuration error: {e}") YTDL_OPTS = { - 'format': 'bestaudio[ext=m4a]/bestaudio/best', + 'format': 'bestaudio/best', 'noplaylist': True, 'quiet': True, 'default_search': 'ytsearch', 'skip_download': True, } -def get_ytdl_opts(client_hint: Optional[str] = None, use_cookies: bool = True) -> Dict: - """Build yt-dlp options dynamically, injecting cookies and headers if configured. - client_hint: preferred YouTube client (e.g., 'android', 'web', 'ios', 'tv'). - """ +def get_ytdl_opts() -> Dict: + """Build yt-dlp options dynamically, injecting cookies and headers if configured.""" opts = dict(YTDL_OPTS) # UA and extractor tweaks ua = os.getenv('YTDL_UA') or ( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/118.0 Safari/537.36' ) - yt_client_env = os.getenv('YTDL_YT_CLIENT', 'android') - yt_client = client_hint or yt_client_env + yt_client = os.getenv('YTDL_YT_CLIENT', 'android') opts['http_headers'] = {'User-Agent': ua} # Extractor args can help avoid some player config checks opts.setdefault('extractor_args', {}) opts['extractor_args'].setdefault('youtube', {}) - # player_client hint + # player_client hint (e.g., android) opts['extractor_args']['youtube']['player_client'] = [yt_client] # Use cookies if available - if use_cookies: - if YTDL_COOKIEFILE: - opts['cookiefile'] = YTDL_COOKIEFILE - elif YTDL_COOKIESFROMBROWSER: - opts['cookiesfrombrowser'] = YTDL_COOKIESFROMBROWSER - # Optional force IPv4 (set YTDL_FORCE_IPV4=1) - if os.getenv('YTDL_FORCE_IPV4'): - opts['force_ipv4'] = True + if YTDL_COOKIEFILE: + opts['cookiefile'] = YTDL_COOKIEFILE + elif YTDL_COOKIESFROMBROWSER: + opts['cookiesfrombrowser'] = YTDL_COOKIESFROMBROWSER return opts FFMPEG_BEFORE = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" @@ -453,81 +444,24 @@ async def _ensure_connected(ctx: commands.Context) -> Optional[discord.VoiceClie async def _ytdlp_extract(loop: asyncio.AbstractEventLoop, query: str) -> Optional[Dict]: if not ytdlp: return None - - # Try multiple YouTube client profiles to bypass some restrictions - env_client = os.getenv('YTDL_YT_CLIENT', 'android') - cookies_present = bool(YTDL_COOKIEFILE or YTDL_COOKIESFROMBROWSER) - - # Helper to try a sequence of clients with/without cookies - async def try_clients(clients: List[str], use_cookies: bool) -> Optional[Dict]: - nonlocal query - last_err: Optional[Exception] = None - for client in clients: - def _extract_with_client(): - with ytdlp.YoutubeDL(get_ytdl_opts(client, use_cookies=use_cookies)) as ytdl: - return ytdl.extract_info(query, download=False) - try: - info = await loop.run_in_executor(executor, _extract_with_client) - if info is None: - continue - if 'entries' in info: - info = info['entries'][0] - return info - except Exception as e: - last_err = e - msg = str(e) - print(f"⚠️ yt-dlp attempt failed (cookies={'on' if use_cookies else 'off'}) client='{client}': {msg[:200]}") - retriable_markers = ( - 'Sign in to confirm', 'Use --cookies', 'pass cookies', 'HTTP Error 403', - 'Signature extraction failed', 'Requested format is not available', - 'Only images are available', 'missing a url', 'SABR streaming' - ) - if any(m in msg for m in retriable_markers): - continue - else: - break - if last_err: - return None - return None - - # Build ordered lists - # When cookies are present, android/ios are skipped by yt-dlp; so try web/tv first with cookies. - # If that fails (e.g., SABR), try without cookies to enable android extraction. - cookie_clients: List[str] = [] - for c in [env_client, 'web', 'tv']: - if c in ('web', 'tv') and c not in cookie_clients: - cookie_clients.append(c) - nocookie_clients: List[str] = [] - for c in [env_client, 'android', 'ios', 'web', 'tv']: - if c not in nocookie_clients: - nocookie_clients.append(c) - - last_error: Optional[Exception] = None - # Pass 1: with cookies (if provided) - if cookies_present: - info = await try_clients(cookie_clients, use_cookies=True) - if info: - return info - # Pass 2: without cookies to allow android/ios - info = await try_clients(nocookie_clients, use_cookies=False) - if info: - return info - else: - # No cookies: try standard nocookie list - info = await try_clients(nocookie_clients, use_cookies=False) - if info: - return info - - # All attempts failed - # At this point, all attempts failed; print last traceback if any + def _extract(): + with ytdlp.YoutubeDL(get_ytdl_opts()) as ytdl: + return ytdl.extract_info(query, download=False) try: - if cookies_present: - print("❌ yt-dlp: All attempts failed with and without cookies. Consider trying another client or refreshing cookies.") - else: - print("❌ yt-dlp: All attempts failed without cookies. Consider providing cookies or changing YTDL_YT_CLIENT.") - except Exception: - pass - return None + info = await loop.run_in_executor(executor, _extract) + if info is None: + return None + if 'entries' in info: + info = info['entries'][0] + return info + except Exception as e: + # Make YouTube cookie issues clearer in logs + msg = str(e) + if 'Sign in to confirm you’re not a bot' in msg or 'Use --cookies' in msg or 'pass cookies' in msg: + print("❌ yt-dlp error: YouTube requires cookies to proceed.") + print(" Provide YTDL_COOKIES_FILE or YTDL_COOKIES_B64 (Netscape cookies.txt).") + traceback.print_exc() + return None async def _create_audio_source(loop: asyncio.AbstractEventLoop, search: str, volume: float): # Accept either URL or search text; prepend ytsearch1: if not a URL @@ -622,11 +556,8 @@ async def play(ctx: commands.Context, *, query: str): await ctx.reply("🔎 Searching…") item = await _create_audio_source(loop, query, state['volume']) if not item: - # Give a hint if cookies or client change likely required - hint = ( - "If this is YouTube, try YTDL_YT_CLIENT=android (or web) and/or provide cookies via " - "YTDL_COOKIES_FILE or YTDL_COOKIES_B64." - ) + # Give a hint if cookies likely required + hint = "If this is YouTube, the server IP may be challenged. Provide YTDL_COOKIES_FILE or YTDL_COOKIES_B64." await ctx.reply(f"❌ Couldn't get audio from that query.\n{hint}") return state['queue'].append(item) @@ -713,83 +644,6 @@ async def volume(ctx: commands.Context, percent: int): now['source'].volume = vol await ctx.reply(f"🔊 Volume set to {percent}%.") -# ------------- Connectivity / Web Test ------------- - -@bot.hybrid_command(name='webtest', description='Test outbound web access and YouTube extraction') -async def webtest(ctx: commands.Context, *, query: Optional[str] = None): - """Check basic web reachability and try a lightweight yt-dlp extraction. - - Tests HTTP GET to a few endpoints - - Tries yt-dlp search or URL extraction (if yt-dlp is available) - """ - urls = [ - ("Google 204", "https://www.google.com/generate_204"), - ("YouTube", "https://www.youtube.com"), - ("Discord API", "https://discord.com/api/v10/gateway"), - ] - results = [] - timeout = aiohttp.ClientTimeout(total=8) - try: - async with aiohttp.ClientSession(timeout=timeout) as session: - for name, url in urls: - t0 = time.monotonic() - status = None - err = None - try: - async with session.get(url, allow_redirects=True) as resp: - status = resp.status - except Exception as e: - err = str(e) - dt = (time.monotonic() - t0) * 1000 - results.append((name, url, status, err, dt)) - except Exception as e: - results.append(("session", "", None, f"Session error: {e}", 0.0)) - - # Try yt-dlp extraction if available - ytdlp_ok = False - ytdlp_msg = "" - chosen = query or "ytsearch1:never gonna give you up" - try: - loop = asyncio.get_running_loop() - info = await _ytdlp_extract(loop, chosen) - if info: - ytdlp_ok = True - ytdlp_msg = f"Success: {info.get('title', 'unknown title')}" - else: - ytdlp_msg = "No info returned (possibly cookies required or blocked)." - except Exception as e: - ytdlp_msg = f"Error: {e}" - - # Cookies configured? - cookies_file = os.getenv('YTDL_COOKIES_FILE') - cookies_b64 = bool(os.getenv('YTDL_COOKIES_B64')) - from_browser = os.getenv('YTDL_COOKIES_FROM_BROWSER') - - desc_lines = [] - for name, url, status, err, dt in results: - if status is not None: - desc_lines.append(f"• {name}: {status} ({dt:.0f} ms)") - else: - desc_lines.append(f"• {name}: ❌ {err or 'unknown error'}") - - desc = "\n".join(desc_lines) - embed = discord.Embed(title="🌐 Web Test", description=desc, color=discord.Color.teal()) - embed.add_field(name="yt-dlp", value=("✅ " + ytdlp_msg) if ytdlp_ok else ("❌ " + ytdlp_msg), inline=False) - - cookie_status = [] - if cookies_file and os.path.exists(cookies_file): - cookie_status.append(f"file: {cookies_file}") - elif cookies_file: - cookie_status.append(f"file missing: {cookies_file}") - if cookies_b64: - cookie_status.append("b64: provided") - if from_browser: - cookie_status.append(f"from-browser: {from_browser}") - if not cookie_status: - cookie_status.append("none") - embed.add_field(name="yt-dlp cookies", value=", ".join(cookie_status), inline=False) - - await ctx.reply(embed=embed) - def _flag_from_iso2(code: str) -> Optional[str]: """Return unicode flag from 2-letter ISO code (e.g., 'DE' -> 🇩🇪).""" if not code or len(code) != 2: @@ -1855,23 +1709,11 @@ async def on_command_error(ctx, error): import traceback traceback.print_exc() - # Send detailed error to user if owner; be safe for slash interactions - try: - content_owner = f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```" - content_user = "❌ An unknown error occurred!" - if hasattr(ctx, 'interaction') and ctx.interaction is not None: - if not ctx.interaction.response.is_done(): - await ctx.interaction.response.send_message(content_owner if ctx.author.id == OWNER_ID else content_user, ephemeral=ctx.author.id != OWNER_ID) - else: - await ctx.interaction.followup.send(content_owner if ctx.author.id == OWNER_ID else content_user, ephemeral=ctx.author.id != OWNER_ID) - else: - await ctx.send(content_owner if ctx.author.id == OWNER_ID else content_user) - except Exception as send_err: - # Final fallback: try reply, then ignore - try: - await ctx.reply("❌ Error occurred, and sending details failed.") - except Exception: - pass + # Send detailed error to user if owner + if ctx.author.id == OWNER_ID: + await ctx.send(f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```") + else: + await ctx.send("❌ An unknown error occurred!") async def main(): """Main function to start the bot"""