e1fca864d8
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 10s
build / build (push) Successful in 38s
CI / compile (push) Successful in 10s
CI / unit (push) Successful in 17s
CI / integration (push) Successful in 11s
Fits the mythology pillar of the persona (Slavic/Norse/Celtic, Old Norse phrases). New always-loaded cog oracle_commands: * $runy [pytanie] draws three Elder Futhark runes (past/present/future, with upright/reversed orientation - the 8 symmetric runes are never reversed) and asks the ACTIVE AI backend to read the spread in Conjurer's voice. If the AI is down it still shows the drawn runes with their own meanings, so the command always answers. * $runa_dnia gives one rune, deterministic per user per day (sha256 seed), so it's stable if asked repeatedly - no AI call, no state file. The full 24-rune Futhark, the draw logic and the reversal rules are pure and unit-tested (distinct draw, non-invertible never reversed, per-day stability, meaning fallback). Also fixes a pre-existing unit-job breakage: test_bar_commands and test_lore_commands each stubbed `discord` with different completeness and shared sys.modules, so once both landed on main the one lacking `discord.ext.tasks` shadowed the one needing it and collection failed order-dependently. A new tests/unit/conftest.py stubs discord once, completely, before any test module - the per-file stubs then skip. Full unit job: 41 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
12 KiB
Python
Executable File
336 lines
12 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,
|
|
RADIO_SERVICE_ADDRESS,
|
|
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",
|
|
"bar_commands",
|
|
"lore_commands",
|
|
"oracle_commands",
|
|
"latex_commands",
|
|
"voice_recognition_commands",
|
|
"conanjurer_commands",
|
|
]
|
|
|
|
SERVICE_EXTENSION_GROUPS = {
|
|
# musician (file service): Discord music download/search, file shares
|
|
"musician": {
|
|
"health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
|
|
"extensions": ["music_commands", "file_search_commands"],
|
|
},
|
|
# betoniarka (radio operator colocated with Liquidsoap): radio playlists
|
|
"radio": {
|
|
"health_url": f"{RADIO_SERVICE_ADDRESS}/ping",
|
|
"extensions": ["radio_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()))
|