Files
conjurer/conanjurer_functions.py
T
Michal Tuszowski f9a1e7c03a Restructure: promote working_copy to repo root
Make the stable 'working copy' bot the canonical code at the repository
root so the install/deploy scripts operate against it again.

- Move working_copy/* to root (bot entrypoint is bot.py)
- Restore root-level install/ops scripts from c4fa88e (deploy.sh,
  install_main_bot.sh, status_report.*, conjurer.service, etc.)
- Fix deploy.sh: copy bot.py (was thin_client.py) and add the
  conanjurer_* modules; bump command count
- Remove side-by-side variant dirs (backup_old_docker, prototype_one,
  prototype_musician_one, musician_old, working_copy) and docker cruft
- Keep components as subdirs: conjurer_librarian, conjurer_musician,
  spotify_dl, yt_dlp, fonts, utils, docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:51:24 +02:00

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