mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 13:34:40 +00:00
cd28c6d4db
log_handler belongs to Client.run() (which configures logging and then calls start()); passing it to start() raised TypeError at launch. This was inherited from the never-run dockerised variant's thin_client.py. We configure our own handlers, so simply drop the kwarg. Also set logger.propagate=False: a dependency calls logging.basicConfig(), so every 'discord' record was printed twice (our format + root's). Verified with a strict-signature stub client (start(token, *, reconnect)). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
11 KiB
Python
Executable File
327 lines
11 KiB
Python
Executable File
# 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()))
|