diff --git a/conanjurer/README.md b/conanjurer/README.md new file mode 100644 index 0000000..d26f477 --- /dev/null +++ b/conanjurer/README.md @@ -0,0 +1,73 @@ +# Tovah-Zar — Most Discord ⇄ Conan Exiles + +Dwukierunkowy most między serwerem Conan Exiles (Enhanced) a serwerem Discord. +Działa w trybie podstawowym **bez moda** (natywny RCON + log serwera), a z małym modem +mostu (Chat V2 API) zyskuje komendy in-game i wiadomości RP z nadawcą. + +## Co potrafi + +**Conan → Discord** +- Relacja czatu z gry na żywo na kanał Discorda. +- Powiadomienia: logowania/wylogowania, zgony, eventy GM. +- (z modem) `/discord ` w grze → wiadomość na Discord. + +**Discord → Conan** +- `!say ` → `broadcast` w grze (widzą wszyscy) — DZIAŁA NA SAMYM RCON. +- `!players` → lista graczy online (RCON `listplayers`). +- `!kick`, `!ban` (tylko role GM). +- (z modem) `!ogłoś ` → wiadomość RP z nadawcą (np. „Król Khasar: ..."). + +## Architektura + +``` + ┌──────────────────────────────────────────┐ + │ BOT (Python, ten projekt) │ + Discord ◄───►│ discord.py ──► RCON client ──► Conan │ + │ ▲ (TCP, komendy) │ + │ │ │ + │ log watcher ◄── SFTP/lokalny log Conana ◄───┤ Conan pisze log + └──────────────────────────────────────────┘ +``` + +- **RCON** = kanał Discord→Conan (komendy z efektem w grze). +- **Log watcher** = kanał Conan→Discord (czat/zdarzenia z gry). +- Bot stoi na VPS lub Twoim NUC-u (na wynajętym Host Havoc nie ma dostępu do OS maszyny — łączy się zdalnie po RCON + SFTP). + +## Szybki start + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # uzupełnij dane +python -m bot.main +``` + +## Wymagania po stronie Conan/Host Havoc +- Włączony **RCON** (w panelu Host Havoc / `Engine.ini`): `RconEnabled=True`, `RconPassword=...`, `RconPort=...`. +- Dostęp do pliku logu serwera: lokalnie (self-host) lub przez **SFTP** (Host Havoc). + +## Struktura plików +``` +conan-discord-bridge/ +├── README.md +├── requirements.txt +├── .env.example +├── config.example.toml +└── bot/ + ├── main.py # punkt wejścia, ładuje cogs + ├── core/ + │ ├── config.py # ładowanie .env / toml + │ ├── rcon.py # klient Source RCON (async) + │ └── logwatch.py # tail logu lokalnie lub po SFTP + parser + └── cogs/ + ├── from_conan.py # Conan -> Discord (relacja czatu/eventów) + └── to_conan.py # Discord -> Conan (komendy RCON) +``` + +## Most z własnym modem (opcjonalnie, później) +Mały mod na **Tot ! Chat V2 API** definiuje: +- komendę `/discord ` → zapisuje znacznik do logu, który bot wykrywa, +- kanał RP, na który bot (przez RCON-owy hook) wysyła wiadomości „z nadawcą". +Bot jest na to gotowy — patrz `cogs/to_conan.py` (funkcja `rp_announce`). +``` +``` diff --git a/conanjurer/conan-discord-bridge/.env.example b/conanjurer/conan-discord-bridge/.env.example new file mode 100644 index 0000000..2491030 --- /dev/null +++ b/conanjurer/conan-discord-bridge/.env.example @@ -0,0 +1,21 @@ +# ---- Discord ---- +DISCORD_TOKEN=wklej_token_bota +DISCORD_GUILD_ID=000000000000000000 +CHAN_CHAT=000000000000000000 # kanał, na który leci czat z gry +CHAN_EVENTS=000000000000000000 # kanał na logowania/zgony/eventy +GM_ROLE_ID=000000000000000000 # rola uprawniona do komend Discord->Conan + +# ---- Conan RCON ---- +RCON_HOST=1.2.3.4 # IP serwera z panelu Host Havoc +RCON_PORT=25575 # RconPort +RCON_PASSWORD=twoje_haslo_rcon + +# ---- Log serwera Conan ---- +LOG_MODE=sftp # "local" (self-host) albo "sftp" (Host Havoc) +LOG_PATH=/ConanSandbox/Saved/Logs/ConanSandbox.log + +# Pola SFTP (wypełnij tylko gdy LOG_MODE=sftp) +SFTP_HOST=1.2.3.4 +SFTP_PORT=22 +SFTP_USER=ftp_user +SFTP_PASSWORD=ftp_haslo diff --git a/conanjurer/conan-discord-bridge/README.md b/conanjurer/conan-discord-bridge/README.md new file mode 100644 index 0000000..d26f477 --- /dev/null +++ b/conanjurer/conan-discord-bridge/README.md @@ -0,0 +1,73 @@ +# Tovah-Zar — Most Discord ⇄ Conan Exiles + +Dwukierunkowy most między serwerem Conan Exiles (Enhanced) a serwerem Discord. +Działa w trybie podstawowym **bez moda** (natywny RCON + log serwera), a z małym modem +mostu (Chat V2 API) zyskuje komendy in-game i wiadomości RP z nadawcą. + +## Co potrafi + +**Conan → Discord** +- Relacja czatu z gry na żywo na kanał Discorda. +- Powiadomienia: logowania/wylogowania, zgony, eventy GM. +- (z modem) `/discord ` w grze → wiadomość na Discord. + +**Discord → Conan** +- `!say ` → `broadcast` w grze (widzą wszyscy) — DZIAŁA NA SAMYM RCON. +- `!players` → lista graczy online (RCON `listplayers`). +- `!kick`, `!ban` (tylko role GM). +- (z modem) `!ogłoś ` → wiadomość RP z nadawcą (np. „Król Khasar: ..."). + +## Architektura + +``` + ┌──────────────────────────────────────────┐ + │ BOT (Python, ten projekt) │ + Discord ◄───►│ discord.py ──► RCON client ──► Conan │ + │ ▲ (TCP, komendy) │ + │ │ │ + │ log watcher ◄── SFTP/lokalny log Conana ◄───┤ Conan pisze log + └──────────────────────────────────────────┘ +``` + +- **RCON** = kanał Discord→Conan (komendy z efektem w grze). +- **Log watcher** = kanał Conan→Discord (czat/zdarzenia z gry). +- Bot stoi na VPS lub Twoim NUC-u (na wynajętym Host Havoc nie ma dostępu do OS maszyny — łączy się zdalnie po RCON + SFTP). + +## Szybki start + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # uzupełnij dane +python -m bot.main +``` + +## Wymagania po stronie Conan/Host Havoc +- Włączony **RCON** (w panelu Host Havoc / `Engine.ini`): `RconEnabled=True`, `RconPassword=...`, `RconPort=...`. +- Dostęp do pliku logu serwera: lokalnie (self-host) lub przez **SFTP** (Host Havoc). + +## Struktura plików +``` +conan-discord-bridge/ +├── README.md +├── requirements.txt +├── .env.example +├── config.example.toml +└── bot/ + ├── main.py # punkt wejścia, ładuje cogs + ├── core/ + │ ├── config.py # ładowanie .env / toml + │ ├── rcon.py # klient Source RCON (async) + │ └── logwatch.py # tail logu lokalnie lub po SFTP + parser + └── cogs/ + ├── from_conan.py # Conan -> Discord (relacja czatu/eventów) + └── to_conan.py # Discord -> Conan (komendy RCON) +``` + +## Most z własnym modem (opcjonalnie, później) +Mały mod na **Tot ! Chat V2 API** definiuje: +- komendę `/discord ` → zapisuje znacznik do logu, który bot wykrywa, +- kanał RP, na który bot (przez RCON-owy hook) wysyła wiadomości „z nadawcą". +Bot jest na to gotowy — patrz `cogs/to_conan.py` (funkcja `rp_announce`). +``` +``` diff --git a/conanjurer/conan-discord-bridge/bot/__init__.py b/conanjurer/conan-discord-bridge/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/conanjurer/conan-discord-bridge/bot/cogs/__init__.py b/conanjurer/conan-discord-bridge/bot/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/conanjurer/conan-discord-bridge/bot/cogs/from_conan.py b/conanjurer/conan-discord-bridge/bot/cogs/from_conan.py new file mode 100644 index 0000000..21d2633 --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/cogs/from_conan.py @@ -0,0 +1,44 @@ +"""Cog: relacja Conan -> Discord. Uruchamia obserwator logu i wrzuca zdarzenia +na właściwe kanały.""" +from __future__ import annotations +import asyncio +import logging + +import discord +from discord.ext import commands + +from bot.core.logwatch import watch, Event + +log = logging.getLogger("from_conan") + + +class FromConan(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self._task: asyncio.Task | None = None + + async def cog_load(self): + self._task = asyncio.create_task(self._run()) + + async def cog_unload(self): + if self._task: + self._task.cancel() + + async def _run(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) + + async def on_event(e: Event): + target = chat_ch if e.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()) + + log.info("Start obserwacji logu (tryb=%s)", cfg.log_mode) + await watch(cfg, on_event) + + +async def setup(bot: commands.Bot): + await bot.add_cog(FromConan(bot)) diff --git a/conanjurer/conan-discord-bridge/bot/cogs/to_conan.py b/conanjurer/conan-discord-bridge/bot/cogs/to_conan.py new file mode 100644 index 0000000..a93b799 --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/cogs/to_conan.py @@ -0,0 +1,76 @@ +"""Cog: komendy Discord -> Conan (przez RCON). + +To jest dowód, że most jest DWUKIERUNKOWY: komenda napisana na Discordzie +wywołuje efekt w grze (np. !say -> broadcast widoczny dla wszystkich graczy). + +Komendy modyfikujące grę są bramkowane rolą GM (GM_ROLE_ID). +""" +from __future__ import annotations +import logging + +import discord +from discord.ext import commands + +log = logging.getLogger("to_conan") + + +def is_gm(): + 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 ok: + await ctx.reply("⛔ Tylko GM.", mention_author=False) + return ok + return commands.check(predicate) + + +class ToConan(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @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) + + @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) + + @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}") + 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) + 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 '). + """ + resp = await self.bot.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)) diff --git a/conanjurer/conan-discord-bridge/bot/core/__init__.py b/conanjurer/conan-discord-bridge/bot/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/conanjurer/conan-discord-bridge/bot/core/config.py b/conanjurer/conan-discord-bridge/bot/core/config.py new file mode 100644 index 0000000..027c6e1 --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/core/config.py @@ -0,0 +1,55 @@ +"""Ładowanie konfiguracji z .env (proste i wystarczające dla jednego serwera).""" +from __future__ import annotations +import os +from dataclasses import dataclass +from dotenv import load_dotenv + +load_dotenv() + + +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 + + +@dataclass(frozen=True) +class Config: + # Discord + token: str + 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( + token=_req("DISCORD_TOKEN"), + 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"), + ) diff --git a/conanjurer/conan-discord-bridge/bot/core/logwatch.py b/conanjurer/conan-discord-bridge/bot/core/logwatch.py new file mode 100644 index 0000000..c96f82a --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/core/logwatch.py @@ -0,0 +1,125 @@ +"""Obserwator logu serwera Conana — KANAŁ Conan -> Discord. + +Dwa tryby: + - local: plik logu jest na tej samej maszynie co bot (self-host) -> klasyczny tail. + - sftp : log pobierany zdalnie (Host Havoc) -> polling przyrostowy po SFTP. + +Parser jest CELOWO ostrożny: formaty linii logu Conana bywają różne między +wersjami i modami (np. Pippi/Tot dokładają własne wpisy). Dlatego dopasowania +są zachowawcze, a wszystko, czego nie rozpoznamy, jest ignorowane (nie zgadujemy). +WERYFIKUJ wzorce na realnym logu swojego serwera i dostrój REGEXY niżej. +""" +from __future__ import annotations +import asyncio +import logging +import re +from dataclasses import dataclass +from typing import AsyncIterator, Callable, Awaitable + +log = logging.getLogger("logwatch") + + +@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)), +] + + +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.""" + import asyncssh + 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") diff --git a/conanjurer/conan-discord-bridge/bot/core/rcon.py b/conanjurer/conan-discord-bridge/bot/core/rcon.py new file mode 100644 index 0000000..51e6cba --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/core/rcon.py @@ -0,0 +1,55 @@ +"""Cienka, odporna na rozłączenia otoczka na klienta Source RCON. + +Conan Exiles wystawia standardowy Source RCON (TCP). Używamy aiomcrcon, +który implementuje ten protokół. Reconnect na wypadek restartów serwera. +""" +from __future__ import annotations +import asyncio +import logging + +from aiomcrcon import Client as _Rcon # Source RCON over TCP + +log = logging.getLogger("rcon") + + +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 diff --git a/conanjurer/conan-discord-bridge/bot/main.py b/conanjurer/conan-discord-bridge/bot/main.py new file mode 100644 index 0000000..0420335 --- /dev/null +++ b/conanjurer/conan-discord-bridge/bot/main.py @@ -0,0 +1,49 @@ +"""Punkt wejścia bota mostu Discord <-> Conan Exiles.""" +from __future__ import annotations +import asyncio +import logging + +import discord +from discord.ext import commands + +from bot.core.config import Config +from bot.core.rcon import RconClient + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +log = logging.getLogger("main") + +COGS = ["bot.cogs.from_conan", "bot.cogs.to_conan"] + + +class BridgeBot(commands.Bot): + def __init__(self, cfg: Config): + intents = discord.Intents.default() + intents.message_content = True # potrzebne do komend prefiksowych + super().__init__(command_prefix="!", intents=intents) + self.cfg = cfg + self.rcon = RconClient(cfg.rcon_host, cfg.rcon_port, cfg.rcon_password) + + async def setup_hook(self): + for c in COGS: + await self.load_extension(c) + log.info("Załadowano cog: %s", c) + + async def close(self): + await self.rcon.close() + await super().close() + + async def on_ready(self): + log.info("Zalogowano jako %s (id=%s)", self.user, self.user.id) + + +def main(): + cfg = Config.load() + bot = BridgeBot(cfg) + bot.run(cfg.token, log_handler=None) + + +if __name__ == "__main__": + main() diff --git a/conanjurer/conan-discord-bridge/requirements.txt b/conanjurer/conan-discord-bridge/requirements.txt new file mode 100644 index 0000000..f3a7a9e --- /dev/null +++ b/conanjurer/conan-discord-bridge/requirements.txt @@ -0,0 +1,5 @@ +discord.py>=2.3,<3.0 +aiomcrcon>=0.2.0 # async Source RCON client +asyncssh>=2.14 # SFTP log pulling (Host Havoc / remote) +python-dotenv>=1.0 # .env loading +tomli>=2.0 ; python_version < "3.11"