Files
clownfischserver/bot/bot.py
T

756 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <beschreibung>` – KI generiert Befehl, du bestätigst\n"
"• `/ja` – letzten Shell-Befehl ausführen\n"
"• `/code <aufgabe>` – Aider generiert Code\n"
"• `/run <befehl>` – direkte Shell, kein Ollama\n"
"• `/status` – CPU, RAM, Disk\n"
"• `/snapshots` – alle Snapshots anzeigen\n"
"• `/rollback <n>` – 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 <n>` – wichtig markieren\n"
msg += "`/snapshot delete <n>` – 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 <snapshot-name>`\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 <befehl>\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 <was du tun willst>`\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 <beschreibung>`.", 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 <aufgabe>`\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 <befehl>\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()