"""Orbitalo Hausmeister — Telegram Bot für Homelab-Management.""" import asyncio import logging import sys import os import fcntl import atexit import signal sys.path.insert(0, os.path.dirname(__file__)) PIDFILE = "/tmp/hausmeister-bot.pid" _lock_fp = None def _acquire_lock(): """Stellt sicher, dass nur eine Bot-Instanz läuft (PID-File + flock).""" global _lock_fp _lock_fp = open(PIDFILE, "w") try: fcntl.flock(_lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: print(f"ABBRUCH: Bot läuft bereits (PID-File {PIDFILE} ist gelockt)", file=sys.stderr) sys.exit(1) _lock_fp.write(str(os.getpid())) _lock_fp.flush() def _release_lock(): global _lock_fp if _lock_fp: try: fcntl.flock(_lock_fp, fcntl.LOCK_UN) _lock_fp.close() os.unlink(PIDFILE) except OSError: pass from telegram import BotCommand, Update, ReplyKeyboardMarkup, KeyboardButton from telegram.ext import ( Application, CommandHandler, MessageHandler, filters, ContextTypes, ) BOT_COMMANDS = [ BotCommand("status", "Alle Container"), BotCommand("errors", "Aktuelle Fehler"), BotCommand("ct", "Container-Detail (/ct 109)"), BotCommand("health", "Health-Check (/health wordpress)"), BotCommand("logs", "Letzte Logs (/logs rss-manager)"), BotCommand("silence", "Stille Hosts"), BotCommand("report", "Tagesbericht"), BotCommand("check", "Monitoring-Check"), BotCommand("feeds", "Feed-Status & Artikel heute"), BotCommand("start", "Hilfe anzeigen"), ] KEYBOARD = ReplyKeyboardMarkup( [ [KeyboardButton("📊 Status"), KeyboardButton("❌ Fehler"), KeyboardButton("📰 Feeds")], [KeyboardButton("📋 Report"), KeyboardButton("🔧 Check"), KeyboardButton("🔇 Stille")], ], resize_keyboard=True, is_persistent=True, ) BUTTON_MAP = { "📊 Status": "status", "❌ Fehler": "errors", "📰 Feeds": "feeds", "📋 Report": "report", "🔧 Check": "check", "🔇 Stille": "silence", } import context import requests as _req import llm import memory_client import monitor from core import config logging.basicConfig( format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", level=logging.INFO, ) log = logging.getLogger("hausmeister") ALLOWED_CHAT_IDS: set[int] = set() def _load_token_and_chat(): cfg = config.parse_config() token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") chat_id = cfg.raw.get("TG_CHAT_ID", "") if chat_id: ALLOWED_CHAT_IDS.add(int(chat_id)) return token def _authorized(update: Update) -> bool: if not ALLOWED_CHAT_IDS: return True return update.effective_chat.id in ALLOWED_CHAT_IDS async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text( "🔧 Orbitalo Hausmeister-Bot\n\n" "Befehle:\n" "/status — Alle Container\n" "/errors — Aktuelle Fehler\n" "/ct — Container-Detail\n" "/health — Health-Check\n" "/logs — Letzte Logs\n" "/silence — Stille Hosts\n" "/report — Tagesbericht\n" "/check — Monitoring-Check\n" "/feeds — Feed-Status & Artikel\n\n" "Oder einfach eine Frage stellen!", reply_markup=KEYBOARD, ) async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text("⏳ Lade Container-Status...") try: text = context.gather_status() if len(text) > 4000: text = text[:4000] + "\n..." await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_errors(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text("⏳ Suche Fehler...") try: text = context.gather_errors(hours=2) await update.message.reply_text(text[:4000]) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_ct(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return args = ctx.args if not args: await update.message.reply_text("Bitte CT-Nummer angeben: /ct 109") return try: text = context.gather_container_status(args[0]) await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_health(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return args = ctx.args if not args: await update.message.reply_text("Bitte Hostname angeben: /health wordpress") return try: text = context.gather_health(args[0]) await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_logs(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return args = ctx.args if not args: await update.message.reply_text("Bitte Hostname angeben: /logs rss-manager") return try: text = context.gather_logs(args[0]) await update.message.reply_text(text[:4000]) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_silence(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return try: text = context.gather_silence() await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_report(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text("⏳ Erstelle Tagesbericht...") try: text = monitor.format_report() await update.message.reply_text(text[:4000]) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def cmd_check(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text("⏳ Prüfe Systeme...") try: alerts = monitor.check_all() if alerts: text = f"⚠️ {len(alerts)} Alarme:\n\n" + "\n".join(alerts) else: text = "✅ Keine Alarme — alles läuft." await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Fehler: {e}") def _get_feed_stats(): """Holt Feed-Statistiken von der RSS Manager API.""" cfg = config.parse_config() ct_109 = config.get_container(cfg, vmid=109) url = f"http://{ct_109.tailscale_ip}:8080/api/feed-stats" if ct_109 else None if not url: return None try: r = _req.get(url, timeout=10) return r.json() if r.ok else None except Exception: return None def format_feed_report(stats: dict) -> str: """Formatiert Feed-Statistiken für Telegram.""" today = stats["today"] yesterday = stats["yesterday"] feeds = stats["feeds"] lines = [f"📊 Feed-Report ({stats['date']})", f"Artikel heute: {today} (gestern: {yesterday})", ""] active = [f for f in feeds if f["posts_today"] > 0] if active: lines.append("📰 Aktive Feeds:") for f in active: lines.append(f" {f['name']}: {f['posts_today']} ({f['schedule']})") silent = [f for f in feeds if f["posts_today"] == 0] if silent: lines.append("") lines.append("😴 Keine Artikel heute:") for f in silent: yd = f"(gestern: {f['posts_yesterday']})" if f["posts_yesterday"] > 0 else "(auch gestern 0)" lines.append(f" {f['name']} {yd}") errors = [f for f in feeds if f["error_count"] and f["error_count"] > 0] if errors: lines.append("") lines.append("⚠️ Fehler:") for f in errors: lines.append(f" {f['name']}: {f['error_count']}x — {f['last_error']}") return "\n".join(lines) async def cmd_feeds(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not _authorized(update): return await update.message.reply_text("⏳ Lade Feed-Status...") try: stats = _get_feed_stats() if not stats: await update.message.reply_text("RSS Manager nicht erreichbar.") return text = format_feed_report(stats) await update.message.reply_text(text[:4000]) except Exception as e: await update.message.reply_text(f"Fehler: {e}") async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """Button-Presses und Freitext-Fragen verarbeiten.""" if not _authorized(update): return text = update.message.text if not text: return cmd = BUTTON_MAP.get(text) if cmd == "status": return await cmd_status(update, ctx) elif cmd == "errors": return await cmd_errors(update, ctx) elif cmd == "feeds": return await cmd_feeds(update, ctx) elif cmd == "report": return await cmd_report(update, ctx) elif cmd == "check": return await cmd_check(update, ctx) elif cmd == "silence": return await cmd_silence(update, ctx) channel_key = str(update.effective_chat.id) session_id = memory_client.get_or_create_session(channel_key, source="telegram") await update.message.reply_text("🤔 Denke nach...") try: handlers = context.get_tool_handlers(session_id=session_id) answer = llm.ask_with_tools(text, handlers, session_id=session_id) if session_id: memory_client.log_message(session_id, "user", text) memory_client.log_message(session_id, "assistant", answer) await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) except Exception as e: log.exception("Fehler bei Freitext") await update.message.reply_text(f"Fehler: {e}") def main(): token = _load_token_and_chat() if not token: log.error("TG_HAUSMEISTER_TOKEN fehlt in homelab.conf!") sys.exit(1) _acquire_lock() atexit.register(_release_lock) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) log.info("Starte Orbitalo Hausmeister-Bot...") app = Application.builder().token(token).build() app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("errors", cmd_errors)) app.add_handler(CommandHandler("ct", cmd_ct)) app.add_handler(CommandHandler("health", cmd_health)) app.add_handler(CommandHandler("logs", cmd_logs)) app.add_handler(CommandHandler("silence", cmd_silence)) app.add_handler(CommandHandler("report", cmd_report)) app.add_handler(CommandHandler("check", cmd_check)) app.add_handler(CommandHandler("feeds", cmd_feeds)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) async def post_init(application): await application.bot.set_my_commands(BOT_COMMANDS) log.info("Kommandomenü registriert") app.post_init = post_init log.info("Bot läuft — polling gestartet") app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()