mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
207 lines
7.3 KiB
Python
207 lines
7.3 KiB
Python
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<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)),
|
|
]
|
|
|
|
@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
|
|
|