From fcd0a1616e979f035becd105823d5e43ddbef237 Mon Sep 17 00:00:00 2001 From: Auto-Sync Date: Sun, 8 Mar 2026 14:26:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20/feeds=20Befehl=20+=20t=C3=A4glicher=20?= =?UTF-8?q?Feed-Report=2022:00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/context.cpython-311.pyc | Bin 9999 -> 9999 bytes .../__pycache__/llm.cpython-311.pyc | Bin 2494 -> 2494 bytes .../__pycache__/monitor.cpython-311.pyc | Bin 10056 -> 139 bytes homelab-ai-bot/context.py | 159 ++++++++++ .../core/__pycache__/__init__.cpython-311.pyc | Bin 145 -> 145 bytes .../core/__pycache__/config.cpython-311.pyc | Bin 10709 -> 10709 bytes .../__pycache__/loki_client.cpython-311.pyc | Bin 8092 -> 8092 bytes .../proxmox_client.cpython-311.pyc | Bin 8252 -> 8252 bytes homelab-ai-bot/daily_feed_report.py | 59 ++++ homelab-ai-bot/llm.py | 44 +++ homelab-ai-bot/telegram_bot.py | 285 ++++++++++++++++++ 11 files changed, 547 insertions(+) create mode 100644 homelab-ai-bot/daily_feed_report.py diff --git a/homelab-ai-bot/__pycache__/context.cpython-311.pyc b/homelab-ai-bot/__pycache__/context.cpython-311.pyc index dea17c7f1333f8359c264e30f53e32acd125c384..8d04a239d2c2bff8e71a836beb621f9c4d312d3d 100644 GIT binary patch delta 19 ZcmeD8>-XbY&dbZi00jFgHgd750{}DC1g!u7 delta 19 YcmeD8>-XbY&dbZi00aR68@X830Wf(47XSbN diff --git a/homelab-ai-bot/__pycache__/llm.cpython-311.pyc b/homelab-ai-bot/__pycache__/llm.cpython-311.pyc index 33b0d01154471701c150ce42ad138961e4489602..b82a11d8d709392b77d14603cbe7ef27ec6ed803 100644 GIT binary patch delta 19 Zcmdldyib^GIWI340}$-1*vPeo696;Y1ttIh delta 19 Zcmdldyib^GIWI340}uoRY~>vE1dsp# diff --git a/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc b/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc index 002b9cea648d4490c8c08bd17e73ae2c7145ea25..39a296a4132eb8dbe49b7cc04b713566ef24cc58 100644 GIT binary patch literal 139 zcmZ3^%ge<81gv3eGePuY5CH>>P{wCAAY(d13PUi1CZpd&tkzPUNFAkgB{FKt1RJ$TppmLCb#r#0x12ZEd;|B&9 JQN#=s0{|n^9F+h7 literal 10056 zcmcgSYit`wdb@ls-!DBZ$#&MVVvBm(vJ=}nNj^oE{FMBV;+*9;EX`d>wD^!_mrAUo z4!pocWYswHQWsY4Y=k|e36r?63S0}LIG_dcr!DT#U1N(73j`2Sq&)*gtAp>y!N2;= zEJ=}+lsG}J!{Ilxv-9}AnQy-NzHjy;hr>!iIDhW1!)Lt|_3!wh5KQ^Z)1S~3b(i9( zaf+ikU4$N|Nva#ykyJmf=jm&PLbHsqkSk3&n%9-oDfC@hh2qU0!bqROUke}V;0gX( zpz-MX2otr8TcXx+Yt%MwgEGSnYTV8l0Xilr&h!g<+{u}F7w@hsG^8eU9CO1k?tyYQ zL|VjXsj)CS&k>?o+i|zJ*9QH z5)3#`zQ=0lX&d~#A3|537P_O#`vaQqLLg>5?>O?16ETG|Y`@M6Ee@aWF7;_VnOL;KbnA z_{iXez)1h_ph~H=8{b^)KlAFKU#A*k!6>g9=Yo+0|I9Ws$G1=M;(Uxp@J^3{Z>su9 zUi2GOeQ07*HHq9;&6_4_f;MMkExczEe?2Kxw(=l^h zwFRJ;fYusSK`R^lg-ZZZRLCgBc8FF%ST#Cd7*g6bnJV7_hJ>>)rzVdY2MQw&}HEzFg=mP?NB^ z#S)=@4fjBTXes1i>s1Ib5%YCERK6?cBoG3+W7L)ZSkQBHQa?u_`j$DV&un@`a5_$3 z(b}+OT?1#VsB7G^u8A{O)HNjyB4&Yn0K8sInmA^fP>)_n(izNC8vYlHv6E!>BiDPBQbb3dQ5;pokebABt~1^RTw% z2hf6LOX|EO)cKMYpss2ob+`Ug#4MT**NU9LRdY2DYAbNIa@%fDN$WAHg70ix9hBQP zl-rYbbXie(ynRZ!$eIx>y|Mx`m&P+k+(#(`S5q<=fNBU8c^>pdkM<^BAe zy00rQp{cT2!E%KJZs&s+%4;q-lTI`MyTQrn*9o1Y(GiXXTAh`&F1V7eufciOmN>i0 z$K4EPa-=pUjsmI|N|Xv7u>(_lXu6CV}_Z+WU8Y z$qtOM^E$S9-sERt1oj!dOVvmDsA`G^--7fB7GIOU`85vzb@T}bPYK-nmp{w=&!gL< zwbjpF=pSaU@?mKG;v~F;uzK`K7bG9+RO3`UfdthU2}i?X*=Y%~lEORZX+40uAPKD_&n!KYe2jl<2KCMF{o2*- zC{`q$yaJv4;kVRp@nYKo5VNz3{b|qL>UXQtbIVBfG%B9P&ppkLJHlc(7lYY7D~)o~ zOG?vAk{!o2GgVc+W}+M~YtI`u2G(E*OW_$WGE_Zgs0@^B3>(=g&nOKAMwyBWBH9Zr zM_}*cQ=w$Ov|W$-p>%%xW(-ka7c5zYeE{-I;OmimJ=y)~{=0+k4leJ>?3SzcC{=r) zup$x?pV^vzc;f8n7&dPf67A3pk(Q@XSPTm=EIY(c!Q!&bgfTh^Uv!>5JM3qJiHUCZ znXZj}vKv}`OsnQ-IF=B3p==}KStH`)3}o`cWY^ctS-Ak=u!O7w^Nd!0v{IH1246*!{Jyr>vto(>s9+q5D9!h zGn)~fB-M-UI@n3LPz+)bA|PxV5F<|$BffzPC&x|>^q&a~oE;hKKM8i}sA>w%%Q}3aVb1|thirX8vA&QRAja-jo);8P*B4ApT9BCrg=x5d zIqQ>mv@1K>f2Y@ZO~0oAq)x%ccGay>4&!lJgNwSH&6~cu*t^)9vsdL@p4)RvbLolY zpzLB57rSU!FAB-7M#a?#MIPVXns;lKnT%idv?-ppMN`h^`ZwPm$%bS0<&{g1Tl=Ke zzMS2Adw6MBs(&Rj1^{9 z@g7N?Ueguj@HCOhzr%w0_ZYxSE5-Pr)c6Mdh1;izniU(Qvad%0=64R8kxxx8hd4n!u{rW#O{j2|9{L}A>gn&N&9#@9wWzdqX8 zM`QnR>F+Q7V?yp7R(fI8&O(&!=M?)n$$oAXJ3zF<02r|t@P5D>`RRWHd|pGCqbKP< zwkgc%K*0xdxLij8(^$W0xdHn>H>q!gL(NpAwj{$D#iHQ>M!UXTT!T4hDtm9(@ZMbZ z-nijCv(X$BJ?jWuE4mCg3uk>`E7#W+%0}nxK%1#@F3Lw-FmFkzBWd2upee%!^8pE* z^Ff6%v;f95dV_NV#AdZ;{Q!&6VG4BJbDS6OV=D0jaba|vCN1TZ zZH9Xc8>XADYjt^Rnu=9Ff8A1Zx`OkpNh|8vvZtCdZm^c)0Nhl2zkrbY#;{ZPQX;B9oa3=U%74++Nc}t znuw!lPz)e`CXU3-%*t1xo2U5r2mQ!7sKxa8Ed9{qEkI7Wcf*1!?h$Ez;Yk=r?q4FZ0ENnGy#M}U-1oKVocxv5_M=#^nhcNiA zNW2D(g&hE}DtbtZ>DlbwmGjxQY+KrcVYZE6IBy#$=!@O#yp_#6KH6|TKOvC!uwd3r zi7aSoJY=p0(G%>jMx7X72SynuejTAD4vP{(*X_K#-9qfVZ|14XFm%CHQPV4txO_f) z{Ly}?tp{RyR!aeD)H)0HNY}wBe>aOxV*;>kL3}5~D^y0xAI<(iXYrue;y~vBd>2;x z6WsU$2H4vQ$YkvlQSsTR#j2!J^e#LszaArw!5&BEil6Bt|p zAmGET#4yl|0!Nv2Lf4_rFs@v?sbK&7-l8IY+m(lTp1`)y+Nthc0y(kq;gHK0?^y1c>7YPiz9YsS|v{>MA@)U zG3=8J`?k`H&BnI$-t3&z*bCke&jUy$G5B;C` zR|e&lZl$Fg3Lwh%Ud7%kk+@o@*&*9I6?>;d;wtF*_9eSi-6Aus3ezevtzS86ZeG51 zIj^l7jJ?-LeQBk5OVTZ3Y2kPHoEQ>jLBQ>vOnQ*gG0IdD>d@d>AQ2y5?!fHfkb&12Wj zR@ywc(<#tMbocd1ItkZE_jH`$f$>9V_i3>&&?8K6?waR!kHVhdLEge{U`K+XDWWQ{ zbKxL6#z*)`6pVJvZ=Vvy8KJwgGdL6O5DUd|G}#GvzVr4@0s0u$(1oM?hk%=E5MjGL zu|gKyYz7XzbgEelM|tqkfwNI%5D)G-Y;GH7K#G9FaUQCYrYH%yyvtFO(W(^>04@hG z`_Te%dFu(=@S+}<^_!Ds^e&xq|mBX-f10y6g@ z=&2a6bRkn*Pj5ZN6{;h?SBCwqg-mgh3ne(kA6QhF0Qf_KZ)&ySW*P1fX$1F(0xaq% zReCR3fs-CS8))T>oN4pVR4NT3JpBs>vE=3mGf&d6fo)(^JhI{oDGg5uymnt`j336V|2IB+Zx4(lgg8+HO@yTHai`#Ui_*`ZdA9S@x7e|2#u z-F55u?bA!ArEU1b1^WTTejqiNb9!z~-kx5XUT(|kWM`}5Yz3K3YuQ9FUp43bR)##5E%{S)%}G z;)DqVqTrhg1W*Uyitos=w(PG{&4B50a&~ION8de~IV}6y6knT6wJTJ+M78IrgVNS<&19mz zYg7?M5}cx;&vb}% zof#m>%I=bZN`|!2u)IaEoif-L=O63qBz;}Z;7pyuzns~c5*82KI{f2>8w=^#$L4L4 zdE4^ny&q(P_bz8oJl=6o+HnxhsYT;rbTN7}ek(3BI~8W9REVoggTyqz*E9;VLn_2I M8)c}6VF+LSFOYZQ3;+NC diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index e69de29b..f09aaa17 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -0,0 +1,159 @@ +"""Intelligente Kontext-Sammlung für den Hausmeister-Bot. +Entscheidet anhand der Frage welche Datenquellen abgefragt werden.""" + +import sys +import os +import re + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client + + +def _load_config(): + return config.parse_config() + + +def _get_tokens(cfg): + tokens = {} + tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") + tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") + if tn and tv: + tokens["pve-hetzner"] = {"name": tn, "value": tv} + return tokens + + +def _get_passwords(cfg): + return { + "pve-hetzner": cfg.passwords.get("hetzner", ""), + "pve1": cfg.passwords.get("default", ""), + "pve3": cfg.passwords.get("default", ""), + "default": cfg.passwords.get("default", ""), + } + + +def gather_status() -> str: + """Komplett-Status aller Container für /status.""" + cfg = _load_config() + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + return proxmox_client.format_containers(containers) + + +def gather_errors(hours: float = 2) -> str: + """Aktuelle Fehler aus Loki für /errors.""" + entries = loki_client.get_errors(hours=hours, limit=30) + return loki_client.format_logs(entries) + + +def gather_container_status(query: str) -> str: + """Status eines einzelnen Containers.""" + cfg = _load_config() + vmid = None + name = None + + m = re.search(r'\b(\d{3})\b', query) + if m: + vmid = int(m.group(1)) + else: + name = query.strip() + + ct = config.get_container(cfg, vmid=vmid, name=name) + if not ct: + return f"Container nicht gefunden: {query}" + + host_ip = proxmox_client.PROXMOX_HOSTS.get(ct.host) + if not host_ip: + return f"Host nicht erreichbar: {ct.host}" + + token = _get_tokens(cfg).get(ct.host, {}) + pw = _get_passwords(cfg).get(ct.host, "") + try: + client = proxmox_client.ProxmoxClient( + host_ip, password=pw, + token_name=token.get("name", ""), + token_value=token.get("value", ""), + ) + status = client.get_container_status(ct.vmid) + except Exception as e: + return f"Proxmox-Fehler: {e}" + + mem_mb = status.get("mem", 0) // (1024 * 1024) + maxmem_mb = status.get("maxmem", 0) // (1024 * 1024) + uptime_h = status.get("uptime", 0) // 3600 + + return ( + f"CT {ct.vmid} — {ct.name}\n" + f"Host: {ct.host}\n" + f"Status: {status.get('status', '?')}\n" + f"RAM: {mem_mb}/{maxmem_mb} MB\n" + f"CPU: {status.get('cpus', '?')} Kerne\n" + f"Uptime: {uptime_h}h\n" + f"Tailscale: {ct.tailscale_ip or '—'}\n" + f"Dienste: {ct.services}" + ) + + +def gather_logs(container: str, hours: float = 1) -> str: + """Logs eines Containers aus Loki.""" + entries = loki_client.query_logs( + f'{{host="{container}"}}', hours=hours, limit=20 + ) + return loki_client.format_logs(entries) + + +def gather_health(container: str) -> str: + """Health-Check eines Containers.""" + health = loki_client.get_health(container, hours=24) + status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "🔴"}.get( + health.get("status", ""), "❓" + ) + return ( + f"{status_emoji} {health.get('host', container)}\n" + f"Status: {health.get('status', '?')}\n" + f"Fehler (24h): {health.get('errors_last_{hours}h', '?')}\n" + f"Sendet Logs: {'ja' if health.get('sending_logs') else 'nein'}" + ) + + +def gather_silence() -> str: + """Welche Hosts senden keine Logs?""" + silent = loki_client.check_silence(minutes=35) + if not silent: + return "✅ Alle Hosts senden Logs." + if silent and "error" in silent[0]: + return f"Fehler: {silent[0]['error']}" + lines = ["⚠️ Stille Hosts (keine Logs seit 35+ Min):\n"] + for s in silent: + lines.append(f" • {s['host']}") + return "\n".join(lines) + + +def gather_context_for_question(question: str) -> str: + """Sammelt relevanten Kontext für eine Freitext-Frage.""" + q = question.lower() + parts = [] + + if any(w in q for w in ["fehler", "error", "problem", "kaputt", "down"]): + parts.append("=== Aktuelle Fehler ===\n" + gather_errors(hours=2)) + + if any(w in q for w in ["status", "läuft", "container", "übersicht", "alles"]): + parts.append("=== Container Status ===\n" + gather_status()) + + if any(w in q for w in ["still", "silence", "stumm", "logs"]): + parts.append("=== Stille Hosts ===\n" + gather_silence()) + + ct_match = re.search(r'\bct[- ]?(\d{3})\b', q) + if ct_match: + parts.append(f"=== CT {ct_match.group(1)} ===\n" + gather_container_status(ct_match.group(1))) + + for name in ["wordpress", "rss", "seafile", "forgejo", "portainer", + "fuenfvoracht", "redax", "flugscanner", "edelmetall"]: + if name in q: + parts.append(f"=== {name} ===\n" + gather_container_status(name)) + + if not parts: + parts.append("=== Container Status ===\n" + gather_status()) + parts.append("=== Aktuelle Fehler ===\n" + gather_errors(hours=1)) + + return "\n\n".join(parts) diff --git a/homelab-ai-bot/core/__pycache__/__init__.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/__init__.cpython-311.pyc index 56c2c7bfc3314521e8bffba4fe95a2528f1021b7..96e8960c4c3c5d6fbf051c059e5319ef598d6244 100644 GIT binary patch delta 18 YcmbQpIFXTSIWI340}zOXP2_3^03sd(hyVZp delta 18 YcmbQpIFXTSIWI340}zA;Oyp_@03$&I!2kdN diff --git a/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc index 234bf29fd3483688af79a919721321efe66f76af..92101d87bc361af09216b9c1a9be8312b83b6e29 100644 GIT binary patch delta 19 ZcmcZ_d^MPBIWI340}zOXZR9$w2>?4w1wjA+ delta 19 ZcmcZ_d^MPBIWI340}zA;Y~(tv2>?8&1$qDg diff --git a/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc index 68f76c91c92bd0628864ed916b1445ec179fed17..ef98d0b22dbe9f0634de1fed43d9fd01b16f1540 100644 GIT binary patch delta 19 ZcmbPZKgXVHIWI340}zOXZRDCH4*)PD1Y`gJ delta 19 ZcmbPZKgXVHIWI340}zA;Y~-3G4*)TL1f2i? diff --git a/homelab-ai-bot/core/__pycache__/proxmox_client.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/proxmox_client.cpython-311.pyc index 35ec04a13aeace25f1e6c39d9faedf3465f445dc..9ac4f3abbbd458442569f5294f02462a6682535a 100644 GIT binary patch delta 19 Zcmdnvu*ZRGIWI340}zOXZR9di001-w1V;b> delta 19 Zcmdnvu*ZRGIWI340}zA;Y~(Uh001>&1b_el diff --git a/homelab-ai-bot/daily_feed_report.py b/homelab-ai-bot/daily_feed_report.py new file mode 100644 index 00000000..0af78e61 --- /dev/null +++ b/homelab-ai-bot/daily_feed_report.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Täglicher Feed-Report — läuft via Cron um 22:00.""" +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +import requests +from core import config + +def main(): + cfg = config.parse_config() + ct_109 = config.get_container(cfg, vmid=109) + if not ct_109: + return + + url = f"http://{ct_109.tailscale_ip}:8080/api/feed-stats" + try: + r = requests.get(url, timeout=10) + stats = r.json() + except Exception as e: + print(f"RSS Manager nicht erreichbar: {e}") + return + + today = stats["today"] + yesterday = stats["yesterday"] + feeds = stats["feeds"] + lines = [f"📊 Täglicher 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.get("error_count", 0) > 0] + if errors: + lines.append("") + lines.append("⚠️ Fehler:") + for f in errors: + lines.append(f" {f['name']}: {f['error_count']}x — {f['last_error']}") + + msg = "\n".join(lines) + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if token and chat_id: + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": msg}, + timeout=10, + ) + print("Report gesendet") + +if __name__ == "__main__": + main() diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index e69de29b..89066456 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -0,0 +1,44 @@ +"""OpenRouter LLM-Wrapper für natürliche Antworten.""" + +import requests +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config + +MODEL = "openai/gpt-4o-mini" +SYSTEM_PROMPT = """Du bist der Hausmeister-Bot für ein Homelab mit mehreren Proxmox-Servern. +Du antwortest kurz, präzise und auf Deutsch. +Du bekommst Live-Daten aus Loki (Logs), Proxmox (Container-Status) und homelab.conf. +Wenn alles in Ordnung ist, sag das kurz. Bei Problemen erkläre was los ist und schlage Lösungen vor. +Nutze Emojis sparsam. Formatiere für Telegram (kein Markdown, nur einfacher Text).""" + + +def _get_api_key() -> str: + cfg = config.parse_config() + return cfg.api_keys.get("openrouter_key", "") + + +def ask(question: str, context: str) -> str: + """Stellt eine Frage mit Kontext an OpenRouter.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, + ] + + try: + r = requests.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json={"model": MODEL, "messages": messages, "max_tokens": 500}, + timeout=30, + ) + r.raise_for_status() + return r.json()["choices"][0]["message"]["content"] + except Exception as e: + return f"LLM-Fehler: {e}" diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index e69de29b..b9f6f036 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -0,0 +1,285 @@ +"""Orbitalo Hausmeister — Telegram Bot für Homelab-Management.""" + +import asyncio +import logging +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) + +from telegram import BotCommand, Update +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"), +] + +import context +import requests as _req +import llm +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!" + ) + + +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): + """Freitext-Fragen → Kontext sammeln → LLM → Antwort.""" + if not _authorized(update): + return + question = update.message.text + if not question: + return + + await update.message.reply_text("🤔 Denke nach...") + try: + data = context.gather_context_for_question(question) + answer = llm.ask(question, data) + await update.message.reply_text(answer[:4000]) + 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) + + 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()