mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
4b2cab0b63
PRs #10 and #11 were merged into their intermediate base branches (restructure/working-copy-root and proto-improvements) rather than main, because the stacked PRs' bases were not auto-retargeted (the branches were not deleted on merge). As a result main only received the #9 restructure and is still the plain working-copy bot. This brings the full prototype onto main as a clean delta on top of the current main tree (identical content to proto-improvements, but with main ancestry so it merges without the squash-induced rename/delete conflicts): - constants.py: env-var config, safe JSON loading, dependency guards, env->netrc tokens, API_SHARED_KEY + service_headers(), CONAN_* config - communication_subroutine.py: queue timeout/Empty, daemon threads, cooperative stop_event, inbound _authorize_request() - bot.py: asyncio event loop + load conanjurer_commands - music_functions / radio_commands / librarian_commands: X-Conjurer-Api-Key - conanjurer_commands/_functions: fixed + integrated bridge with RCON player-join notifications - requirements_bot.txt: aiomcrcon, asyncssh - conjurer_musician/.gitignore: keep runtime playlists/mp3 out of the repo Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
7.7 KiB
Python
218 lines
7.7 KiB
Python
# 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
|
|
|
|
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:
|
|
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 ConanModule(commands.Cog):
|
|
"""Bridges a Conan Exiles server with Discord over RCON + log following."""
|
|
|
|
def __init__(self, bot, logger_name):
|
|
self.bot = bot
|
|
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):
|
|
# 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):
|
|
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()
|
|
|
|
# ---------------------------------------------------------------- watchers
|
|
async def _run_log_watch(self):
|
|
await self.bot.wait_until_ready()
|
|
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(event: Event):
|
|
target = chat_ch if event.kind == "chat" else evt_ch
|
|
if target is not None:
|
|
await target.send(
|
|
event.text, allowed_mentions=discord.AllowedMentions.none()
|
|
)
|
|
|
|
self.logger.info("Conan: starting log watch (mode=%s)", self.cfg.log_mode)
|
|
await watch(self.cfg, on_event)
|
|
|
|
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."""
|
|
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)."""
|
|
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)."""
|
|
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."""
|
|
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)
|
|
|
|
@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.
|
|
"""
|
|
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):
|
|
logger = logging.getLogger("discord")
|
|
await bot.add_cog(ConanModule(bot, "discord"))
|
|
logger.info("Loading conanjurer commands module done")
|