mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-15 14:22:13 +00:00
nowe
This commit is contained in:
@@ -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"
|
|
||||||
+37
-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ę są 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))
|
||||||
|
|
||||||
|
|
||||||
+94
-14
@@ -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
|
|
||||||
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
|
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
|
||||||
|
|
||||||
Reference in New Issue
Block a user