#!/usr/bin/env python3 """ 🐠 Clownfischserver Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude) License: GPL-3.0 – Keep it open. Always. Repo: https://github.com/mehlzoerwer-claude/clownfischserver """ import asyncio import logging import os import re import shutil import subprocess import sys import tempfile import zipfile from dotenv import load_dotenv from telegram import Update from telegram.ext import ( Application, MessageHandler, CommandHandler, filters, ContextTypes, ) # Global Ollama ready flag ollama_ready = None # Load local modules sys.path.insert(0, os.path.dirname(__file__)) from ollama_client import OllamaClient from openrouter_client import OpenRouterClient from shell import run_shell, execute_command from snapshot import SnapshotManager, should_snapshot from aider_wrapper import AiderWrapper # --- Load env --- _install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") load_dotenv(os.path.join(_install_dir, "config", ".env")) # --- Logging --- logging.basicConfig( format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=logging.INFO, handlers=[ logging.StreamHandler(), logging.FileHandler(os.path.join(_install_dir, "bot.log")), ], ) logger = logging.getLogger(__name__) BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") CHAT_ID = str(os.getenv("TELEGRAM_CHAT_ID")) if not BOT_TOKEN or not CHAT_ID: logger.error("TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID fehlt in .env!") sys.exit(1) # --- Init modules --- openrouter = OpenRouterClient() ollama = OllamaClient(openrouter_client=openrouter) snapshots = SnapshotManager() aider = AiderWrapper() # ============================================================================= # AUTH GUARD + HELPERS # ============================================================================= def is_authorized(update: Update) -> bool: return str(update.effective_chat.id) == CHAT_ID async def safe_reply(target, text: str, parse_mode: str = "Markdown", edit: bool = False): """Send or edit a message. Falls back to plain text if Markdown fails.""" try: if edit: return await target.edit_text(text, parse_mode=parse_mode) return await target.reply_text(text, parse_mode=parse_mode) except Exception: plain = text.replace("`", "").replace("*", "").replace("_", "") try: if edit: return await target.edit_text(plain) return await target.reply_text(plain) except Exception as e: logger.error(f"Telegram send failed: {e}") # ============================================================================= # COMMANDS # ============================================================================= async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return fallback_info = "" if openrouter.is_available(): fallback_info = "\n✅ OpenRouter Fallback aktiv" await update.message.reply_text( f"🐠 *Clownfischserver v0.4.13 online.*{fallback_info}\n\n" "Schreib mir einfach – ich antworte als Chat.\n\n" "*Befehle:*\n" "• `/shell ` – KI generiert Befehl, du bestätigst\n" "• `/ja` – letzten Shell-Befehl ausführen\n" "• `/code ` – Aider generiert Code\n" "• `/run ` – direkte Shell, kein Ollama\n" "• `/status` – CPU, RAM, Disk\n" "• `/snapshots` – alle Snapshots anzeigen\n" "• `/rollback ` – zu einem Snapshot zurück\n" "• `/snapshot [label]` – manuell Snapshot erstellen\n" "• `/ssh open [ip]` – SSH öffnen\n" "• `/ssh close [ip]` – SSH schließen\n" "• `/help` – diese Hilfe", parse_mode="Markdown" ) async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return cmd = ( "echo '=== UPTIME ===' && uptime && " "echo '=== MEMORY ===' && free -h && " "echo '=== DISK ===' && df -h && " "echo '=== TOP PROCESSES ===' && ps aux --sort=-%cpu | head -10" ) stdout, stderr, rc = await run_shell(cmd) result = stdout or stderr or "Keine Ausgabe" if len(result) > 4000: result = result[:4000] + "\n... (gekürzt)" await safe_reply(update.message, f"📊 Systemstatus\n\n```\n{result}\n```") async def cmd_snapshots(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return snap_list = snapshots.list_snapshots() if not snap_list: await update.message.reply_text("📸 Keine Snapshots vorhanden.") return msg = "📸 *Snapshots:*\n\n" for s in snap_list[:20]: star = "⭐ " if s["kept"] else "" msg += f"• {star}`{s['name']}` ({s['size_mb']}MB)\n" msg += f"\n_Gesamt: {len(snap_list)} | Max auto: 20 (⭐ bleiben immer)_\n" msg += "\n`/snapshot keep ` – wichtig markieren\n" msg += "`/snapshot delete ` – löschen" await update.message.reply_text(msg, parse_mode="Markdown") async def cmd_rollback(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return if not context.args: await update.message.reply_text( "Usage: `/rollback `\n\nBeispiel: `/rollback 2025-01-15_17-24-00`", parse_mode="Markdown" ) return snap_name = context.args[0] await update.message.reply_text(f"⏪ Rollback zu `{snap_name}` wird eingeleitet...", parse_mode="Markdown") result = snapshots.rollback(snap_name) await update.message.reply_text(result) async def cmd_snapshot(update: Update, context: ContextTypes.DEFAULT_TYPE): """Snapshot management: create, keep, delete""" if not is_authorized(update): return args = context.args subcmd = args[0].lower() if args else "create" if subcmd == "keep" and len(args) > 1: result = snapshots.keep_snapshot(args[1]) await update.message.reply_text(result) elif subcmd == "unkeep" and len(args) > 1: result = snapshots.unkeep_snapshot(args[1]) await update.message.reply_text(result) elif subcmd == "delete" and len(args) > 1: result = snapshots.delete_snapshot(args[1]) await update.message.reply_text(result) elif subcmd in ("list", "ls"): await cmd_snapshots(update, context) else: label = args[0] if args and subcmd not in ("create",) else "manual" if subcmd == "create": label = args[1] if len(args) > 1 else "manual" await update.message.reply_text(f"📸 Erstelle Snapshot ({label})...") snap_name = snapshots.create_snapshot(label=label) if snap_name: await update.message.reply_text( f"✅ Snapshot erstellt: `{snap_name}`\n\nAls wichtig markieren: `/snapshot keep {snap_name}`", parse_mode="Markdown" ) else: await update.message.reply_text("❌ Snapshot fehlgeschlagen – check Logs.") async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return await cmd_start(update, context) async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE): """Direct shell execution – bypasses Ollama completely""" if not is_authorized(update): return if not context.args: await update.message.reply_text( "Usage: /run \n\nBeispiel: /run free -h", parse_mode="Markdown" ) return cmd = " ".join(context.args) await update.message.reply_text(f"⚡ Direkt: `{cmd}`", parse_mode="Markdown") stdout, stderr, returncode = await run_shell(cmd) result = stdout or stderr or "✓ Fertig (keine Ausgabe)" if len(result) > 4000: result = result[:4000] + "\n... (gekürzt)" await safe_reply(update.message, f"```\n{result}\n```") async def cmd_ssh(update: Update, context: ContextTypes.DEFAULT_TYPE): """Open or close SSH via ufw""" if not is_authorized(update): return action = context.args[0].lower() if context.args else "" client_ip = context.args[1] if len(context.args) > 1 else None if action == "open": if client_ip: await run_shell(f"sudo ufw allow from {client_ip} to any port 22 comment 'clownfisch-ssh'") else: await run_shell("sudo ufw allow 22/tcp comment 'clownfisch-ssh'") ip_info = f"für {client_ip}" if client_ip else "für alle" await update.message.reply_text( f"🔓 SSH geöffnet {ip_info}!\nVergiss nicht: /ssh close danach!", parse_mode="Markdown" ) elif action == "close": if client_ip: await run_shell(f"sudo ufw delete allow from {client_ip} to any port 22") else: await run_shell("sudo ufw delete allow 22/tcp") await update.message.reply_text("🔒 SSH wieder geschlossen.") else: await update.message.reply_text( "Usage:\n" "/ssh open – SSH für alle öffnen\n" "/ssh open 1.2.3.4 – SSH nur für deine IP\n" "/ssh close – SSH schließen\n" "/ssh close 1.2.3.4 – SSH für IP schließen", parse_mode="Markdown" ) # ============================================================================= # /shell – KI generiert Befehl, Nutzer bestätigt mit /ja # ============================================================================= async def cmd_shell(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return if not context.args: await update.message.reply_text( "Usage: `/shell `\n\n" "Beispiel: `/shell zeig mir den freien RAM`", parse_mode="Markdown" ) return if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available(): await update.message.reply_text("⏳ Ollama lädt noch – bitte kurz warten.") return description = " ".join(context.args) thinking_msg = await update.message.reply_text("🤔 Generiere Befehl...") try: result = await ollama.generate_shell_command(description) command = result.get("command", "").strip() error = result.get("_error", "") if error: await safe_reply(thinking_msg, f"⚠️ {error}", edit=True) return if not command: await safe_reply(thinking_msg, "❌ Konnte keinen Befehl generieren. Versuch es anders zu formulieren.", edit=True) return if result.get("dangerous", False): await safe_reply(thinking_msg, f"⚠️ *Gefährlicher Befehl verweigert!*\n\n" f"Befehl: `{command}`\n" f"Grund: {result.get('reason', 'Zu riskant')}", edit=True ) return context.user_data["pending_cmd"] = command await safe_reply(thinking_msg, f"🔧 Vorgeschlagener Befehl:\n\n" f"`{command}`\n\n" f"→ Mit `/ja` ausführen oder einfach ignorieren.", edit=True ) except Exception as e: logger.error(f"cmd_shell failed: {e}", exc_info=True) await safe_reply(thinking_msg, f"❌ Fehler: `{e}`", edit=True) # ============================================================================= # /ja – Bestätigung für pending /shell Befehl # ============================================================================= async def cmd_ja(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return pending = context.user_data.get("pending_cmd") if not pending: await update.message.reply_text("❓ Kein ausstehender Befehl. Nutze erst `/shell `.", parse_mode="Markdown") return command = pending context.user_data.pop("pending_cmd", None) snap_name = None if should_snapshot(command): await update.message.reply_text("📸 Snapshot wird erstellt...") snap_name = snapshots.create_snapshot() if snap_name: await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") status_msg = await update.message.reply_text(f"⚙️ Ausführung: `{command}`", parse_mode="Markdown") try: result = await execute_command(command, ollama) if len(result) > 4000: chunks = [result[i:i+4000] for i in range(0, len(result), 4000)] await safe_reply(status_msg, f"✅ Ergebnis (Teil 1/{len(chunks)}):\n\n```\n{chunks[0]}\n```", edit=True) for i, chunk in enumerate(chunks[1:], 2): await safe_reply(update.message, f"Teil {i}/{len(chunks)}:\n\n```\n{chunk}\n```") else: await safe_reply(status_msg, f"✅ Ergebnis:\n\n```\n{result}\n```", edit=True) except Exception as e: logger.error(f"cmd_ja execution failed: {e}", exc_info=True) await safe_reply(status_msg, f"❌ Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`", edit=True ) # ============================================================================= # /code – Aider für Code-Generierung # ============================================================================= async def cmd_code(update: Update, context: ContextTypes.DEFAULT_TYPE): if not is_authorized(update): return if not context.args: await update.message.reply_text( "Usage: `/code `\n\n" "Beispiel: `/code erstelle eine einfache Flask API`\n\n" "💡 Tipp: Kläre Details erst im Chat, dann `/code` mit klarer Aufgabe.", parse_mode="Markdown" ) return task = " ".join(context.args) # Smart prompt: ensure Aider creates a FILE, not just chat output workspace = os.getenv("AIDER_WORKDIR", "/opt/clownfisch-workspace") task_lower = task.lower() if workspace not in task and "/opt/" not in task and "/" not in task.split()[0] if task.split() else True: file_match = re.search(r'(\S+\.(sh|py|js|ts|html|css|yaml|yml|json|conf|cfg|txt))', task) if file_match: filename = file_match.group(1) filepath = f"{workspace}/{filename}" else: words = re.sub(r'[^a-z0-9 ]', '', task_lower).split()[:3] slug = "-".join(words) if words else "script" ext = ".py" if "python" in task_lower else ".sh" filepath = f"{workspace}/{slug}{ext}" task = f"erstelle die datei {filepath} mit folgendem inhalt: {task}" logger.info(f"Code task enhanced: {task[:100]}") status_msg = await update.message.reply_text("🛠️ Aider arbeitet...") snap_name = snapshots.create_snapshot(label="pre-aider") if snap_name: await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") try: result = await aider.run(task) if len(result) > 4000: result = result[:4000] + "\n... (gekürzt)" await safe_reply(status_msg, f"🛠️ Aider Ergebnis:\n\n```\n{result}\n```", edit=True) except Exception as e: logger.error(f"cmd_code failed: {e}", exc_info=True) await safe_reply(status_msg, f"❌ Aider Fehler:\n`{e}`\n\nSnapshot: `{snap_name or 'keiner'}`", edit=True ) # ============================================================================= # FILE HANDLER – Self-Update via Telegram # ============================================================================= async def handle_file(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle file uploads – ZIP = self-update""" if not is_authorized(update): return doc = update.message.document if not doc: return filename = doc.file_name or "" if not filename.endswith(".zip"): await update.message.reply_text( f"📎 Datei empfangen: {filename}\nNur .zip Dateien werden als Updates verarbeitet." ) return status_msg = await update.message.reply_text( f"📦 Update-Paket empfangen: `{filename}`\n⏳ Verarbeite...", parse_mode="Markdown" ) try: # Snapshot before update await status_msg.edit_text("📸 Erstelle Snapshot vor Update...") snap_name = snapshots.create_snapshot(label="pre-update") if snap_name: await update.message.reply_text(f"📸 Snapshot: `{snap_name}`", parse_mode="Markdown") # Download ZIP await status_msg.edit_text("⬇️ Lade Datei herunter...") tmp_dir = tempfile.mkdtemp() zip_path = os.path.join(tmp_dir, filename) tg_file = await context.bot.get_file(doc.file_id) await tg_file.download_to_drive(zip_path) # Extract await status_msg.edit_text("📂 Entpacke ZIP...") extract_dir = os.path.join(tmp_dir, "extracted") with zipfile.ZipFile(zip_path, "r") as z: z.extractall(extract_dir) # Find bot/ directory bot_dir = None for root, dirs, files in os.walk(extract_dir): if "bot.py" in files: bot_dir = root break if not bot_dir: raise FileNotFoundError("Kein bot.py im ZIP gefunden!") # Check if openrouter_client.py is in the update has_openrouter = os.path.exists(os.path.join(bot_dir, "openrouter_client.py")) if has_openrouter and not openrouter.is_available(): # New feature available – ask user if they want to set it up context.user_data["update_tmp_dir"] = tmp_dir context.user_data["update_bot_dir"] = bot_dir context.user_data["update_extract_dir"] = extract_dir context.user_data["update_step"] = "ask_openrouter" await status_msg.edit_text( "✅ Update-Paket gültig\n\n" "🆕 *Neues Feature: OpenRouter Fallback*\n\n" "Wenn Ollama mal zu langsam ist, kann der Bot\n" "kostenlose Cloud-Modelle als Backup nutzen.\n" "Kein Zwang, keine Kreditkarte nötig.\n\n" "Möchtest du OpenRouter aktivieren?\n" "Antworte: `ja` oder `nein`", parse_mode="Markdown" ) else: # No OpenRouter setup needed – install directly await _perform_update(update, context, tmp_dir, bot_dir, extract_dir) except Exception as e: logger.error(f"Update fehlgeschlagen: {e}", exc_info=True) await status_msg.edit_text( f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.", parse_mode="Markdown" ) async def _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=None): """Execute the actual update installation.""" try: msg = await update.message.reply_text("📋 Kopiere Bot-Dateien...") install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver") install_bot_dir = os.path.join(install_dir, "bot") mgmt_user = os.getenv("MGMT_USER", "clownfish") for f in os.listdir(bot_dir): if f.endswith(".py"): src = os.path.join(bot_dir, f) dst = os.path.join(install_bot_dir, f) shutil.copy2(src, dst) os.chmod(dst, 0o644) logger.info(f"Updated: {f}") subprocess.run( ["sudo", "chown", "-R", f"{mgmt_user}:{mgmt_user}", install_bot_dir], capture_output=True, ) # Copy systemd services – patch User= with actual MGMT_USER systemd_dir = None for root, dirs, files in os.walk(extract_dir): if any(f.endswith(".service") for f in files): systemd_dir = root break if systemd_dir: for f in os.listdir(systemd_dir): if f.endswith(".service"): src = os.path.join(systemd_dir, f) with open(src, "r") as sf: service_content = sf.read() # Replace ANY User=/Group= value with actual MGMT_USER service_content = re.sub(r'User=\S+', f'User={mgmt_user}', service_content) service_content = re.sub(r'Group=\S+', f'Group={mgmt_user}', service_content) tmp_svc = os.path.join(install_dir, f".tmp_{f}") with open(tmp_svc, "w") as sf: sf.write(service_content) subprocess.run(["sudo", "cp", tmp_svc, f"/etc/systemd/system/{f}"], capture_output=True) os.remove(tmp_svc) logger.info(f"Service updated: {f} (User={mgmt_user})") subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True) subprocess.run(["sudo", "systemctl", "enable", "clownfisch-boot"], capture_output=True) # Save OpenRouter key if provided if openrouter_key: env_file = os.path.join(install_dir, "config", ".env") try: with open(env_file, "r") as f: env_content = f.read() except FileNotFoundError: env_content = "" if "OPENROUTER_API_KEY=" in env_content: env_content = re.sub(r'OPENROUTER_API_KEY=.*', f'OPENROUTER_API_KEY={openrouter_key}', env_content) else: env_content += f"\nOPENROUTER_API_KEY={openrouter_key}\n" with open(env_file, "w") as f: f.write(env_content) os.chmod(env_file, 0o600) logger.info("OpenRouter Key in .env gespeichert") # Cleanup shutil.rmtree(tmp_dir, ignore_errors=True) # Clear update state for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step", "update_openrouter_key"]: context.user_data.pop(key, None) or_status = "\n✅ OpenRouter Fallback aktiviert" if openrouter_key else "" await msg.edit_text( f"✅ *Update installiert!*{or_status}\n\nBot startet in 3 Sekunden neu...", parse_mode="Markdown" ) await asyncio.sleep(3) subprocess.Popen(["sudo", "systemctl", "restart", "clownfisch"]) except Exception as e: logger.error(f"Update execution failed: {e}", exc_info=True) await update.message.reply_text( f"❌ Update fehlgeschlagen:\n`{e}`\n\nSnapshot zum Rollback verfügbar.", parse_mode="Markdown" ) for key in ["update_tmp_dir", "update_bot_dir", "update_extract_dir", "update_step"]: context.user_data.pop(key, None) # ============================================================================= # MAIN MESSAGE HANDLER – Chat + Update Dialog # ============================================================================= async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): """Normal messages: either handle update dialog or go to Ollama chat.""" if not is_authorized(update): logger.warning(f"Unauthorized access attempt from chat_id: {update.effective_chat.id}") return user_message = update.message.text.strip() # --- Active update dialog? Handle that first, don't send to chat --- update_step = context.user_data.get("update_step") if update_step: await _handle_update_dialog(update, context, user_message, update_step) return # --- Normal chat --- logger.info(f"Chat message: {user_message[:100]}") if (ollama_ready is None or not ollama_ready.is_set()) and not openrouter.is_available(): await update.message.reply_text( "⏳ Ollama lädt noch...\n\n" "Direkte Befehle funktionieren sofort:\n" "• /run \n" "• /ssh open/close\n" "• /status\n\n" "Bitte in ~1 Minute nochmal versuchen." ) return thinking_msg = await update.message.reply_text("💬 ...") try: history = context.user_data.get("chat_history", []) result = await ollama.chat(user_message, history=history) history.append({"role": "user", "content": user_message}) history.append({"role": "assistant", "content": result}) if len(history) > 20: history = history[-20:] context.user_data["chat_history"] = history if len(result) > 4000: chunks = [result[i:i+4000] for i in range(0, len(result), 4000)] await thinking_msg.edit_text(f"💬 {chunks[0]}") for i, chunk in enumerate(chunks[1:], 2): await update.message.reply_text(f"💬 ({i}/{len(chunks)}) {chunk}") else: await thinking_msg.edit_text(f"💬 {result}") except Exception as e: logger.error(f"Chat error: {e}", exc_info=True) await thinking_msg.edit_text(f"❌ Chat-Fehler: `{e}`", parse_mode="Markdown") async def _handle_update_dialog(update, context, user_input, step): """Handle interactive update setup dialog (OpenRouter key etc.).""" user_input_lower = user_input.lower() if step == "ask_openrouter": if user_input_lower in ("ja", "yes", "j", "y"): context.user_data["update_step"] = "ask_openrouter_key" await update.message.reply_text( "🔑 *OpenRouter API Key eingeben*\n\n" "Kostenlos – keine Kreditkarte nötig!\n\n" "1. Gehe zu openrouter.ai\n" "2. Sign up (2 Minuten)\n" "3. Settings → API Keys\n" "4. Key kopieren und hier senden\n\n" "Format: `sk-or-v1-...`", parse_mode="Markdown" ) elif user_input_lower in ("nein", "no", "n"): tmp_dir = context.user_data.get("update_tmp_dir") bot_dir = context.user_data.get("update_bot_dir") extract_dir = context.user_data.get("update_extract_dir") await _perform_update(update, context, tmp_dir, bot_dir, extract_dir) else: await update.message.reply_text("❓ Bitte antworte mit `ja` oder `nein`", parse_mode="Markdown") elif step == "ask_openrouter_key": key = user_input.strip() if len(key) < 20: await update.message.reply_text( "❌ Key zu kurz. Bitte den kompletten Key von openrouter.ai senden.", parse_mode="Markdown" ) return await update.message.reply_text("✅ Key gespeichert – Installation startet...") tmp_dir = context.user_data.get("update_tmp_dir") bot_dir = context.user_data.get("update_bot_dir") extract_dir = context.user_data.get("update_extract_dir") await _perform_update(update, context, tmp_dir, bot_dir, extract_dir, openrouter_key=key) # ============================================================================= # MAIN # ============================================================================= def main(): logger.info("🐠 Clownfischserver v0.4.13 Bot startet...") import requests as req import time import threading model = os.getenv("OLLAMA_MODEL", "qwen2.5-coder:7b") model_fast = os.getenv("OLLAMA_MODEL_FAST", "") base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") global ollama_ready ollama_ready = threading.Event() def warmup_model(model_name, label): for attempt in range(60): try: resp = req.post(f"{base_url}/api/chat", json={ "model": model_name, "messages": [{"role": "user", "content": "hi"}], "stream": False, "keep_alive": -1, }, timeout=150) if resp.status_code == 200: logger.info(f"✓ {label} warm: {model_name}") return True except Exception: pass time.sleep(3) logger.warning(f"{label} nach 3 Minuten nicht bereit: {model_name}") return False def warmup_ollama(): logger.info("Ollama Warmup gestartet (Hintergrund)...") models_to_warm = [(model, "Hauptmodell")] if model_fast and model_fast != model: models_to_warm.insert(0, (model_fast, "Fast-Modell")) logger.info(f"Dual-Model: fast={model_fast}, main={model}") for m, label in models_to_warm: warmup_model(m, label) ollama_ready.set() threading.Thread(target=warmup_ollama, daemon=True).start() if model_fast and model_fast != model: logger.info(f"🐠 Bot startet – 2 Modelle laden ({model_fast} + {model})") else: logger.info("🐠 Bot startet – Ollama lädt im Hintergrund.") if openrouter.is_available(): logger.info("✅ OpenRouter Fallback aktiv") app = Application.builder().token(BOT_TOKEN).build() # Commands app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("snapshots", cmd_snapshots)) app.add_handler(CommandHandler("rollback", cmd_rollback)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("run", cmd_run)) app.add_handler(CommandHandler("snapshot", cmd_snapshot)) app.add_handler(CommandHandler("ssh", cmd_ssh)) app.add_handler(CommandHandler("shell", cmd_shell)) app.add_handler(CommandHandler("ja", cmd_ja)) app.add_handler(CommandHandler("code", cmd_code)) # Messages – single handler for chat AND update dialog (no double-processing) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) app.add_handler(MessageHandler(filters.Document.ALL, handle_file)) logger.info(f"Bot läuft. Authorized Chat-ID: {CHAT_ID}") app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()