Files
conjurer/conanjurer/conan-discord-bridge/bot/core/logwatch.py
T
2026-06-04 19:13:50 +02:00

126 lines
5.0 KiB
Python

"""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<who>.+?):\s*(?P<msg>.+)$", re.I)),
("login", re.compile(r"(?P<who>.+?)\s+(joined|connected|logged in)", re.I)),
("logout", re.compile(r"(?P<who>.+?)\s+(left|disconnected|logged out)", re.I)),
("death", re.compile(r"(?P<who>.+?)\s+was killed by\s+(?P<by>.+)$", 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")