mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-15 05:48:35 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1a6138ab | |||
| 2fd4077955 |
@@ -4,21 +4,9 @@
|
||||
# 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
|
||||
@@ -27,26 +15,12 @@ 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,
|
||||
)
|
||||
|
||||
from constants import ENCODING, 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)
|
||||
logger.setLevel(logging.INFO)
|
||||
handler = handlers.RotatingFileHandler(
|
||||
filename=LOGFILE,
|
||||
encoding=ENCODING,
|
||||
@@ -54,18 +28,10 @@ handler = handlers.RotatingFileHandler(
|
||||
maxBytes=6 * 1024 * 1024,
|
||||
backupCount=6,
|
||||
)
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
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
|
||||
@@ -82,129 +48,37 @@ intents.moderation = True
|
||||
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 = logging.getLogger("discord")
|
||||
logger.debug("SAMPLE DEBUG LOG")
|
||||
logger.info("%s has connected to Discord!", client.user)
|
||||
if _STARTUP_DONE:
|
||||
logger.info("Reconnected - extensions already loaded")
|
||||
return
|
||||
_STARTUP_DONE = True
|
||||
# TODO: load vs reload
|
||||
logger.info("Reactor: online")
|
||||
|
||||
for extension in CORE_EXTENSIONS:
|
||||
await _load_extension_safe(extension)
|
||||
await client.load_extension("administration_commands")
|
||||
|
||||
await _load_service_groups()
|
||||
await client.load_extension("librarian_commands")
|
||||
await client.load_extension("music_commands")
|
||||
await client.load_extension("radio_commands")
|
||||
|
||||
await client.load_extension("ai_commands")
|
||||
|
||||
await client.load_extension("other_commands")
|
||||
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)
|
||||
await _sync_tree()
|
||||
await client.tree.sync()
|
||||
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")
|
||||
@@ -219,64 +93,22 @@ async def on_ready():
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
"""Run the blocking comm subroutine in a worker thread."""
|
||||
await asyncio.to_thread(comm_subroutine, stop_event)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Start the Discord client and flag shutdown when it returns."""
|
||||
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
|
||||
await client.start(token, log_handler=None)
|
||||
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)
|
||||
)
|
||||
async def main() -> None:
|
||||
if not TOKEN:
|
||||
logger.error("Discord token missing - set DISCORD_TOKEN or configure netrc")
|
||||
return
|
||||
|
||||
logger.info("Starting discord bot")
|
||||
shutdown_event = asyncio.Event()
|
||||
@@ -285,7 +117,6 @@ async def main() -> int:
|
||||
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):
|
||||
@@ -296,31 +127,9 @@ async def main() -> int:
|
||||
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
|
||||
await asyncio.gather(bot_task, comm_task, return_exceptions=True)
|
||||
|
||||
|
||||
# *================================== 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()))
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -11,9 +11,9 @@ from urllib import request as urequest
|
||||
from flask import Flask, abort, jsonify, request
|
||||
from waitress import serve
|
||||
|
||||
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.92")
|
||||
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.31")
|
||||
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
|
||||
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.12:8000")
|
||||
ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.15:8000")
|
||||
API_KEY = os.getenv("CONJURER_API_KEY")
|
||||
OUT_COMM_Q = Queue()
|
||||
IN_COMM_Q = Queue()
|
||||
@@ -234,13 +234,10 @@ def comm_subroutine(stop_event: Optional[threading.Event] = None):
|
||||
:param stop_event: optional :class:`threading.Event` shared with the caller
|
||||
to coordinate a cooperative shutdown.
|
||||
"""
|
||||
# logger.setLevel(logging.DEBUG)
|
||||
logger = logging.getLogger("discord")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.info("Started comms")
|
||||
threads = []
|
||||
# NOTE: flask_debug is the dev server bound to the SAME host:port as
|
||||
# waitress - running both kills the comm layer with 'address in use'.
|
||||
# Enable it only INSTEAD of waitress_run, never alongside.
|
||||
# threads.append(threading.Thread(target=flask_debug))
|
||||
threads.append(threading.Thread(target=waitress_run, daemon=True))
|
||||
threads.append(
|
||||
|
||||
+11
-81
@@ -107,18 +107,18 @@ if platform in ("linux", "linux2"):
|
||||
DIR_PATH_SADOX = "/mnt/c/Users/mtusz/OneDrive/Dokumenty/Fansadox"
|
||||
|
||||
else:
|
||||
LOGFILE = "./discord.log"
|
||||
MEMORY_FIVE_SIARA = "./pamiec.json"
|
||||
SYSTEM_GPT_SETTINGS = "./system_gpt_settings.json"
|
||||
MEMORY_FIVE_MUZYKA = "./pamiec_muzyki.json"
|
||||
MUSIC_FOLDER = "./"
|
||||
SETTINGS_FILE = "./settings.json"
|
||||
NETRC_FILE = "/srv/conjurer/secrets/.netrc"
|
||||
LOGSTORE = "./logs/"
|
||||
ACCIDENT_LOG = "./accident_log.json"
|
||||
LOGFILE = "/home/pi/Conjurer/discord.log"
|
||||
MEMORY_FIVE_SIARA = "/home/pi/Conjurer/pamiec.json"
|
||||
SYSTEM_GPT_SETTINGS = "/home/pi/Conjurer/system_gpt_settings.json"
|
||||
MEMORY_FIVE_MUZYKA = "/home/pi/Conjurer/pamiec_muzyki.json"
|
||||
MUSIC_FOLDER = "/home/pi/MediaShare/mp3/"
|
||||
SETTINGS_FILE = "/home/pi/Conjurer/settings.json"
|
||||
NETRC_FILE = "/home/pi/.netrc"
|
||||
LOGSTORE = "/home/pi/MediaShara/logs/"
|
||||
ACCIDENT_LOG = "/home/pi/Conjurer/accident_log.json"
|
||||
ENCODING = "utf-8"
|
||||
GRAPHICS_PATH = "./Conjurer_graphics/"
|
||||
DIR_PATH_SADOX = "./Fansadox/"
|
||||
GRAPHICS_PATH = "/home/pi/MediaShare/Conjurer_graphics/"
|
||||
DIR_PATH_SADOX = "/home/pi/MediaShare/Fansadox/"
|
||||
|
||||
|
||||
elif platform == "win32":
|
||||
@@ -189,13 +189,6 @@ DIR_PATH_SADOX = os.getenv("CONJURER_SADOX_DIR", DIR_PATH_SADOX)
|
||||
ENCODING = os.getenv("CONJURER_ENCODING", ENCODING)
|
||||
SEPARATOR_FILE_PATH = os.getenv("CONJURER_PATH_SEPARATOR", SEPARATOR_FILE_PATH)
|
||||
|
||||
# Voice-recognition transcript dumps. Defaults next to the log file (so on the
|
||||
# Pi it lands in /home/pi/Conjurer/transcripts/, in docker under /data).
|
||||
TRANSCRIPTS_PATH = os.getenv(
|
||||
"CONJURER_TRANSCRIPTS_PATH",
|
||||
os.path.join(os.path.dirname(LOGFILE) or ".", "transcripts") + os.sep,
|
||||
)
|
||||
|
||||
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
||||
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
||||
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
|
||||
@@ -209,66 +202,6 @@ PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
|
||||
API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
|
||||
|
||||
|
||||
# *=========================================== Self-healing runtime layout
|
||||
# A fresh host/volume must never kill the bot at import time. Missing
|
||||
# directories are created and missing state files are seeded - first from the
|
||||
# templates shipped alongside this file (repo checkout / docker image), then
|
||||
# from a safe empty structure. Existing files are never touched, so preserved
|
||||
# history always wins.
|
||||
|
||||
_TEMPLATE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _ensure_dir(path: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Cannot create directory %s: %s", path, exc)
|
||||
|
||||
|
||||
def _seed_file(path: str, template_name: str, empty_content: str) -> None:
|
||||
"""Create *path* from the repo template (or *empty_content*) if missing."""
|
||||
if not path or os.path.exists(path):
|
||||
return
|
||||
_ensure_dir(os.path.dirname(path) or ".")
|
||||
template = os.path.join(_TEMPLATE_DIR, template_name)
|
||||
try:
|
||||
if os.path.exists(template) and os.path.abspath(template) != os.path.abspath(path):
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(template, path)
|
||||
logger.warning("Seeded missing %s from repo template", path)
|
||||
else:
|
||||
with open(path, "w", encoding=ENCODING) as handle:
|
||||
handle.write(empty_content)
|
||||
logger.warning("Created missing %s as empty state", path)
|
||||
except OSError as exc:
|
||||
logger.warning("Cannot seed %s: %s", path, exc)
|
||||
|
||||
|
||||
def _ensure_runtime_layout() -> None:
|
||||
for directory in (
|
||||
os.path.dirname(LOGFILE) or ".",
|
||||
LOGSTORE,
|
||||
GRAPHICS_PATH,
|
||||
MUSIC_FOLDER,
|
||||
TRANSCRIPTS_PATH,
|
||||
):
|
||||
_ensure_dir(directory)
|
||||
|
||||
# (target path, template shipped next to this file, empty fallback)
|
||||
_seed_file(SETTINGS_FILE, "settings.json", "{}")
|
||||
_seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}")
|
||||
_seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]")
|
||||
_seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]")
|
||||
_seed_file(ACCIDENT_LOG, "accident_log.json", "[]")
|
||||
|
||||
|
||||
_ensure_runtime_layout()
|
||||
|
||||
|
||||
# *=========================================== Defensive state loading
|
||||
def _load_json(path: str, fallback):
|
||||
"""Load JSON from *path*, falling back gracefully on missing/corrupt files."""
|
||||
@@ -335,9 +268,6 @@ else:
|
||||
|
||||
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
|
||||
|
||||
# Voice recognition (AssemblyAI). None = the voice cog reports and disables.
|
||||
ASSEMBLYAI_API_KEY = _resolve_token("assemblyai", "ASSEMBLYAI_API_KEY")
|
||||
|
||||
if spotipy:
|
||||
_spotify_creds = _load_netrc_credentials("spotipy")
|
||||
if _spotify_creds and SpotifyClientCredentials:
|
||||
|
||||
+2
-10
@@ -1,7 +1,7 @@
|
||||
# Conjurer main Discord bot.
|
||||
# Build from the repository root:
|
||||
# docker build -f docker/Dockerfile.bot -t conjurer-bot .
|
||||
FROM python:3.13-trixie
|
||||
FROM python:3.11-slim
|
||||
|
||||
# System dependencies:
|
||||
# ffmpeg - audio download/convert (yt_dlp) and Discord voice
|
||||
@@ -17,9 +17,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
ca-certificates \
|
||||
python3-dev \
|
||||
build-essential \
|
||||
portaudio19-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -29,14 +26,9 @@ RUN curl -fsSL https://drop-sh.fullyjustified.net | sh \
|
||||
&& mv tectonic /usr/local/bin/tectonic \
|
||||
|| echo "tectonic not installed - the LaTeX feature will be disabled"
|
||||
|
||||
COPY requirements_bot.txt requirements_conan.txt ./
|
||||
COPY requirements_bot.txt ./
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements_bot.txt
|
||||
# Best-effort Conan bridge extras: aiomcrcon supports Python <= 3.11 only, so
|
||||
# on this 3.13 image the install fails harmlessly and the conanjurer cog stays
|
||||
# dormant (its imports are guarded).
|
||||
RUN pip install --no-cache-dir -r requirements_conan.txt \
|
||||
|| echo "conan extras skipped - conanjurer cog will stay dormant"
|
||||
|
||||
# Vendored forks take import precedence over the pip packages of the same name,
|
||||
# because /app (the script dir) is first on sys.path when running `python bot.py`.
|
||||
|
||||
Vendored
-1
@@ -6,7 +6,6 @@ CONJURER_NETRC_FILE=/secrets/.netrc
|
||||
# Option B: pass tokens directly (these take precedence over netrc).
|
||||
# DISCORD_TOKEN=
|
||||
# OPENAI_API_KEY=
|
||||
# ASSEMBLYAI_API_KEY= # voice recognition; netrc machine 'assemblyai' works too
|
||||
# YOUTUBE_USERNAME=
|
||||
# YOUTUBE_PASSWORD=
|
||||
|
||||
|
||||
@@ -96,47 +96,8 @@ docker compose -f docker/compose.bot.yaml up -d --build
|
||||
docker logs -f conjurer-bot
|
||||
```
|
||||
|
||||
Look for `Extension loaded: …` for each cog and `All systems: operational`.
|
||||
|
||||
### 1e. Startup model: core cogs vs service-gated cogs
|
||||
|
||||
The bot **always** starts with the cogs that depend on nothing but itself
|
||||
(administration, AI, other, latex, voice, conanjurer). Cogs that need a
|
||||
sibling service are **health-gated**:
|
||||
|
||||
| Group | Cogs | Enabled when |
|
||||
|-------|------|--------------|
|
||||
| musician | `music_commands`, `radio_commands`, `file_search_commands` | `GET {FILE_SERVICE}/mp3` answers |
|
||||
| librarian | `librarian_commands` | librarian answers HTTP at all |
|
||||
|
||||
When a service is down its cogs stay disabled (commands simply don't exist)
|
||||
and the log says so. A watchdog re-checks every 5 minutes and enables the
|
||||
cogs the moment the service starts answering — no bot restart needed.
|
||||
A single broken cog (missing pip package, bad import) is skipped with a full
|
||||
traceback in the log; it never takes the whole bot down.
|
||||
|
||||
### 1f. Troubleshooting a crash-looping container
|
||||
|
||||
`docker logs conjurer-bot` now shows the real reason (the bot logs to stdout
|
||||
as well as the rotating file). The most common cases:
|
||||
|
||||
- **`FATAL: Discord token missing`** — the secrets mount is missing/empty or
|
||||
`CONJURER_NETRC_FILE` points elsewhere. Check:
|
||||
`docker inspect -f '{{json .Mounts}}' conjurer-bot | jq` and
|
||||
`docker exec conjurer-bot ls -la /secrets/` (after a manual
|
||||
`docker run … sleep infinity` if it crash-loops too fast).
|
||||
- **Missing state files** — not fatal anymore: missing dirs are created and
|
||||
missing JSON state is seeded from the repo templates baked into the image
|
||||
(existing files are never overwritten). Fix the mount at your leisure.
|
||||
|
||||
**About files "disappearing" from `/srv/conjurer/...`:** nothing in this stack
|
||||
deletes host files — the entrypoint and the bot only ever *create* missing
|
||||
files. With a bind mount, `/srv/conjurer/data` **is** the live state (not an
|
||||
installation staging area): don't delete it after a successful install.
|
||||
If files vanished, the usual suspects are `docker compose down -v` (only
|
||||
affects *named* volumes, not binds), a re-provisioned VM, or copying the files
|
||||
to a different path than the one in the compose `volumes:` line — verify with
|
||||
`docker inspect -f '{{json .Mounts}}' conjurer-bot`.
|
||||
Look for `Loading … module done` for each cog, `Loading conanjurer commands
|
||||
module done`, and `All systems: operational`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,5 +17,6 @@ PyMuPDF
|
||||
waitress
|
||||
assemblyai[extras]
|
||||
SpeechRecognition
|
||||
aiomcrcon
|
||||
asyncssh
|
||||
git+https://github.com/imayhaveborkedit/discord-ext-voice-recv
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Optional extras for the Conan Exiles bridge (conanjurer cog).
|
||||
#
|
||||
# aiomcrcon currently installs only on Python <= 3.11, while the main image
|
||||
# runs 3.13. Install is therefore best-effort: when it fails the conanjurer
|
||||
# cog simply stays dormant (its imports are guarded) and the bot runs fine.
|
||||
aiomcrcon
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import netrc
|
||||
import time
|
||||
import wave
|
||||
|
||||
@@ -8,17 +9,11 @@ import discord
|
||||
from discord.ext import commands, tasks, voice_recv
|
||||
from discord.opus import Decoder as OpusDecoder
|
||||
|
||||
from constants import ASSEMBLYAI_API_KEY, TRANSCRIPTS_PATH
|
||||
|
||||
# Credentials come from constants (env ASSEMBLYAI_API_KEY, or the 'assemblyai'
|
||||
# machine in the netrc at CONJURER_NETRC_FILE). Raising here means the guarded
|
||||
# extension loader logs the reason and disables ONLY this cog.
|
||||
if not ASSEMBLYAI_API_KEY:
|
||||
raise RuntimeError(
|
||||
"AssemblyAI API key not configured (netrc machine 'assemblyai' or "
|
||||
"ASSEMBLYAI_API_KEY env) - voice recognition stays disabled"
|
||||
)
|
||||
aai.settings.api_key = ASSEMBLYAI_API_KEY
|
||||
# Replace with your API key
|
||||
NETRC_FILE = "/home/pi/.netrc"
|
||||
netrc_mod = netrc.netrc(NETRC_FILE)
|
||||
authTokens = netrc_mod.authenticators("assemblyai")
|
||||
aai.settings.api_key = authTokens[2]
|
||||
|
||||
discord.opus._load_default()
|
||||
CHANNELS = OpusDecoder.CHANNELS
|
||||
@@ -28,7 +23,7 @@ SAMPLING_RATE = OpusDecoder.SAMPLING_RATE
|
||||
# rotate file after there is 0.5s between last received pcm for user.
|
||||
# delete messsages after user disconnect
|
||||
|
||||
LOCATION = TRANSCRIPTS_PATH
|
||||
LOCATION = "/home/pi/Conjurer/transcripts/"
|
||||
|
||||
|
||||
class CommunicationObject:
|
||||
|
||||
Reference in New Issue
Block a user