modified: .gitignore

modified:   Dockerfile
	modified:   README.md
	modified:   app.py
This commit is contained in:
SimolZimol
2025-10-28 01:57:59 +01:00
parent c253767497
commit 9d91d7120b
4 changed files with 49 additions and 245 deletions

5
.gitignore vendored
View File

@@ -128,4 +128,7 @@ venv.bak/
dmypy.json dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# Secrets and local cookies
cookies.txt

View File

@@ -22,8 +22,8 @@ ENV DB_PORT=$DB_PORT
ENV DB_NAME=$DB_NAME ENV DB_NAME=$DB_NAME
ENV DB_USER=$DB_USER ENV DB_USER=$DB_USER
ENV DB_PASSWORD=$DB_PASSWORD ENV DB_PASSWORD=$DB_PASSWORD
ENV YTDL_COOKIES_B64=$YTDL_COOKIES_B64
ENV YTDL_COOKIES_FILE=$YTDL_COOKIES_FILE 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_COOKIES_FROM_BROWSER=$YTDL_COOKIES_FROM_BROWSER
ENV YTDL_UA=$YTDL_UA ENV YTDL_UA=$YTDL_UA
ENV YTDL_YT_CLIENT=$YTDL_YT_CLIENT ENV YTDL_YT_CLIENT=$YTDL_YT_CLIENT

View File

