mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 13:34:40 +00:00
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:
@@ -71,6 +71,7 @@ async def on_ready():
|
||||
await client.load_extension("voice_recognition_commands")
|
||||
await client.load_extension("file_search_commands")
|
||||
await client.load_extension("latex_commands")
|
||||
await client.load_extension("conanjurer_commands")
|
||||
logger.info("Sensors: online")
|
||||
|
||||
logger.info(client.cogs)
|
||||
|
||||
+162
-47
@@ -1,102 +1,217 @@
|
||||
# ai command cogs
|
||||
import logging
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from other_functions import discord_friendly_send, discord_friendly_reply
|
||||
from __future__ import annotations
|
||||
# This Python file uses the following encoding: utf-8
|
||||
"""Conan Exiles <-> Discord bridge (cog).
|
||||
|
||||
Follows the project convention (cog here, helpers in
|
||||
``conanjurer_functions.py``). The integration is dormant unless configured in
|
||||
:mod:`constants`: with no RCON host / channels the background watchers simply
|
||||
do not start and the GM commands report that RCON is unavailable, so loading
|
||||
this extension is always safe even on hosts without a Conan server.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from conanjurer_functions import watch, Event
|
||||
log = logging.getLogger("discord")
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from conanjurer_functions import ConanConfig, Event, RconClient, watch, watch_players
|
||||
from constants import (
|
||||
CONAN_CHAT_CHANNEL_ID,
|
||||
CONAN_EVENTS_CHANNEL_ID,
|
||||
CONAN_GM_ROLE_ID,
|
||||
CONAN_JOIN_CHANNEL_ID,
|
||||
CONAN_LOG_MODE,
|
||||
CONAN_LOG_PATH,
|
||||
CONAN_PLAYER_POLL_SECONDS,
|
||||
CONAN_RCON_HOST,
|
||||
CONAN_RCON_PASSWORD,
|
||||
CONAN_RCON_PORT,
|
||||
CONAN_SFTP_HOST,
|
||||
CONAN_SFTP_PASSWORD,
|
||||
CONAN_SFTP_PORT,
|
||||
CONAN_SFTP_USER,
|
||||
)
|
||||
|
||||
|
||||
def is_gm():
|
||||
"""Allow only members holding the configured Conan GM role."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
role_id = ctx.bot.cfg.gm_role_id
|
||||
ok = any(r.id == role_id for r in getattr(ctx.author, "roles", []))
|
||||
if not CONAN_GM_ROLE_ID:
|
||||
await ctx.reply(
|
||||
"⛔ Rola GM Conana nie jest skonfigurowana.", mention_author=False
|
||||
)
|
||||
return False
|
||||
ok = any(r.id == CONAN_GM_ROLE_ID for r in getattr(ctx.author, "roles", []))
|
||||
if not ok:
|
||||
await ctx.reply("⛔ Tylko GM.", mention_author=False)
|
||||
return ok
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
class FromConan(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
class ConanModule(commands.Cog):
|
||||
"""Bridges a Conan Exiles server with Discord over RCON + log following."""
|
||||
|
||||
def __init__(self, bot, logger_name):
|
||||
self.bot = bot
|
||||
self._task: asyncio.Task | None = None
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
self.cfg = ConanConfig(
|
||||
rcon_host=CONAN_RCON_HOST,
|
||||
rcon_port=CONAN_RCON_PORT,
|
||||
rcon_password=CONAN_RCON_PASSWORD,
|
||||
log_mode=CONAN_LOG_MODE,
|
||||
log_path=CONAN_LOG_PATH,
|
||||
sftp_host=CONAN_SFTP_HOST,
|
||||
sftp_port=CONAN_SFTP_PORT,
|
||||
sftp_user=CONAN_SFTP_USER,
|
||||
sftp_password=CONAN_SFTP_PASSWORD,
|
||||
)
|
||||
self.rcon = (
|
||||
RconClient(CONAN_RCON_HOST, CONAN_RCON_PORT, CONAN_RCON_PASSWORD)
|
||||
if self.cfg.rcon_enabled
|
||||
else None
|
||||
)
|
||||
self._log_task = None
|
||||
self._player_task = None
|
||||
|
||||
async def cog_load(self):
|
||||
self._task = asyncio.create_task(self._run())
|
||||
# Conan -> Discord chat/event mirroring (only with a log source + target)
|
||||
if self.cfg.log_enabled and (CONAN_CHAT_CHANNEL_ID or CONAN_EVENTS_CHANNEL_ID):
|
||||
self._log_task = asyncio.create_task(self._run_log_watch())
|
||||
else:
|
||||
self.logger.info("Conan: log watch disabled (not configured)")
|
||||
|
||||
# Player-join notifications — disabled when the channel is not defined
|
||||
if CONAN_JOIN_CHANNEL_ID and self.rcon is not None:
|
||||
self._player_task = asyncio.create_task(self._run_player_watch())
|
||||
else:
|
||||
self.logger.info(
|
||||
"Conan: player-join notifications disabled (no channel or RCON)"
|
||||
)
|
||||
|
||||
async def cog_unload(self):
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
for task in (self._log_task, self._player_task):
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
if self.rcon is not None:
|
||||
await self.rcon.close()
|
||||
|
||||
async def _run(self):
|
||||
# ---------------------------------------------------------------- watchers
|
||||
async def _run_log_watch(self):
|
||||
await self.bot.wait_until_ready()
|
||||
cfg = self.bot.cfg
|
||||
chat_ch = self.bot.get_channel(cfg.chan_chat)
|
||||
evt_ch = self.bot.get_channel(cfg.chan_events)
|
||||
chat_ch = (
|
||||
self.bot.get_channel(CONAN_CHAT_CHANNEL_ID) if CONAN_CHAT_CHANNEL_ID else None
|
||||
)
|
||||
evt_ch = (
|
||||
self.bot.get_channel(CONAN_EVENTS_CHANNEL_ID)
|
||||
if CONAN_EVENTS_CHANNEL_ID
|
||||
else None
|
||||
)
|
||||
|
||||
async def on_event(e: Event):
|
||||
target = chat_ch if e.kind == "chat" else evt_ch
|
||||
async def on_event(event: Event):
|
||||
target = chat_ch if event.kind == "chat" else evt_ch
|
||||
if target is not None:
|
||||
# allowed_mentions: nie pinguj nikogo treścią z gry
|
||||
await target.send(e.text, allowed_mentions=discord.AllowedMentions.none())
|
||||
await target.send(
|
||||
event.text, allowed_mentions=discord.AllowedMentions.none()
|
||||
)
|
||||
|
||||
log.info("Start obserwacji logu (tryb=%s)", cfg.log_mode)
|
||||
await watch(cfg, on_event)
|
||||
self.logger.info("Conan: starting log watch (mode=%s)", self.cfg.log_mode)
|
||||
await watch(self.cfg, on_event)
|
||||
|
||||
class ToConan(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
async def _run_player_watch(self):
|
||||
"""Task 2: announce on a defined channel when a player joins the server."""
|
||||
await self.bot.wait_until_ready()
|
||||
channel = self.bot.get_channel(CONAN_JOIN_CHANNEL_ID)
|
||||
if channel is None:
|
||||
self.logger.warning(
|
||||
"Conan: join channel %s not found — player notifications off",
|
||||
CONAN_JOIN_CHANNEL_ID,
|
||||
)
|
||||
return
|
||||
|
||||
async def on_join(name: str):
|
||||
await channel.send(
|
||||
f"🟢 **{name}** wszedł na serwer Conan",
|
||||
allowed_mentions=discord.AllowedMentions.none(),
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
"Conan: starting player-join watch (channel=%s, every %ss)",
|
||||
CONAN_JOIN_CHANNEL_ID,
|
||||
CONAN_PLAYER_POLL_SECONDS,
|
||||
)
|
||||
await watch_players(self.rcon, CONAN_PLAYER_POLL_SECONDS, on_join)
|
||||
|
||||
# ----------------------------------------------------- Discord -> Conan
|
||||
async def _require_rcon(self, ctx: commands.Context):
|
||||
if self.rcon is None:
|
||||
await ctx.reply(
|
||||
"⛔ RCON Conana nie jest skonfigurowany/dostępny.",
|
||||
mention_author=False,
|
||||
)
|
||||
return None
|
||||
return self.rcon
|
||||
|
||||
@commands.command(name="say")
|
||||
@is_gm()
|
||||
async def say(self, ctx: commands.Context, *, message: str):
|
||||
"""Discord -> Conan: ogłoszenie widoczne dla wszystkich graczy w grze."""
|
||||
resp = await self.bot.rcon.command(f"broadcast {message}")
|
||||
await ctx.reply(f"✅ Wysłano do gry. (serwer: `{resp.strip() or 'OK'}`)",
|
||||
mention_author=False)
|
||||
rcon = await self._require_rcon(ctx)
|
||||
if rcon is None:
|
||||
return
|
||||
resp = await rcon.command(f"broadcast {message}")
|
||||
await ctx.reply(
|
||||
f"✅ Wysłano do gry. (serwer: `{resp.strip() or 'OK'}`)",
|
||||
mention_author=False,
|
||||
)
|
||||
|
||||
@commands.command(name="players")
|
||||
@is_gm()
|
||||
async def players(self, ctx: commands.Context):
|
||||
"""Lista graczy online (RCON listplayers)."""
|
||||
resp = await self.bot.rcon.command("listplayers")
|
||||
await ctx.reply(f"```\n{resp.strip() or 'brak danych'}\n```",
|
||||
mention_author=False)
|
||||
rcon = await self._require_rcon(ctx)
|
||||
if rcon is None:
|
||||
return
|
||||
resp = await rcon.command("listplayers")
|
||||
await ctx.reply(
|
||||
f"```\n{resp.strip() or 'brak danych'}\n```", mention_author=False
|
||||
)
|
||||
|
||||
@commands.command(name="kick")
|
||||
@is_gm()
|
||||
async def kick(self, ctx: commands.Context, *, who: str):
|
||||
"""Wyrzuć gracza (po nazwie/charname — zależnie od wersji serwera)."""
|
||||
resp = await self.bot.rcon.command(f"kick {who}")
|
||||
rcon = await self._require_rcon(ctx)
|
||||
if rcon is None:
|
||||
return
|
||||
resp = await rcon.command(f"kick {who}")
|
||||
await ctx.reply(f"👢 `{resp.strip() or 'OK'}`", mention_author=False)
|
||||
|
||||
@commands.command(name="rcon")
|
||||
@is_gm()
|
||||
async def raw_rcon(self, ctx: commands.Context, *, cmd: str):
|
||||
"""Surowa komenda RCON (dla zaawansowanych GM). Używaj ostrożnie."""
|
||||
resp = await self.bot.rcon.command(cmd)
|
||||
rcon = await self._require_rcon(ctx)
|
||||
if rcon is None:
|
||||
return
|
||||
resp = await rcon.command(cmd)
|
||||
await ctx.reply(f"```\n{resp.strip() or 'OK'}\n```", mention_author=False)
|
||||
|
||||
# --- HOOK pod przyszły mod mostu (Chat V2 API) ---
|
||||
@commands.command(name="ogłoś", aliases=["oglos", "rp"])
|
||||
@is_gm()
|
||||
async def rp_announce(self, ctx: commands.Context, nadawca: str, *, message: str):
|
||||
"""Wiadomość RP 'z nadawcą' (np. ogłoszenie w imieniu Króla Khasara).
|
||||
|
||||
Na samym RCON realizujemy to jako sformatowany broadcast. Gdy dodasz
|
||||
własny mod na Chat V2 API z dedykowaną komendą czatu, podmień tę linię
|
||||
na wywołanie tej komendy (np. 'tot_rp_say <nadawca> <msg>').
|
||||
Na samym RCON realizujemy to jako sformatowany broadcast.
|
||||
"""
|
||||
resp = await self.bot.rcon.command(f"broadcast [{nadawca}]: {message}")
|
||||
rcon = await self._require_rcon(ctx)
|
||||
if rcon is None:
|
||||
return
|
||||
resp = await rcon.command(f"broadcast [{nadawca}]: {message}")
|
||||
await ctx.reply(f"📜 Ogłoszono jako **{nadawca}**.", mention_author=False)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(ToConan(bot))
|
||||
await bot.add_cog(FromConan(bot))
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
logger = logging.getLogger("discord")
|
||||
await bot.add_cog(ConanModule(bot, "discord"))
|
||||
logger.info("Loading conanjurer commands module done")
|
||||
|
||||
+165
-105
@@ -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: %s — ponawiam", e)
|
||||
except Exception as exc:
|
||||
logger.warning("SFTP disconnected: %s — retrying", 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")
|
||||
|
||||
@@ -297,3 +297,26 @@ ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
|
||||
GUILD_ID = 664789470779932693
|
||||
LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu)
|
||||
CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej)
|
||||
|
||||
|
||||
# *=========================================== Conan Exiles bridge
|
||||
# Every value is optional; the conanjurer cog stays dormant unless configured.
|
||||
# Channel/role ids default to 0 ("not defined"); RCON host/log path default to
|
||||
# "" ("disabled"). See conanjurer_commands.py / conanjurer_functions.py.
|
||||
CONAN_GM_ROLE_ID = int(os.getenv("CONAN_GM_ROLE_ID", "0"))
|
||||
CONAN_CHAT_CHANNEL_ID = int(os.getenv("CONAN_CHAT_CHANNEL_ID", "0"))
|
||||
CONAN_EVENTS_CHANNEL_ID = int(os.getenv("CONAN_EVENTS_CHANNEL_ID", "0"))
|
||||
# Player-join notifications: leave at 0 to keep the feature disabled.
|
||||
CONAN_JOIN_CHANNEL_ID = int(os.getenv("CONAN_JOIN_CHANNEL_ID", "0"))
|
||||
CONAN_PLAYER_POLL_SECONDS = int(os.getenv("CONAN_PLAYER_POLL_SECONDS", "60"))
|
||||
|
||||
CONAN_RCON_HOST = os.getenv("CONAN_RCON_HOST", "")
|
||||
CONAN_RCON_PORT = int(os.getenv("CONAN_RCON_PORT", "25575"))
|
||||
CONAN_RCON_PASSWORD = os.getenv("CONAN_RCON_PASSWORD", "")
|
||||
|
||||
CONAN_LOG_MODE = os.getenv("CONAN_LOG_MODE", "local") # "local" | "sftp"
|
||||
CONAN_LOG_PATH = os.getenv("CONAN_LOG_PATH", "")
|
||||
CONAN_SFTP_HOST = os.getenv("CONAN_SFTP_HOST", "")
|
||||
CONAN_SFTP_PORT = int(os.getenv("CONAN_SFTP_PORT", "22"))
|
||||
CONAN_SFTP_USER = os.getenv("CONAN_SFTP_USER", "")
|
||||
CONAN_SFTP_PASSWORD = os.getenv("CONAN_SFTP_PASSWORD", "")
|
||||
|
||||
@@ -17,4 +17,6 @@ PyMuPDF
|
||||
waitress
|
||||
assemblyai[extras]
|
||||
SpeechRecognition
|
||||
aiomcrcon
|
||||
asyncssh
|
||||
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
|
||||
|
||||
Reference in New Issue
Block a user