"""Obserwator logu serwera Conana — KANAŁ Conan -> Discord. Dwa tryby: - local: plik logu jest na tej samej maszynie co bot (self-host) -> klasyczny tail. - sftp : log pobierany zdalnie (Host Havoc) -> polling przyrostowy po SFTP. Parser jest CELOWO ostrożny: formaty linii logu Conana bywają różne między wersjami i modami (np. Pippi/Tot dokładają własne wpisy). Dlatego dopasowania są zachowawcze, a wszystko, czego nie rozpoznamy, jest ignorowane (nie zgadujemy). WERYFIKUJ wzorce na realnym logu swojego serwera i dostrój REGEXY niżej. """ from __future__ import annotations import asyncio import logging import re from dataclasses import dataclass from typing import AsyncIterator, Callable, Awaitable log = logging.getLogger("logwatch") @dataclass class Event: kind: str # "chat" | "login" | "logout" | "death" | "raw" text: str # gotowy do wyświetlenia tekst raw: str # oryginalna linia (do debugowania) # --- REGEXY DO DOSTROJENIA NA WŁASNYM LOGU --- # Poniżej PRZYKŁADOWE wzorce. Conan/Pippi/Tot logują różnie — sprawdź swój log # i dopasuj. Jeśli wzorzec nie pasuje, linia trafia jako "raw" tylko do debug. _PATTERNS: list[tuple[str, re.Pattern]] = [ ("chat", re.compile(r"Chat:\s*(?P.+?):\s*(?P.+)$", re.I)), ("login", re.compile(r"(?P.+?)\s+(joined|connected|logged in)", re.I)), ("logout", re.compile(r"(?P.+?)\s+(left|disconnected|logged out)", re.I)), ("death", re.compile(r"(?P.+?)\s+was killed by\s+(?P.+)$", re.I)), ] def parse_line(line: str) -> Event | None: line = line.rstrip("\n") if not line.strip(): return None for kind, pat in _PATTERNS: m = pat.search(line) if m: g = m.groupdict() if kind == "chat": return Event(kind, f"💬 **{g['who']}**: {g['msg']}", line) if kind == "login": return Event(kind, f"🟢 **{g['who']}** dołączył do gry", line) if kind == "logout": return Event(kind, f"⚪ **{g['who']}** opuścił grę", line) if kind == "death": return Event(kind, f"💀 **{g['who']}** zginął z ręki {g['by']}", line) return None # nierozpoznane -> ignoruj (nie zgadujemy) async def _follow_local(path: str) -> AsyncIterator[str]: """tail -f w czystym asyncio, podąża też po rotacji pliku.""" import os while True: try: with open(path, "r", encoding="utf-8", errors="replace") as f: f.seek(0, os.SEEK_END) inode = os.fstat(f.fileno()).st_ino while True: line = f.readline() if line: yield line continue await asyncio.sleep(0.5) # wykryj rotację logu try: if os.stat(path).st_ino != inode: break except FileNotFoundError: break except FileNotFoundError: log.warning("Log nie istnieje jeszcze: %s", path) await asyncio.sleep(3.0) async def _follow_sftp(cfg) -> AsyncIterator[str]: """Polling przyrostowy po SFTP (Host Havoc). Czyta tylko nowe bajty.""" import asyncssh offset = 0 while True: try: async with asyncssh.connect( cfg.sftp_host, port=cfg.sftp_port, username=cfg.sftp_user, password=cfg.sftp_password, known_hosts=None, ) as conn: async with conn.start_sftp_client() as sftp: while True: try: attrs = await sftp.stat(cfg.log_path) size = attrs.size or 0 if size < offset: # rotacja offset = 0 if size > offset: async with sftp.open(cfg.log_path, "r") as rf: await rf.seek(offset) chunk = await rf.read() offset = size for ln in chunk.splitlines(): yield ln except FileNotFoundError: log.warning("SFTP: brak logu %s", cfg.log_path) await asyncio.sleep(2.0) except Exception as e: log.warning("SFTP rozłączony: %s — ponawiam", e) await asyncio.sleep(5.0) async def watch(cfg, on_event: Callable[[Event], Awaitable[None]]) -> None: source = _follow_local(cfg.log_path) if cfg.log_mode == "local" else _follow_sftp(cfg) async for line in source: evt = parse_line(line) if evt is not None: try: await on_event(evt) except Exception: log.exception("Błąd obsługi zdarzenia")