@@ -4,23 +4,18 @@ Ein Discord Bot für Hearts of Iron IV, der über Coolify deployed werden kann.
## Features ## Features
- ELO-System (Standard/Competitive), Spiele-Lifecycle (/hoi4create, /hoi4setup, /hoi4end) - **Basic Commands**: Ping, Info, Server-Informationen
- Stats, History und Leaderboard - **HOI4 Theme**: Speziell für Hearts of Iron IV Communities
- Emoji-/Länder-Tag-Unterstützung (tags.txt, emotes.markdown) - **Docker Support**: Bereit für Deployment über Coolify
- Automatische Rollenvergabe nach ELO (pro Kategorie) - **Error Handling**: Robuste Fehlerbehandlung
- YouTube Musikplayer (yt-dlp + ffmpeg) mit Queue und Lautstärke - **Logging**: Ausführliche Logs für Debugging
- Docker/Coolify ready
## Verfügbare Befehle (Auszug) ## Verfügbare Befehle
- `/hoi4create <type> <name>` Neues Spiel anlegen (type: standard/competitive) - `!ping` - Zeigt die Bot-Latenz an
- `/hoi4setup <game> <user> <team> <t-level> [country]` Spieler hinzufügen (T1/2/3; country optional als HOI4-Tag) - `!info` - Zeigt Bot-Informationen an
- `/hoi4end <game> <winner_team|draw>` Spiel beenden, ELO berechnen, Rollen syncen - `!help_hoi4` - Zeigt alle verfügbaren Befehle an
- `/hoi4stats [user]` ELO, Rank, Win/Draw/Loss - `!server_info` - Zeigt Informationen über den aktuellen Server
- `/hoi4history [limit] [player] [name] [type]` Abgeschlossene Spiele filtern
- `/hoi4leaderboard [type] [limit]` Top ELOs
- Musik: `/join`, `/leave`, `/play <url|search>`, `/skip`, `/stop`, `/pause`, `/resume`, `/queue`, `/np`, `/volume <0-200>`
- Diagnose: `/webtest [query]` Testet Web-Konnektivität und yt-dlp-Extraction
## Setup für Coolify ## Setup für Coolify
@@ -43,13 +38,8 @@ Der Bot benötigt folgende Permissions:
### 3. Coolify Deployment ### 3. Coolify Deployment
1. **Repository**: Verbinde dein Git Repository mit Coolify 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 - `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 youre 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**: 3. **Build Settings**:
- Coolify wird automatisch das Dockerfile verwenden - Coolify wird automatisch das Dockerfile verwenden
@@ -86,37 +76,6 @@ Der Bot benötigt folgende Permissions:
python app.py python app.py
``` ```
### YouTube-Cookies bereitstellen (empfohlen)
YouTube kann Server/Container ohne Cookies blockieren („Sign in to confirm youre 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 ## Docker
### Lokal mit Docker testen ### Lokal mit Docker testen

224
app.py
View File

@@ -11,8 +11,6 @@ from typing import Optional, List, Dict
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import functools import functools
import traceback import traceback
import aiohttp
import time
import base64 import base64
from pathlib import Path from pathlib import Path
@@ -374,40 +372,33 @@ except Exception as e:
print(f"⚠️ Cookie configuration error: {e}") print(f"⚠️ Cookie configuration error: {e}")
YTDL_OPTS = { YTDL_OPTS = {
'format': 'bestaudio[ext=m4a]/bestaudio/best', 'format': 'bestaudio/best',
'noplaylist': True, 'noplaylist': True,
'quiet': True, 'quiet': True,
'default_search': 'ytsearch', 'default_search': 'ytsearch',
'skip_download': True, 'skip_download': True,
} }
def get_ytdl_opts(client_hint: Optional[str] = None, use_cookies: bool = True) -> Dict: def get_ytdl_opts() -> Dict:
"""Build yt-dlp options dynamically, injecting cookies and headers if configured. """Build yt-dlp options dynamically, injecting cookies and headers if configured."""
client_hint: preferred YouTube client (e.g., 'android', 'web', 'ios', 'tv').
"""
opts = dict(YTDL_OPTS) opts = dict(YTDL_OPTS)
# UA and extractor tweaks # UA and extractor tweaks
ua = os.getenv('YTDL_UA') or ( ua = os.getenv('YTDL_UA') or (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/118.0 Safari/537.36' '(KHTML, like Gecko) Chrome/118.0 Safari/537.36'
) )
yt_client_env = os.getenv('YTDL_YT_CLIENT', 'android') yt_client = os.getenv('YTDL_YT_CLIENT', 'android')
yt_client = client_hint or yt_client_env
opts['http_headers'] = {'User-Agent': ua} opts['http_headers'] = {'User-Agent': ua}
# Extractor args can help avoid some player config checks # Extractor args can help avoid some player config checks
opts.setdefault('extractor_args', {}) opts.setdefault('extractor_args', {})
opts['extractor_args'].setdefault('youtube', {}) opts['extractor_args'].setdefault('youtube', {})
# player_client hint # player_client hint (e.g., android)
opts['extractor_args']['youtube']['player_client'] = [yt_client] opts['extractor_args']['youtube']['player_client'] = [yt_client]
# Use cookies if available # Use cookies if available
if use_cookies: if YTDL_COOKIEFILE:
if YTDL_COOKIEFILE: opts['cookiefile'] = YTDL_COOKIEFILE
opts['cookiefile'] = YTDL_COOKIEFILE elif YTDL_COOKIESFROMBROWSER:
elif YTDL_COOKIESFROMBROWSER: opts['cookiesfrombrowser'] = YTDL_COOKIESFROMBROWSER
opts['cookiesfrombrowser'] = YTDL_COOKIESFROMBROWSER
# Optional force IPv4 (set YTDL_FORCE_IPV4=1)
if os.getenv('YTDL_FORCE_IPV4'):
opts['force_ipv4'] = True
return opts return opts
FFMPEG_BEFORE = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" 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]: async def _ytdlp_extract(loop: asyncio.AbstractEventLoop, query: str) -> Optional[Dict]:
if not ytdlp: if not ytdlp:
return None return None
def _extract():
# Try multiple YouTube client profiles to bypass some restrictions with ytdlp.YoutubeDL(get_ytdl_opts()) as ytdl:
env_client = os.getenv('YTDL_YT_CLIENT', 'android') return ytdl.extract_info(query, download=False)
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
try: try:
if cookies_present: info = await loop.run_in_executor(executor, _extract)
print("❌ yt-dlp: All attempts failed with and without cookies. Consider trying another client or refreshing cookies.") if info is None:
else: return None
print("❌ yt-dlp: All attempts failed without cookies. Consider providing cookies or changing YTDL_YT_CLIENT.") if 'entries' in info:
except Exception: info = info['entries'][0]
pass return info
return None except Exception as e:
# Make YouTube cookie issues clearer in logs
msg = str(e)
if 'Sign in to confirm youre 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): async def _create_audio_source(loop: asyncio.AbstractEventLoop, search: str, volume: float):
# Accept either URL or search text; prepend ytsearch1: if not a URL # 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…") await ctx.reply("🔎 Searching…")
item = await _create_audio_source(loop, query, state['volume']) item = await _create_audio_source(loop, query, state['volume'])
if not item: if not item:
# Give a hint if cookies or client change likely required # Give a hint if cookies likely required
hint = ( hint = "If this is YouTube, the server IP may be challenged. Provide YTDL_COOKIES_FILE or YTDL_COOKIES_B64."
"If this is YouTube, try YTDL_YT_CLIENT=android (or web) and/or provide cookies via "
"YTDL_COOKIES_FILE or YTDL_COOKIES_B64."
)
await ctx.reply(f"❌ Couldn't get audio from that query.\n{hint}") await ctx.reply(f"❌ Couldn't get audio from that query.\n{hint}")
return return
state['queue'].append(item) state['queue'].append(item)
@@ -713,83 +644,6 @@ async def volume(ctx: commands.Context, percent: int):
now['source'].volume = vol now['source'].volume = vol
await ctx.reply(f"🔊 Volume set to {percent}%.") 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", "<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]: def _flag_from_iso2(code: str) -> Optional[str]:
"""Return unicode flag from 2-letter ISO code (e.g., 'DE' -> 🇩🇪).""" """Return unicode flag from 2-letter ISO code (e.g., 'DE' -> 🇩🇪)."""
if not code or len(code) != 2: if not code or len(code) != 2:
@@ -1855,23 +1709,11 @@ async def on_command_error(ctx, error):
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Send detailed error to user if owner; be safe for slash interactions # Send detailed error to user if owner
try: if ctx.author.id == OWNER_ID:
content_owner = f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```" await ctx.send(f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```")
content_user = "❌ An unknown error occurred!" else:
if hasattr(ctx, 'interaction') and ctx.interaction is not None: await ctx.send("❌ An unknown error occurred!")
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
async def main(): async def main():
"""Main function to start the bot""" """Main function to start the bot"""