# This Python file uses the following encoding: utf-8 # trunk-ignore-all(bandit/B311) # pylint: disable=line-too-long # pylint: disable=too-many-lines """ Module of a python bot named Conjurer - used to work on BDSM discord servers. Startup is defensive by design: * every cog is loaded independently - one broken/missing dependency disables that cog only, never the whole bot, * cogs that need a sibling service (musician / librarian) are only enabled after a positive health check; a watchdog keeps re-checking and enables them the moment the service comes alive, * fatal misconfiguration (missing Discord token) exits loudly on stderr with a clear message instead of dying silently into a log file. """ import asyncio import logging import os import sys # *=========================================== Standard Library Imports import random import threading from logging import handlers # *==============Imported libraries import discord import requests from discord.ext import commands from communication_subroutine import comm_subroutine from constants import ( ENCODING, FILE_SERVICE_ADDRESS, GET_MP3, LIBRARIAN_SERVICE_ADDRESS, LOGFILE, TOKEN, ) logger = logging.getLogger("discord") logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") # File log (rotated). constants ensures the directory exists; this is belt and # braces for exotic overrides. os.makedirs(os.path.dirname(LOGFILE) or ".", exist_ok=True) handler = handlers.RotatingFileHandler( filename=LOGFILE, encoding=ENCODING, mode="a", maxBytes=6 * 1024 * 1024, backupCount=6, ) handler.setFormatter(formatter) logger.addHandler(handler) # Console log so `docker logs` / journalctl actually show what happened. console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(formatter) logger.addHandler(console_handler) # Some dependency calls logging.basicConfig(), adding a root handler; without # this the 'discord' logger's records get printed twice (our format + root's). logger.propagate = False # *=========================================== Initializations intents = discord.Intents.default() intents.message_content = True intents.typing = True intents.presences = True intents.members = True intents.messages = True intents.voice_states = True intents.moderation = True # on_member_ban - wyswietl na glownym kanale pieczatke "Niech spierdala" # on_member_unban - "mam wyjebane" random.seed() client = commands.Bot(intents=intents, command_prefix="$") # *=========================================== Extension groups # Core cogs depend on nothing but the bot itself (broken ones are skipped # individually). Service groups are gated on a health check of the service # they talk to and enabled later by the watchdog when the service appears. CORE_EXTENSIONS = [ "administration_commands", "ai_commands", "other_commands", "latex_commands", "voice_recognition_commands", "conanjurer_commands", ] SERVICE_EXTENSION_GROUPS = { # musician (file service): music download/search, radio control, file shares "musician": { "health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}", "extensions": ["music_commands", "radio_commands", "file_search_commands"], }, # librarian: DOI / Crossref search "librarian": { "health_url": f"{LIBRARIAN_SERVICE_ADDRESS}/", "extensions": ["librarian_commands"], }, } SERVICE_RECHECK_SECONDS = 300 def _service_alive(url: str) -> bool: """True when the service answers HTTP at all (any status code counts).""" try: requests.get(url, timeout=3) return True except requests.exceptions.RequestException: return False async def _load_extension_safe(name: str) -> bool: """Load one extension; log and continue instead of killing startup.""" if name in client.extensions: return True try: await client.load_extension(name) logger.info("Extension loaded: %s", name) return True except Exception: # pylint: disable=broad-exception-caught logger.exception("Extension FAILED (disabled, bot continues): %s", name) return False async def _load_service_groups() -> bool: """Health-check each service group and load its cogs when alive. Returns True when anything new was loaded (caller may want to re-sync). """ loaded_any = False for service, group in SERVICE_EXTENSION_GROUPS.items(): missing = [e for e in group["extensions"] if e not in client.extensions] if not missing: continue alive = await asyncio.to_thread(_service_alive, group["health_url"]) if not alive: logger.warning( "Service '%s' unreachable (%s) - cogs stay disabled: %s", service, group["health_url"], ", ".join(missing), ) continue logger.info("Service '%s' is alive - enabling: %s", service, ", ".join(missing)) for extension in missing: if await _load_extension_safe(extension): loaded_any = True return loaded_any async def _sync_tree() -> None: try: await client.tree.sync() except Exception: # pylint: disable=broad-exception-caught logger.exception("Slash-command tree sync failed (commands may lag)") async def _service_watchdog() -> None: """Periodically retry offline services and enable their cogs when up.""" while not client.is_closed(): await asyncio.sleep(SERVICE_RECHECK_SECONDS) try: if await _load_service_groups(): await _sync_tree() except Exception: # pylint: disable=broad-exception-caught logger.exception("Service watchdog tick failed") _STARTUP_DONE = False # *=========================================== Define Events @client.event async def on_ready(): """Metoda wywoływana przy połączeniu do serwera.""" global _STARTUP_DONE # pylint: disable=global-statement logger.info("%s has connected to Discord!", client.user) if _STARTUP_DONE: logger.info("Reconnected - extensions already loaded") return _STARTUP_DONE = True logger.info("Reactor: online") for extension in CORE_EXTENSIONS: await _load_extension_safe(extension) await _load_service_groups() logger.info("Sensors: online") logger.info(client.cogs) await _sync_tree() for com in client.commands: logger.info("Command %s is awejleble", com.qualified_name) asyncio.create_task(_service_watchdog()) logger.info("Logged in as ----> %s", client.user) logger.info("ID:%s ", client.user.id) logger.info("All systems: operational") # *=========================================== Runtime orchestration # The legacy bootstrap used two bare threads (client.run + comm_subroutine) and # joined them, which made a clean shutdown impossible. We now drive everything # from a single asyncio loop: the Flask comm layer still runs in its own # threads (via asyncio.to_thread) but is steered through a shared stop_event so # the bot can stop both halves cooperatively. async def _run_comm_subroutine(stop_event: threading.Event) -> None: """Run the blocking comm subroutine in a worker thread. A crash here takes down the internal HTTP endpoints only - the Discord side keeps running, so log loudly and swallow. """ try: await asyncio.to_thread(comm_subroutine, stop_event) except Exception: # pylint: disable=broad-exception-caught logger.exception( "Comm layer crashed - internal HTTP endpoints are down " "(musician/librarian callbacks will not arrive); bot continues" ) async def _run_bot(token: str, shutdown_event: asyncio.Event) -> None: """Start the Discord client; log WHY it died before flagging shutdown. Exceptions were previously swallowed by ``gather(return_exceptions=True)`` which made a failed login look like a clean exit (silent crash-loop in docker). Now every death is diagnosed on the console first. """ try: # NOTE: log_handler is a Client.run()-only kwarg (run() configures # logging, then calls start()); start() takes just the token. We set # up our own handlers above, so nothing is lost. await client.start(token) except discord.LoginFailure: logger.critical( "FATAL: Discord REJECTED the token (Improper token). Fix the " "'discord' entry in the netrc mounted at CONJURER_NETRC_FILE or " "the DISCORD_TOKEN env var. If the token leaked/reset, generate a " "new one in the Discord Developer Portal -> Bot -> Reset Token." ) raise except discord.PrivilegedIntentsRequired: logger.critical( "FATAL: this bot application does not have the Privileged Gateway " "Intents enabled. Open Discord Developer Portal -> your app -> " "Bot -> enable 'Presence', 'Server Members' and 'Message Content' " "Intents, then restart." ) raise except Exception: # pylint: disable=broad-exception-caught logger.exception("FATAL: Discord client crashed at startup/runtime") raise finally: shutdown_event.set() async def main() -> int: """Run both halves; return a process exit code (0 = clean shutdown).""" if TOKEN: token_source = ( "env DISCORD_TOKEN" if os.getenv("DISCORD_TOKEN") else "netrc file" ) logger.info( "Discord token: loaded from %s (length %d)", token_source, len(TOKEN) ) logger.info("Starting discord bot") shutdown_event = asyncio.Event() comm_stop_event = threading.Event() comm_task = asyncio.create_task(_run_comm_subroutine(comm_stop_event)) bot_task = asyncio.create_task(_run_bot(TOKEN, shutdown_event)) exit_code = 0 try: await shutdown_event.wait() except (KeyboardInterrupt, asyncio.CancelledError): logger.info("Shutdown signal received") comm_stop_event.set() await client.close() finally: comm_stop_event.set() if not client.is_closed(): await client.close() results = await asyncio.gather(bot_task, comm_task, return_exceptions=True) for result in results: if isinstance(result, BaseException) and not isinstance( result, asyncio.CancelledError ): # Already logged with full traceback inside the task; repeat # the one-liner so it is the LAST thing in `docker logs`. logger.critical("Task died: %r", result) exit_code = 1 return exit_code # *================================== Run if __name__ == "__main__": if not TOKEN: # Loud, unmissable and in `docker logs`: this is THE most common cause # of a silent container crash-loop. MSG = ( "FATAL: Discord token missing.\n" "Provide it via the DISCORD_TOKEN environment variable or a netrc " "file (machine 'discord') at the path in CONJURER_NETRC_FILE.\n" "Docker: check that your secrets mount exists, e.g.\n" " -v /srv/conjurer/secrets/.netrc:/secrets/.netrc:ro\n" " CONJURER_NETRC_FILE=/secrets/.netrc" ) logger.critical(MSG) sys.exit(MSG) sys.exit(asyncio.run(main()))