diff --git a/bot.py b/bot.py index 3614e39..a1105e1 100755 --- a/bot.py +++ b/bot.py @@ -71,6 +71,7 @@ async def on_ready(): await client.load_extension("voice_recognition_commands") await client.load_extension("file_search_commands") await client.load_extension("latex_commands") + await client.load_extension("conanjurer_commands") logger.info("Sensors: online") logger.info(client.cogs) diff --git a/conanjurer_commands.py b/conanjurer_commands.py index b33965a..926897c 100644 --- a/conanjurer_commands.py +++ b/conanjurer_commands.py @@ -1,102 +1,217 @@ -# ai command cogs -import logging -import discord -from discord.ext import commands -from other_functions import discord_friendly_send, discord_friendly_reply -from __future__ import annotations +# 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 -from conanjurer_functions import watch, Event -log = logging.getLogger("discord") +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: - role_id = ctx.bot.cfg.gm_role_id - ok = any(r.id == role_id for r in getattr(ctx.author, "roles", [])) + 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 FromConan(commands.Cog): - def __init__(self, bot: commands.Bot): +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._task: asyncio.Task | None = None + 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): - self._task = asyncio.create_task(self._run()) + # 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): - if self._task: - self._task.cancel() + 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() - async def _run(self): + # ---------------------------------------------------------------- watchers + async def _run_log_watch(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) + 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(e: Event): - target = chat_ch if e.kind == "chat" else evt_ch + async def on_event(event: Event): + target = chat_ch if event.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()) + await target.send( + event.text, allowed_mentions=discord.AllowedMentions.none() + ) - log.info("Start obserwacji logu (tryb=%s)", cfg.log_mode) - await watch(cfg, on_event) + self.logger.info("Conan: starting log watch (mode=%s)", self.cfg.log_mode) + await watch(self.cfg, on_event) -class ToConan(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot + 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.""" - 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) + 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).""" - resp = await self.bot.rcon.command("listplayers") - await ctx.reply(f"```\n{resp.strip() or 'brak danych'}\n```", - mention_author=False) + 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).""" - resp = await self.bot.rcon.command(f"kick {who}") + 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.""" - resp = await self.bot.rcon.command(cmd) + 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) - # --- 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 '). + Na samym RCON realizujemy to jako sformatowany broadcast. """ - resp = await self.bot.rcon.command(f"broadcast [{nadawca}]: {message}") + 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: commands.Bot): - await bot.add_cog(ToConan(bot)) - await bot.add_cog(FromConan(bot)) - - +async def setup(bot): + logger = logging.getLogger("discord") + await bot.add_cog(ConanModule(bot, "discord")) + logger.info("Loading conanjurer commands module done") diff --git a/conanjurer_functions.py b/conanjurer_functions.py index b94d18f..60c7c22 100644 --- a/conanjurer_functions.py +++ b/conanjurer_functions.py @@ -1,92 +1,103 @@ +# This Python file uses the following encoding: utf-8 +"""Helper logic for the Conan Exiles <-> Discord bridge. + +Mirrors the project layout: the cog lives in ``conanjurer_commands.py`` and the +reusable logic lives here. Configuration comes from :mod:`constants` (env-var +overridable); optional third-party dependencies (``aiomcrcon``/``asyncssh``) +are imported defensively so the main bot can still load the extension when the +Conan integration is not installed or not in use. +""" from __future__ import annotations -import os -from dataclasses import dataclass -from dotenv import load_dotenv + import asyncio import logging +import os import re -from typing import AsyncIterator, Callable, Awaitable -from aiomcrcon import Client as _Rcon # Source RCON over TCP -import asyncssh +from dataclasses import dataclass +from typing import AsyncIterator, Awaitable, Callable, Optional, Set + +try: + from aiomcrcon import Client as _Rcon # Source RCON over TCP +except ImportError: # pragma: no cover - optional component + _Rcon = None + +try: + import asyncssh +except ImportError: # pragma: no cover - optional component + asyncssh = None + +logger = logging.getLogger("discord") -load_dotenv() -log = logging.getLogger("discord") @dataclass class Event: - kind: str # "chat" | "login" | "logout" | "death" | "raw" - text: str # gotowy do wyświetlenia tekst - raw: str # oryginalna linia (do debugowania) + kind: str # "chat" | "login" | "logout" | "death" | "raw" + text: str # ready-to-display text + raw: str # original line (for debugging) -# --- 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)), -] +@dataclass +class ConanConfig: + """Runtime configuration for the bridge, built from :mod:`constants`.""" -@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_mode: str # "local" | "sftp" log_path: str - sftp_host: str | None + sftp_host: str sftp_port: int - sftp_user: str | None - sftp_password: str | None + sftp_user: str + sftp_password: str - @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"), - ) + @property + def rcon_enabled(self) -> bool: + """RCON usable only when host+password are set and the lib is present.""" + return bool(self.rcon_host and self.rcon_password and _Rcon is not None) + + @property + def log_enabled(self) -> bool: + """Log following usable only when its prerequisites are configured.""" + if not self.log_path: + return False + if self.log_mode == "sftp": + return bool(self.sftp_host and asyncssh is not None) + return True + + +# --- REGEXY DO DOSTROJENIA NA WŁASNYM LOGU --- +# Conan/Pippi/Tot logują różnie — dopasuj do swojego logu. Linie niepasujące są +# ignorowane (nie zgadujemy). +_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)), +] class RconClient: + """Thin async wrapper around a Source-RCON connection to the Conan server.""" + def __init__(self, host: str, port: int, password: str): self._host, self._port, self._pw = host, port, password - self._client: _Rcon | None = None + self._client: Optional["_Rcon"] = None self._lock = asyncio.Lock() - async def _ensure(self) -> _Rcon: + async def _ensure(self) -> "_Rcon": + if _Rcon is None: + raise RuntimeError("aiomcrcon not installed — RCON unavailable") 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) + client = _Rcon(self._host, self._port, self._pw) + await client.connect() + self._client = client + logger.info("RCON connected %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. + """Send a command to the Conan server and return its response. - To jest KANAŁ Discord -> Conan. Np. command("broadcast Witajcie!") - wyświetli komunikat wszystkim graczom w grze. + Discord -> Conan channel, e.g. ``command("broadcast Hi!")``. """ async with self._lock: for attempt in (1, 2): @@ -94,8 +105,8 @@ class RconClient: 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) + except Exception as exc: # disconnect / server restart + logger.warning("RCON error (attempt %s): %s", attempt, exc) await self.close() if attempt == 2: raise @@ -106,20 +117,19 @@ class RconClient: if self._client is not None: try: await self._client.close() - except Exception: + except Exception: # pragma: no cover - best effort pass self._client = None - -def parse_line(line: str) -> Event | None: +def parse_line(line: str) -> Optional[Event]: line = line.rstrip("\n") if not line.strip(): return None for kind, pat in _PATTERNS: - m = pat.search(line) - if m: - g = m.groupdict() + match = pat.search(line) + if match: + g = match.groupdict() if kind == "chat": return Event(kind, f"💬 **{g['who']}**: {g['msg']}", line) if kind == "login": @@ -128,42 +138,98 @@ def parse_line(line: str) -> Event | None: 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) + return None # nierozpoznane -> ignoruj + + +def parse_players(listplayers_output: str) -> Set[str]: + """Extract the set of connected player char-names from RCON ``listplayers``. + + Conan's table is roughly:: + + Idx | Char name | Player name | User ID | Platform ID | Platform Name + 0 | Conan | SomeUser | 12345 | 765... | Steam + + The char-name column (index 1) is used. The exact format varies between + server builds, so this is best-effort and intentionally tunable. + """ + players: Set[str] = set() + for raw in listplayers_output.splitlines(): + line = raw.strip() + if not line or "|" not in line: + continue + cols = [c.strip() for c in line.split("|")] + head = cols[0].lower() + # skip the header row and any separator rows (e.g. "---|---") + if head in ("idx", "") or set(cols[0]) <= set("-"): + continue + if len(cols) >= 2 and cols[1]: + players.add(cols[1]) + return players + + +async def watch_players( + rcon: RconClient, + interval: float, + on_join: Callable[[str], Awaitable[None]], +) -> None: + """Poll RCON ``listplayers`` and call *on_join* for each new player. + + The first poll seeds the known-player set without announcing, so restarting + the bot does not re-announce everyone already online. RCON failures are + logged and retried on the next tick rather than killing the task. + """ + known: Optional[Set[str]] = None + while True: + try: + response = await rcon.command("listplayers") + current = parse_players(response) + if known is None: + known = current + else: + for name in current - known: + try: + await on_join(name) + except Exception: # pragma: no cover - handler guard + logger.exception("Conan: on_join handler failed") + known = current + except Exception as exc: + logger.warning("Conan: player poll failed: %s", exc) + await asyncio.sleep(interval) async def _follow_local(path: str) -> AsyncIterator[str]: - """tail -f w czystym asyncio, podąża też po rotacji pliku.""" - import os + """``tail -f`` in pure asyncio, following log rotation.""" 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 + with open(path, "r", encoding="utf-8", errors="replace") as handle: + handle.seek(0, os.SEEK_END) + inode = os.fstat(handle.fileno()).st_ino while True: - line = f.readline() + line = handle.readline() if line: yield line continue await asyncio.sleep(0.5) - # wykryj rotację logu try: - if os.stat(path).st_ino != inode: + if os.stat(path).st_ino != inode: # rotation break except FileNotFoundError: break except FileNotFoundError: - log.warning("Log nie istnieje jeszcze: %s", path) + logger.warning("Conan log not present yet: %s", path) await asyncio.sleep(3.0) -async def _follow_sftp(cfg) -> AsyncIterator[str]: - """Polling przyrostowy po SFTP (Host Havoc). Czyta tylko nowe bajty.""" +async def _follow_sftp(cfg: ConanConfig) -> AsyncIterator[str]: + """Incremental SFTP polling (e.g. Host Havoc): reads only new bytes.""" offset = 0 while True: try: async with asyncssh.connect( - cfg.sftp_host, port=cfg.sftp_port, - username=cfg.sftp_user, password=cfg.sftp_password, + 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: @@ -171,36 +237,30 @@ async def _follow_sftp(cfg) -> AsyncIterator[str]: try: attrs = await sftp.stat(cfg.log_path) size = attrs.size or 0 - if size < offset: # rotacja + if size < offset: # rotation offset = 0 if size > offset: - async with sftp.open(cfg.log_path, "r") as rf: - await rf.seek(offset) - chunk = await rf.read() + async with sftp.open(cfg.log_path, "r") as remote: + await remote.seek(offset) + chunk = await remote.read() offset = size - for ln in chunk.splitlines(): - yield ln + for line in chunk.splitlines(): + yield line except FileNotFoundError: - log.warning("SFTP: brak logu %s", cfg.log_path) + logger.warning("SFTP: missing log %s", cfg.log_path) await asyncio.sleep(2.0) - except Exception as e: - log.warning("SFTP rozłączony: %s — ponawiam", e) + except Exception as exc: + logger.warning("SFTP disconnected: %s — retrying", exc) 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 def watch(cfg: ConanConfig, on_event: Callable[[Event], Awaitable[None]]) -> None: + """Follow the Conan log and dispatch recognised lines to *on_event*.""" + source = _follow_local(cfg.log_path) if cfg.log_mode != "sftp" else _follow_sftp(cfg) async for line in source: - evt = parse_line(line) - if evt is not None: + event = parse_line(line) + if event is not None: try: - await on_event(evt) - except Exception: - log.exception("Błąd obsługi zdarzenia") - -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 - + await on_event(event) + except Exception: # pragma: no cover - handler guard + logger.exception("Conan: event handler failed") diff --git a/constants.py b/constants.py index aac36eb..4698d25 100644 --- a/constants.py +++ b/constants.py @@ -297,3 +297,26 @@ ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"] GUILD_ID = 664789470779932693 LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu) CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej) + + +# *=========================================== Conan Exiles bridge +# Every value is optional; the conanjurer cog stays dormant unless configured. +# Channel/role ids default to 0 ("not defined"); RCON host/log path default to +# "" ("disabled"). See conanjurer_commands.py / conanjurer_functions.py. +CONAN_GM_ROLE_ID = int(os.getenv("CONAN_GM_ROLE_ID", "0")) +CONAN_CHAT_CHANNEL_ID = int(os.getenv("CONAN_CHAT_CHANNEL_ID", "0")) +CONAN_EVENTS_CHANNEL_ID = int(os.getenv("CONAN_EVENTS_CHANNEL_ID", "0")) +# Player-join notifications: leave at 0 to keep the feature disabled. +CONAN_JOIN_CHANNEL_ID = int(os.getenv("CONAN_JOIN_CHANNEL_ID", "0")) +CONAN_PLAYER_POLL_SECONDS = int(os.getenv("CONAN_PLAYER_POLL_SECONDS", "60")) + +CONAN_RCON_HOST = os.getenv("CONAN_RCON_HOST", "") +CONAN_RCON_PORT = int(os.getenv("CONAN_RCON_PORT", "25575")) +CONAN_RCON_PASSWORD = os.getenv("CONAN_RCON_PASSWORD", "") + +CONAN_LOG_MODE = os.getenv("CONAN_LOG_MODE", "local") # "local" | "sftp" +CONAN_LOG_PATH = os.getenv("CONAN_LOG_PATH", "") +CONAN_SFTP_HOST = os.getenv("CONAN_SFTP_HOST", "") +CONAN_SFTP_PORT = int(os.getenv("CONAN_SFTP_PORT", "22")) +CONAN_SFTP_USER = os.getenv("CONAN_SFTP_USER", "") +CONAN_SFTP_PASSWORD = os.getenv("CONAN_SFTP_PASSWORD", "") diff --git a/requirements_bot.txt b/requirements_bot.txt index 2b68520..bcbbfac 100644 --- a/requirements_bot.txt +++ b/requirements_bot.txt @@ -17,4 +17,6 @@ PyMuPDF waitress assemblyai[extras] SpeechRecognition +aiomcrcon +asyncssh git+https://github.com/imayhaveborkedit/discord-ext-voice-recv