Conanjurer generation

This commit is contained in:
2026-06-04 19:13:50 +02:00
parent 1f271b4c71
commit f6ccdb3e34
13 changed files with 576 additions and 0 deletions
+73
View File
@@ -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 <tekst>` w grze → wiadomość na Discord.
**Discord → Conan**
- `!say <tekst>``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ś <NPC> <tekst>` → 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 <tekst>` → 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`).
```
```
@@ -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
+73
View File
@@ -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 <tekst>` w grze → wiadomość na Discord.
**Discord → Conan**
- `!say <tekst>``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ś <NPC> <tekst>` → 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 <tekst>` → 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`).
```
```
@@ -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))
@@ -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 <nadawca> <msg>').
"""
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))
@@ -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"),
)
@@ -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<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)),
]
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")
@@ -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
@@ -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()
@@ -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"