Files
clownfischserver/bot/bot.py
root f2fdf95897 v0.5.0: Add RBAC auth, LLM router, audit logging, output sanitization
- New: auth.py – Role-based access (operator/approver/viewer)
- New: llm_router.py – Configurable primary/fallback provider chain
- New: audit_log.py + audit_query.py – DSGVO-compliant audit trail
- New: sanitize_output() – masks secrets in bot responses
- Updated: bot.py – Integrated auth guards, router, audit logging
- Updated: ollama_client.py – Router-compatible interface
- Updated: CLOWNFISCHSERVER.md – Documentation updated
- Updated: README.md – Documentation updated
2026-06-26 20:41:35 +02:00

1082 lines
44 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
import auth
import audit_log
import audit_query
from llm_router import LLMRouter, OllamaProvider
# --- 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") or "")
# v0.5.0: auth is satisfied if EITHER legacy TELEGRAM_CHAT_ID OR any of
# the new role envs is set. The bot only requires BOT_TOKEN to boot.
_has_role_config = any(
os.getenv(k) for k in
("CLOWNFISCH_OPERATORS", "CLOWNFISCH_APPROVERS", "CLOWNFISCH_VIEWERS")
)
if not BOT_TOKEN:
logger.error("TELEGRAM_BOT_TOKEN fehlt in .env!")
sys.exit(1)
if not CHAT_ID and not _has_role_config:
logger.error(
"Weder TELEGRAM_CHAT_ID noch CLOWNFISCH_OPERATORS/APPROVERS/VIEWERS "
"in .env gesetzt – Bot hätte keine autorisierten Nutzer!"
)
sys.exit(1)
# --- Init modules ---
# LLMRouter owns the primary/fallback chain. OpenRouterClient is kept as a
# thin status proxy for backwards-compatible /start banner ("Fallback aktiv").
openrouter = OpenRouterClient()
llm = LLMRouter()
# Legacy alias so any third-party patches/plugins that still refer to `ollama`
# keep working. The router exposes the same async surface.
ollama = llm
snapshots = SnapshotManager()
aider = AiderWrapper()
# =============================================================================
# AUTH GUARD + HELPERS
# =============================================================================
def _user_id(update: Update) -> str | None:
"""Prefer the actual user id over chat id so group chats with multiple
members still produce distinct audit records."""
if update.effective_user and update.effective_user.id is not None:
return str(update.effective_user.id)
if update.effective_chat:
return str(update.effective_chat.id)
return None
def is_authorized(update: Update) -> bool:
"""Backwards-compatible auth check. Delegates to auth.is_authorized()
which falls back to legacy TELEGRAM_CHAT_ID when no roles are configured."""
return auth.is_authorized(_user_id(update))
async def deny(update: Update, action: str, required_role: str):
"""Reply with a denial message and write an audit entry."""
uid = _user_id(update)
role = auth.get_role(uid)
audit_log.log_denied(uid, action, role=role,
reason=f"requires_{required_role}")
if update.message:
try:
await update.message.reply_text(
f"⛔ Nicht erlaubt – `{action}` benötigt Rolle `{required_role}`.",
parse_mode="Markdown",
)
except Exception:
pass
def require(update: Update, *roles: str) -> bool:
"""Returns True if the user holds any of the listed roles.
Returns False if unauthorized OR role is too weak. Caller must
short-circuit on False (deny() should be awaited separately)."""
uid = _user_id(update)
if not auth.is_authorized(uid):
return False
return auth.has_role(uid, *roles)
def sanitize_output(text: str) -> str:
"""Mask sensitive data in bot output: API keys, tokens, passwords, secrets."""
import re
# Patterns to mask (key prefix -> replacement)
patterns = [
# Bearer tokens (in headers and in text) - MUST come before API key patterns
(r"Bearer\s+(?:sk-or-v1-[a-zA-Z0-9\-_.]{10,}|sk-[a-zA-Z0-9\-_.]{10,}|[a-zA-Z0-9\-_\.=]{20,})", r"Bearer ***"),
# Authorization headers
(r"(Authorization:\s*)[a-zA-Z0-9\-_\.=]{20,}", r"\1***"),
# OpenRouter keys (full: sk-or-v1-..., display: sk-or-...)
(r"(sk-or-v1-[a-zA-Z0-9\-_]{10,})", r"sk-or-v1-***"),
(r"(sk-or-\.{3}[a-zA-Z0-9]{4,})", r"sk-or-***"),
# Generic API keys (sk-..., pk-...)
(r"\b(sk-[a-zA-Z0-9\-_]{20,})\b", r"sk-***"),
(r"\b(pk-[a-zA-Z0-9\-_]{20,})\b", r"pk-***"),
# Generic long base64-like secrets (32+ chars)
(r"\b([A-Za-z0-9+/]{32,}={0,2})\b", r"***"),
# Passwords in URLs
(r"(://)([^:@/]+):([^:@/]+)@", r"\1\2:***@"),
# Environment variable assignments with secrets (env-style: VAR=value, VAR: value)
(r"(?m)^\s*(?:PASSWORD|SECRET|TOKEN|KEY|API_KEY)\s*[=:]?\s*\S+", "***", re.IGNORECASE),
# Password/passwort in text with assignment (password: value, password=value)
(r"(?i)(password|passwort|pwd)\s*[:=]\s*\S+", "***"),
# SSH private key markers
(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", "***PRIVATE KEY***"),
]
result = text
for pattern_tuple in patterns:
if len(pattern_tuple) == 3:
pattern, repl, flags = pattern_tuple
result = re.sub(pattern, repl, result, flags=flags)
else:
pattern, repl = pattern_tuple
result = re.sub(pattern, repl, result)
return result
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."""
# Sanitize output to prevent leaking secrets
text = sanitize_output(text)
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 require(update, auth.ROLE_VIEWER):
await deny(update, "/start", auth.ROLE_VIEWER)
return
desc = llm.describe()
primary_info = ""
if desc["primary"]:
primary_info = f"\n🧠 Primary: `{desc['primary']['provider']}` ({desc['primary']['model']})"
fallback_info = ""
if desc["fallback"]:
fallback_info = f"\n🔄 Fallback: `{desc['fallback']['provider']}` ({desc['fallback']['model']})"
role = auth.get_role(_user_id(update)) or "—"
audit_log.log_action(_user_id(update), "bot.start", role=role)
await update.message.reply_text(
f"🐠 *Clownfischserver v0.5.1 online.*{primary_info}{fallback_info}\n"
f"👤 Deine Rolle: `{role}`\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"
"• `/logs [filter]` – Audit-Trail abfragen (operator)\n"
"• `/help` – diese Hilfe",
parse_mode="Markdown"
)
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not require(update, auth.ROLE_VIEWER):
await deny(update, "/status", auth.ROLE_VIEWER)
return
audit_log.log_action(_user_id(update), "status.read",
role=auth.get_role(_user_id(update)))
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 require(update, auth.ROLE_VIEWER):
await deny(update, "/snapshots", auth.ROLE_VIEWER)
return
audit_log.log_action(_user_id(update), "snapshots.list",
role=auth.get_role(_user_id(update)))
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 require(update, auth.ROLE_OPERATOR):
await deny(update, "/rollback", auth.ROLE_OPERATOR)
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]
audit_log.log_action(
_user_id(update), "snapshot.rollback",
role=auth.get_role(_user_id(update)),
details={"snapshot": snap_name},
)
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"""
args = context.args
subcmd = args[0].lower() if args else "create"
# Read-only subcommands are viewer-accessible; everything else mutates
# and requires operator.
if subcmd in ("list", "ls"):
required = auth.ROLE_VIEWER
else:
required = auth.ROLE_OPERATOR
if not require(update, required):
await deny(update, f"/snapshot {subcmd}", required)
return
audit_log.log_action(
_user_id(update), f"snapshot.{subcmd}",
role=auth.get_role(_user_id(update)),
details={"args": args[1:] if len(args) > 1 else []},
)
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 require(update, auth.ROLE_VIEWER):
await deny(update, "/help", auth.ROLE_VIEWER)
return
await cmd_start(update, context)
async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Operator-only audit query.
Usage:
/logs → last 20 entries
/logs user 12345 → filter by user
/logs action shell.execute → exact action
/logs prefix shell. → action prefix
/logs days 7 → last 7 days only
/logs result denied → filter by result
/logs limit 50 → up to 50 entries
Filters can be combined: /logs user 12345 days 1 limit 10
"""
if not require(update, auth.ROLE_OPERATOR):
await deny(update, "/logs", auth.ROLE_OPERATOR)
return
args = list(context.args) if context.args else []
filters: dict[str, object] = {"limit": 20}
i = 0
while i < len(args) - 1:
key = args[i].lower()
val = args[i + 1]
if key == "user":
filters["user_id"] = val
elif key == "action":
filters["action"] = val
elif key == "prefix":
filters["action_prefix"] = val
elif key == "result":
filters["result"] = val
elif key == "days":
try:
filters["days"] = int(val)
except ValueError:
await update.message.reply_text(f"❌ `days` muss eine Zahl sein, war `{val}`",
parse_mode="Markdown")
return
elif key == "limit":
try:
filters["limit"] = max(1, min(200, int(val)))
except ValueError:
await update.message.reply_text(f"❌ `limit` muss eine Zahl sein, war `{val}`",
parse_mode="Markdown")
return
else:
await update.message.reply_text(
f"❓ Unbekannter Filter `{key}`. Erlaubt: user, action, prefix, result, days, limit",
parse_mode="Markdown")
return
i += 2
audit_log.log_action(
_user_id(update), "logs.query",
role=auth.get_role(_user_id(update)),
details={"filters": filters},
)
try:
records = audit_query.query(**filters)
except Exception as e:
logger.error(f"audit query failed: {e}", exc_info=True)
await update.message.reply_text(f"❌ Audit-Query fehlgeschlagen: `{e}`",
parse_mode="Markdown")
return
if not records:
await update.message.reply_text("📭 Keine passenden Audit-Einträge.")
return
summary = audit_query.summarize(records)
header = f"📜 *{summary['total']} Einträge*"
if summary["by_result"]:
header += " · " + ", ".join(
f"{k}={v}" for k, v in sorted(summary["by_result"].items())
)
lines = [audit_query.format_record(r) for r in records]
body = "\n".join(lines)
# Telegram limit ~4096; chunk if necessary.
msg = f"{header}\n```\n{body}\n```"
if len(msg) > 4000:
# Drop the code-fence wrapping for large results and split.
await safe_reply(update.message, header)
chunk_size = 3800
for i in range(0, len(body), chunk_size):
chunk = body[i:i + chunk_size]
await safe_reply(update.message, f"```\n{chunk}\n```")
else:
await safe_reply(update.message, msg)
async def cmd_run(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Direct shell execution – bypasses Ollama completely"""
if not require(update, auth.ROLE_OPERATOR):
await deny(update, "/run", auth.ROLE_OPERATOR)
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)
uid = _user_id(update)
role = auth.get_role(uid)
await update.message.reply_text(f"⚡ Direkt: `{cmd}`", parse_mode="Markdown")
stdout, stderr, returncode = await run_shell(cmd)
audit_log.log_shell(uid, cmd, role=role,
status="ok" if returncode == 0 else "error",
returncode=returncode, stderr=stderr or None)
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 require(update, auth.ROLE_OPERATOR):
await deny(update, "/ssh", auth.ROLE_OPERATOR)
return
action = context.args[0].lower() if context.args else ""
client_ip = context.args[1] if len(context.args) > 1 else None
audit_log.log_action(
_user_id(update), "ssh.toggle",
role=auth.get_role(_user_id(update)),
details={"action": action, "client_ip": client_ip},
)
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 require(update, auth.ROLE_OPERATOR):
await deny(update, "/shell", auth.ROLE_OPERATOR)
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
primary_is_ollama = isinstance(llm.primary, OllamaProvider)
ollama_warming = ollama_ready is None or not ollama_ready.is_set()
if primary_is_ollama and ollama_warming and not llm.has_fallback():
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
uid = _user_id(update)
role = auth.get_role(uid)
if result.get("dangerous", False):
audit_log.log_shell_proposed(uid, description, command,
role=role, dangerous=True)
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
audit_log.log_shell_proposed(uid, description, command, role=role)
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):
# /ja is the approval gate – approver inherits to operator.
if not require(update, auth.ROLE_APPROVER):
await deny(update, "/ja", auth.ROLE_APPROVER)
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)
uid = _user_id(update)
role = auth.get_role(uid)
audit_log.log_approval(uid, command, role=role, approved=True)
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)
audit_log.log_shell(uid, command, role=role, status="ok")
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)
audit_log.log_shell(uid, command, role=role, status="error",
stderr=str(e))
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 require(update, auth.ROLE_OPERATOR):
await deny(update, "/code", auth.ROLE_OPERATOR)
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()
# Only auto-generate a filepath if the task doesn't already point at one.
words = task.split()
first_word_is_path = bool(words) and "/" in words[0]
needs_filepath = (
workspace not in task
and "/opt/" not in task
and not first_word_is_path
)
if needs_filepath:
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")
uid = _user_id(update)
role = auth.get_role(uid)
try:
result = await aider.run(task)
audit_log.log_code(uid, task, role=role, status="ok")
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)
audit_log.log_code(uid, task, role=role, status="error")
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 require(update, auth.ROLE_OPERATOR):
await deny(update, "file.upload", auth.ROLE_OPERATOR)
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
# PowerShell's Compress-Archive writes path entries with backslashes,
# which Python's zipfile does NOT split on under POSIX – without this
# normalization the archive extracts as flat files named
# "clownfischserver\bot\bot.py", and os.walk never finds bot.py.
await status_msg.edit_text("📂 Entpacke ZIP...")
extract_dir = os.path.join(tmp_dir, "extracted")
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as z:
for info in z.infolist():
normalized = info.filename.replace("\\", "/")
if not normalized or normalized.endswith("/"):
if normalized:
os.makedirs(os.path.join(extract_dir, normalized.rstrip("/")),
exist_ok=True)
continue
target_path = os.path.join(extract_dir, *normalized.split("/"))
# Zip-Slip guard: refuse entries that escape extract_dir.
if not os.path.abspath(target_path).startswith(
os.path.abspath(extract_dir) + os.sep):
raise ValueError(f"Unsicherer Pfad im ZIP: {info.filename}")
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with z.open(info) as src, open(target_path, "wb") as dst:
shutil.copyfileobj(src, dst)
# 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."""
uid = _user_id(update)
if not require(update, auth.ROLE_VIEWER):
logger.warning(f"Unauthorized access attempt from user_id: {uid}")
audit_log.log_denied(uid, "chat.message", reason="unauthorized")
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]}")
primary_is_ollama = isinstance(llm.primary, OllamaProvider)
ollama_warming = ollama_ready is None or not ollama_ready.is_set()
if primary_is_ollama and ollama_warming and not llm.has_fallback():
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)
# Sanitize LLM response before sending
result = sanitize_output(result)
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)
error_msg = sanitize_output(f"❌ Chat-Fehler: `{e}`")
await thinking_msg.edit_text(error_msg, 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.5.1 Bot startet...")
import requests as req
import time
import threading
# Pull Ollama models from the router – respects LLM_*_MODEL overrides
# and falls back to OLLAMA_MODEL/OLLAMA_MODEL_FAST for backwards compat.
base_url = llm.ollama_base_url
ollama_models = llm.ollama_models
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():
if not ollama_models:
logger.info("Kein Ollama-Provider konfiguriert – Warmup übersprungen.")
ollama_ready.set()
return
logger.info(f"Ollama Warmup gestartet (Hintergrund): {ollama_models}")
for idx, m in enumerate(ollama_models):
label = "Fast-Modell" if idx == 0 and len(ollama_models) > 1 else "Hauptmodell"
warmup_model(m, label)
ollama_ready.set()
threading.Thread(target=warmup_ollama, daemon=True).start()
if ollama_models:
logger.info(f"🐠 Bot startet – Ollama warmup für {len(ollama_models)} Modell(e)")
else:
logger.info("🐠 Bot startet – kein Ollama-Provider, kein Warmup")
ollama_ready.set()
route_desc = llm.describe()
logger.info(f"LLM Routing: {route_desc}")
# Audit-Trail Setup: prune old logs on boot, log the role roster.
dropped = audit_log.prune_old_logs()
if dropped:
logger.info(f"Audit: {dropped} alte Einträge entfernt (Retention)")
roster = auth.describe_roles()
logger.info(
f"Rollen: operators={len(roster['operator'])} "
f"approvers={len(roster['approver'])} viewers={len(roster['viewer'])}"
)
audit_log.log_action(None, "bot.boot", details={"roster": roster})
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))
app.add_handler(CommandHandler("logs", cmd_logs))
# 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))
legacy_info = f" legacy_chat={CHAT_ID}" if CHAT_ID else ""
logger.info(f"Bot läuft. Rollen geladen.{legacy_info}")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.initialize())
app.run_polling(allowed_updates=Update.ALL_TYPES)
finally:
loop.run_until_complete(app.shutdown())
if __name__ == "__main__":
main()