Fix and integrate the conanjurer (Conan Exiles) bridge

Fixes the broken, never-loaded conanjurer module and wires it into the bot
following the project convention (cog in *_commands, logic in *_functions).

- conanjurer_functions.py: move 'from __future__' to the top (was a
  SyntaxError on line 6), drop duplicate import; config now comes from
  constants instead of dotenv/.env; guard optional deps (aiomcrcon,
  asyncssh) so the extension loads even when they are absent
- conanjurer_commands.py: single ConanModule(commands.Cog) with the
  standard (bot, logger_name)+setup() shape; owns its RconClient (no more
  undefined bot.cfg/bot.rcon); GM check uses CONAN_GM_ROLE_ID; commands
  report cleanly when RCON is unconfigured
- bot.py: load_extension('conanjurer_commands')
- constants.py: CONAN_* config (env-overridable); integration is dormant
  unless configured
- requirements_bot.txt: add aiomcrcon, asyncssh

Player-join notifications (task 2): watch_players() polls RCON
'listplayers', diffs against the previous set and announces new players on
CONAN_JOIN_CHANNEL_ID. Disabled when that channel is not defined (0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michal Tuszowski
2026-06-28 23:46:33 +02:00
parent f9bb0b2834
commit c03e2e985e
5 changed files with 353 additions and 152 deletions
+165 -105
View File
@@ -1,92 +1,103 @@
# This Python file uses the following encoding: utf-8
"""Helper logic for the Conan Exiles <-> Discord bridge.
Mirrors the project layout: the cog lives in ``conanjurer_commands.py`` and the
reusable logic lives here. Configuration comes from :mod:`constants` (env-var
overridable); optional third-party dependencies (``aiomcrcon``/``asyncssh``)
are imported defensively so the main bot can still load the extension when the
Conan integration is not installed or not in use.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
import asyncio
import logging
import os
import re
from typing import AsyncIterator, Callable, Awaitable
from aiomcrcon import Client as _Rcon # Source RCON over TCP
import asyncssh
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable, Optional, Set
try:
from aiomcrcon import Client as _Rcon # Source RCON over TCP
except ImportError: # pragma: no cover - optional component
_Rcon = None
try:
import asyncssh
except ImportError: # pragma: no cover - optional component
asyncssh = None
logger = logging.getLogger("discord")
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)
kind: str # "chat" | "login" | "logout" | "death" | "raw"
text: str # ready-to-display text
raw: str # original line (for debugging)
# --- 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
class ConanConfig:
"""Runtime configuration for the bridge, built from :mod:`constants`."""
@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_mode: str # "local" | "sftp"
log_path: str
sftp_host: str | None
sftp_host: str
sftp_port: int
sftp_user: str | None
sftp_password: str | None
sftp_user: str
sftp_password: str
@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"),
)
@property
def rcon_enabled(self) -> bool:
"""RCON usable only when host+password are set and the lib is present."""
return bool(self.rcon_host and self.rcon_password and _Rcon is not None)
@property
def log_enabled(self) -> bool:
"""Log following usable only when its prerequisites are configured."""
if not self.log_path:
return False
if self.log_mode == "sftp":
return bool(self.sftp_host and asyncssh is not None)
return True
# --- REGEXY DO DOSTROJENIA NA WŁASNYM LOGU ---
# Conan/Pippi/Tot logują różnie — dopasuj do swojego logu. Linie niepasujące są
# ignorowane (nie zgadujemy).
_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)),
]
class RconClient:
"""Thin async wrapper around a Source-RCON connection to the Conan server."""
def __init__(self, host: str, port: int, password: str):
self._host, self._port, self._pw = host, port, password
self._client: _Rcon | None = None
self._client: Optional["_Rcon"] = None
self._lock = asyncio.Lock()
async def _ensure(self) -> _Rcon:
async def _ensure(self) -> "_Rcon":
if _Rcon is None:
raise RuntimeError("aiomcrcon not installed — RCON unavailable")
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)
client = _Rcon(self._host, self._port, self._pw)
await client.connect()
self._client = client
logger.info("RCON connected %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.
"""Send a command to the Conan server and return its response.
To jest KANAŁ Discord -> Conan. Np. command("broadcast Witajcie!")
wyświetli komunikat wszystkim graczom w grze.
Discord -> Conan channel, e.g. ``command("broadcast Hi!")``.
"""
async with self._lock:
for attempt in (1, 2):
@@ -94,8 +105,8 @@ class RconClient:
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)
except Exception as exc: # disconnect / server restart
logger.warning("RCON error (attempt %s): %s", attempt, exc)
await self.close()
if attempt == 2:
raise
@@ -106,20 +117,19 @@ class RconClient:
if self._client is not None:
try:
await self._client.close()
except Exception:
except Exception: # pragma: no cover - best effort
pass
self._client = None
def parse_line(line: str) -> Event | None:
def parse_line(line: str) -> Optional[Event]:
line = line.rstrip("\n")
if not line.strip():
return None
for kind, pat in _PATTERNS:
m = pat.search(line)
if m:
g = m.groupdict()
match = pat.search(line)
if match:
g = match.groupdict()
if kind == "chat":
return Event(kind, f"💬 **{g['who']}**: {g['msg']}", line)
if kind == "login":
@@ -128,42 +138,98 @@ def parse_line(line: str) -> Event | None:
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)
return None # nierozpoznane -> ignoruj
def parse_players(listplayers_output: str) -> Set[str]:
"""Extract the set of connected player char-names from RCON ``listplayers``.
Conan's table is roughly::
Idx | Char name | Player name | User ID | Platform ID | Platform Name
0 | Conan | SomeUser | 12345 | 765... | Steam
The char-name column (index 1) is used. The exact format varies between
server builds, so this is best-effort and intentionally tunable.
"""
players: Set[str] = set()
for raw in listplayers_output.splitlines():
line = raw.strip()
if not line or "|" not in line:
continue
cols = [c.strip() for c in line.split("|")]
head = cols[0].lower()
# skip the header row and any separator rows (e.g. "---|---")
if head in ("idx", "") or set(cols[0]) <= set("-"):
continue
if len(cols) >= 2 and cols[1]:
players.add(cols[1])
return players
async def watch_players(
rcon: RconClient,
interval: float,
on_join: Callable[[str], Awaitable[None]],
) -> None:
"""Poll RCON ``listplayers`` and call *on_join* for each new player.
The first poll seeds the known-player set without announcing, so restarting
the bot does not re-announce everyone already online. RCON failures are
logged and retried on the next tick rather than killing the task.
"""
known: Optional[Set[str]] = None
while True:
try:
response = await rcon.command("listplayers")
current = parse_players(response)
if known is None:
known = current
else:
for name in current - known:
try:
await on_join(name)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: on_join handler failed")
known = current
except Exception as exc:
logger.warning("Conan: player poll failed: %s", exc)
await asyncio.sleep(interval)
async def _follow_local(path: str) -> AsyncIterator[str]:
"""tail -f w czystym asyncio, podąża też po rotacji pliku."""
import os
"""``tail -f`` in pure asyncio, following log rotation."""
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
with open(path, "r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
inode = os.fstat(handle.fileno()).st_ino
while True:
line = f.readline()
line = handle.readline()
if line:
yield line
continue
await asyncio.sleep(0.5)
# wykryj rotację logu
try:
if os.stat(path).st_ino != inode:
if os.stat(path).st_ino != inode: # rotation
break
except FileNotFoundError:
break
except FileNotFoundError:
log.warning("Log nie istnieje jeszcze: %s", path)
logger.warning("Conan log not present yet: %s", path)
await asyncio.sleep(3.0)
async def _follow_sftp(cfg) -> AsyncIterator[str]:
"""Polling przyrostowy po SFTP (Host Havoc). Czyta tylko nowe bajty."""
async def _follow_sftp(cfg: ConanConfig) -> AsyncIterator[str]:
"""Incremental SFTP polling (e.g. Host Havoc): reads only new bytes."""
offset = 0
while True:
try:
async with asyncssh.connect(
cfg.sftp_host, port=cfg.sftp_port,
username=cfg.sftp_user, password=cfg.sftp_password,
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:
@@ -171,36 +237,30 @@ async def _follow_sftp(cfg) -> AsyncIterator[str]:
try:
attrs = await sftp.stat(cfg.log_path)
size = attrs.size or 0
if size < offset: # rotacja
if size < offset: # rotation
offset = 0
if size > offset:
async with sftp.open(cfg.log_path, "r") as rf:
await rf.seek(offset)
chunk = await rf.read()
async with sftp.open(cfg.log_path, "r") as remote:
await remote.seek(offset)
chunk = await remote.read()
offset = size
for ln in chunk.splitlines():
yield ln
for line in chunk.splitlines():
yield line
except FileNotFoundError:
log.warning("SFTP: brak logu %s", cfg.log_path)
logger.warning("SFTP: missing log %s", cfg.log_path)
await asyncio.sleep(2.0)
except Exception as e:
log.warning("SFTP rozłączony: %sponawiam", e)
except Exception as exc:
logger.warning("SFTP disconnected: %sretrying", exc)
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 def watch(cfg: ConanConfig, on_event: Callable[[Event], Awaitable[None]]) -> None:
"""Follow the Conan log and dispatch recognised lines to *on_event*."""
source = _follow_local(cfg.log_path) if cfg.log_mode != "sftp" else _follow_sftp(cfg)
async for line in source:
evt = parse_line(line)
if evt is not None:
event = parse_line(line)
if event 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
await on_event(event)
except Exception: # pragma: no cover - handler guard
logger.exception("Conan: event handler failed")