This commit is contained in:
Michal Tuszowski
2026-06-11 18:26:32 +02:00
parent b107f01208
commit d6b33cc614
13 changed files with 131 additions and 400 deletions
-73
View File
@@ -1,73 +0,0 @@
# 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`).
```
```
@@ -1,21 +0,0 @@
# ---- 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
@@ -1,73 +0,0 @@
# 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`).
```
```
@@ -1,44 +0,0 @@
"""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))
@@ -1,55 +0,0 @@
"""Ł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"),
)
@@ -1,55 +0,0 @@
"""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
@@ -1,49 +0,0 @@
"""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()
@@ -1,5 +0,0 @@
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"
@@ -1,18 +1,14 @@
"""Cog: komendy Discord -> Conan (przez RCON). # ai command cogs
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ę bramkowane rolą GM (GM_ROLE_ID).
"""
from __future__ import annotations
import logging import logging
import discord import discord
from discord.ext import commands from discord.ext import commands
from other_functions import discord_friendly_send, discord_friendly_reply
from __future__ import annotations
import asyncio
import logging
log = logging.getLogger("to_conan") from conanjurer_functions import watch, Event
log = logging.getLogger("discord")
def is_gm(): def is_gm():
async def predicate(ctx: commands.Context) -> bool: async def predicate(ctx: commands.Context) -> bool:
@@ -24,6 +20,33 @@ def is_gm():
return commands.check(predicate) return commands.check(predicate)
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)
class ToConan(commands.Cog): class ToConan(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
@@ -74,3 +97,6 @@ class ToConan(commands.Cog):
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
await bot.add_cog(ToConan(bot)) await bot.add_cog(ToConan(bot))
await bot.add_cog(FromConan(bot))
@@ -1,23 +1,95 @@
"""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
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 from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
import asyncio import asyncio
import logging import logging
import re import re
from dataclasses import dataclass
from typing import AsyncIterator, Callable, Awaitable from typing import AsyncIterator, Callable, Awaitable
from aiomcrcon import Client as _Rcon # Source RCON over TCP
import asyncssh
log = logging.getLogger("logwatch") log = logging.getLogger("discord")
@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
@dataclass @dataclass
class Event: class Event:
@@ -83,7 +155,6 @@ async def _follow_local(path: str) -> AsyncIterator[str]:
async def _follow_sftp(cfg) -> AsyncIterator[str]: async def _follow_sftp(cfg) -> AsyncIterator[str]:
"""Polling przyrostowy po SFTP (Host Havoc). Czyta tylko nowe bajty.""" """Polling przyrostowy po SFTP (Host Havoc). Czyta tylko nowe bajty."""
import asyncssh
offset = 0 offset = 0
while True: while True:
try: try:
@@ -123,3 +194,12 @@ async def watch(cfg, on_event: Callable[[Event], Awaitable[None]]) -> None:
await on_event(evt) await on_event(evt)
except Exception: except Exception:
log.exception("Błąd obsługi zdarzenia") log.exception("Błąd obsługi zdarzenia")
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