from __future__ import annotations import os from dataclasses import dataclass from dotenv import load_dotenv import asyncio import logging import re from typing import AsyncIterator, Callable, Awaitable from aiomcrcon import Client as _Rcon # Source RCON over TCP import asyncssh load_dotenv() log = logging.getLogger("discord") @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)), ] @dataclass(frozen=True) class Config: # Discord guild_id: int chan_chat: int chan_events: int gm_role_id: int # RCON rcon_host: str rcon_port: int rcon_password: str # Log log_mode: str # "local" | "sftp" log_path: str sftp_host: str | None sftp_port: int sftp_user: str | None sftp_password: str | None @staticmethod def load() -> "Config": mode = os.getenv("LOG_MODE", "local").lower() return Config( guild_id=int(_req("DISCORD_GUILD_ID")), chan_chat=int(_req("CHAN_CHAT")), chan_events=int(_req("CHAN_EVENTS")), gm_role_id=int(_req("GM_ROLE_ID")), rcon_host=_req("RCON_HOST"), rcon_port=int(os.getenv("RCON_PORT", "25575")), rcon_password=_req("RCON_PASSWORD"), log_mode=mode, log_path=_req("LOG_PATH"), sftp_host=os.getenv("SFTP_HOST"), sftp_port=int(os.getenv("SFTP_PORT", "22")), sftp_user=os.getenv("SFTP_USER"), sftp_password=os.getenv("SFTP_PASSWORD"), ) class RconClient: def __init__(self, host: str, port: int, password: str): self._host, self._port, self._pw = host, port, password self._client: _Rcon | None = None self._lock = asyncio.Lock() async def _ensure(self) -> _Rcon: if self._client is None: c = _Rcon(self._host, self._port, self._pw) await c.connect() self._client = c log.info("RCON połączony %s:%s", self._host, self._port) return self._client async def command(self, cmd: str) -> str: """Wyślij komendę do serwera Conana. Zwraca odpowiedź serwera. To jest KANAŁ Discord -> Conan. Np. command("broadcast Witajcie!") wyświetli komunikat wszystkim graczom w grze. """ async with self._lock: for attempt in (1, 2): try: client = await self._ensure() resp, _ = await client.send_cmd(cmd) return resp except Exception as e: # rozłączenie/restart serwera log.warning("RCON błąd (próba %s): %s", attempt, e) await self.close() if attempt == 2: raise await asyncio.sleep(1.0) return "" async def close(self) -> None: if self._client is not None: try: await self._client.close() except Exception: pass self._client = None 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.""" 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") def _req(key: str) -> str: val = os.getenv(key) if not val or val.startswith("wklej") or val.startswith("000000"): raise RuntimeError(f"Brak/placeholder w .env: {key}") return val