Files
conjurer/constants.py
Michal Tuszowski 8b7058b411 Apply conversation bot improvements (proto-improvements)
Diff vs working-copy baseline = exactly this session's bot edits:
- constants.py: env-var config, safe JSON loading, optional-dependency
  guards, env->netrc token resolution, API_SHARED_KEY + service_headers()
- communication_subroutine.py: queue get(timeout) + Empty handling,
  daemon threads, cooperative stop_event, inbound _authorize_request()
- bot.py: single asyncio event loop with cooperative shutdown
- music_functions / radio_commands / librarian_commands: send
  X-Conjurer-Api-Key on internal HTTP calls via service_headers()

Musician runtime data overlay dropped; conjurer_musician/.gitignore now
keeps generated playlists/mp3 out of the repo. The 1+2+3 musician code
port already lives in the base conjurer_musician.

Depends on #9: must merge after restructure/working-copy-root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:55:28 +02:00

300 lines
11 KiB
Python

"""Centralised configuration and runtime constants for Conjurer.
Historically this module performed heavy filesystem and credential reads at
import time which made the bot brittle on hosts that did not mirror the
original paths. This version keeps the original platform defaults (so the
behaviour on the Raspberry Pi / WSL / Windows deployments is unchanged when no
environment variables are set) but adds three robustness improvements ported
from the dockerised experiment:
* every path/endpoint can be overridden via an environment variable,
* JSON state files are loaded defensively (a missing or corrupt file no longer
crashes the whole bot at import time),
* optional dependencies (openai, spotipy, netrc) and credentials are guarded so
the bot can still start when a secondary integration is offline, and
* a shared ``CONJURER_API_KEY`` plus ``service_headers()`` helper enables
authenticated internal HTTP calls.
All path constants intentionally remain plain ``str`` (with their original
trailing separators) to stay byte-for-byte compatible with the existing string
concatenation in the command modules.
"""
import json
import logging
import os
from datetime import datetime
from platform import uname
from sys import platform
from typing import List, Optional, TypedDict
try:
import netrc
except ImportError: # pragma: no cover - standard on CPython
netrc = None
try:
import openai
except ImportError: # pragma: no cover - optional at runtime
openai = None
try:
import spotipy
from spotipy.oauth2 import SpotifyClientCredentials
except ImportError: # pragma: no cover - optional component
spotipy = None
SpotifyClientCredentials = None
logger = logging.getLogger("discord")
Music_Config = TypedDict(
"Music_Config",
{
"ctx": Optional[str],
"queue": List[str],
"requester": List[str],
},
)
# *=========================================== Predefines
MASTER_TIMEOUT = datetime.now()
INITIAL_TIME_WAIT = 500
MUZYKA: Music_Config = {"ctx": None, "queue": [], "requester": []}
LOGFILE = ""
NETRC_FILE = ""
MUSIC_FOLDER = ""
MEMORY_FIVE_SIARA = ""
MEMORY_FIVE_MUZYKA = ""
SETTINGS_FILE = ""
ENCODING = "utf-8"
GRAPHICS_PATH = ""
MUZYKA_MOJEGO_LUDU_HISTORIA = 1500
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = 15
MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30
GET_MP3 = "/mp3"
SEND_MP3 = "/update_mp3"
GET_PLAYLIST = "/get_music"
ADD_TO_PRIO_PLAYLIST = "/add_to_priority"
CREATE_PRIO_PLAYLIST = "/create_priority_playlist"
REQUEST_MUSIC = "/request_radio_file"
CLEAR_PRIO = "/clear_pr_pls"
SEND_QUERY = "/query"
TIME_BETWEEN_CALLS = 100000
LAST_SPONTANEOUS_CALL = datetime.now()
# *=========================================== Platform Specific Defaults
# These blocks only establish *default* values. Every constant is overridable
# through the matching environment variable further below.
if platform in ("linux", "linux2"):
SEPARATOR_FILE_PATH = "/"
if "microsoft-standard" in uname().release:
LOGFILE = "/home/mtuszowski/conjurer/discord.log"
MEMORY_FIVE_SIARA = "/home/mtuszowski/conjurer/pamiec.json"
SYSTEM_GPT_SETTINGS = "/home/mtuszowski/conjurer/system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "/home/mtuszowski/conjurer/pamiec_muzyki.json"
MUSIC_FOLDER = "/mnt/g/Muzyka/"
SETTINGS_FILE = "/home/mtuszowski/conjurer/settings.json"
NETRC_FILE = "/home/mtuszowski/.netrc"
LOGSTORE = "/home/mtuszowski/conjurer/logs/"
ACCIDENT_LOG = "/home/mtuszowski/conjurer/accident_log.json"
ENCODING = "utf-8"
GRAPHICS_PATH = "/home/mtuszowski/conjurer/Conjurer_graphics/"
DIR_PATH_SADOX = "/mnt/c/Users/mtusz/OneDrive/Dokumenty/Fansadox"
else:
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 = "/home/pi/MediaShare/Conjurer_graphics/"
DIR_PATH_SADOX = "/home/pi/MediaShare/Fansadox/"
elif platform == "win32":
LOGFILE = "discord.log"
MEMORY_FIVE_SIARA = "pamiec.json"
SYSTEM_GPT_SETTINGS = "system_gpt_settings.json"
MEMORY_FIVE_MUZYKA = "pamiec_muzyki.json"
MUSIC_FOLDER = "G:\\Muzyka\\"
SETTINGS_FILE = "settings.json"
NETRC_FILE = "C:\\Users\\mtusz\\.netrc"
LOGSTORE = "C:\\Users\\mtusz\\OneDrive\\Pulpit\\Conjurer\\logs\\"
ACCIDENT_LOG = "accident_log.json"
ENCODING = "utf-8"
DIR_PATH_SADOX = "C:\\Users\\mtusz\\OneDrive\\Dokumenty\\Fansadox\\"
SEPARATOR_FILE_PATH = "\\"
else:
# Fallback for development hosts (macOS, BSD, …) that match none of the
# production platforms. Everything is rooted next to this file so the
# module can at least be imported and unit-tested off-deployment.
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SEPARATOR_FILE_PATH = os.sep
LOGFILE = os.path.join(_BASE_DIR, "discord.log")
MEMORY_FIVE_SIARA = os.path.join(_BASE_DIR, "pamiec.json")
SYSTEM_GPT_SETTINGS = os.path.join(_BASE_DIR, "system_gpt_settings.json")
MEMORY_FIVE_MUZYKA = os.path.join(_BASE_DIR, "pamiec_muzyki.json")
MUSIC_FOLDER = os.path.join(_BASE_DIR, "music") + os.sep
SETTINGS_FILE = os.path.join(_BASE_DIR, "settings.json")
NETRC_FILE = os.path.join(os.path.expanduser("~"), ".netrc")
LOGSTORE = os.path.join(_BASE_DIR, "logs") + os.sep
ACCIDENT_LOG = os.path.join(_BASE_DIR, "accident_log.json")
GRAPHICS_PATH = os.path.join(_BASE_DIR, "Conjurer_graphics") + os.sep
DIR_PATH_SADOX = os.path.join(_BASE_DIR, "Fansadox") + os.sep
# *=========================================== Environment overrides
# Values stay as plain strings so existing ``PATH + filename`` concatenation in
# the command modules keeps working unchanged.
LOGFILE = os.getenv("CONJURER_LOG_FILE", LOGFILE)
NETRC_FILE = os.getenv("CONJURER_NETRC_FILE", NETRC_FILE)
SETTINGS_FILE = os.getenv("CONJURER_SETTINGS_FILE", SETTINGS_FILE)
MEMORY_FIVE_SIARA = os.getenv("CONJURER_MEMORY_FILE", MEMORY_FIVE_SIARA)
MEMORY_FIVE_MUZYKA = os.getenv("CONJURER_MUSIC_MEMORY_FILE", MEMORY_FIVE_MUZYKA)
SYSTEM_GPT_SETTINGS = os.getenv("CONJURER_SYSTEM_GPT_SETTINGS", SYSTEM_GPT_SETTINGS)
GRAPHICS_PATH = os.getenv("CONJURER_GRAPHICS_PATH", GRAPHICS_PATH)
MUSIC_FOLDER = os.getenv("CONJURER_MUSIC_FOLDER", MUSIC_FOLDER)
LOGSTORE = os.getenv("CONJURER_LOGSTORE", LOGSTORE)
ACCIDENT_LOG = os.getenv("CONJURER_ACCIDENT_LOG", ACCIDENT_LOG)
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)
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")
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
)
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.191")
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
# Shared secret for authenticating internal service-to-service HTTP calls.
API_SHARED_KEY = os.getenv("CONJURER_API_KEY", "")
# *=========================================== Defensive state loading
def _load_json(path: str, fallback):
"""Load JSON from *path*, falling back gracefully on missing/corrupt files."""
try:
with open(path, "r", encoding=ENCODING) as handle:
return json.load(handle)
except FileNotFoundError:
logger.warning("Missing JSON file at %s - using fallback", path)
return fallback
except json.JSONDecodeError:
logger.warning("Corrupt JSON at %s - resetting to fallback", path)
return fallback
DATA = _load_json(SETTINGS_FILE, {})
WORD_REACTIONS = DATA.get("word_reactions", {})
CYCLIC_WORDS = DATA.get("cyclic_words", {})
for key in WORD_REACTIONS:
if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
WORD_REACTIONS[key][2] = datetime.now()
MESSAGE_TABLE = _load_json(MEMORY_FIVE_SIARA, [])
GPT_SETTINGS = _load_json(SYSTEM_GPT_SETTINGS, {})
MESSAGE_TABLE_MUZYKA = _load_json(MEMORY_FIVE_MUZYKA, [])
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1] if isinstance(GPT_SETTINGS, list) else {}
ASSISTANTS = {}
# *=========================================== Credentials
def _load_netrc_credentials(host: str):
"""Return the netrc authenticators tuple for *host* or ``None``."""
if netrc is None:
return None
try:
parsed = netrc.netrc(NETRC_FILE)
except FileNotFoundError:
logger.warning("netrc file %s not found", NETRC_FILE)
return None
except netrc.NetrcParseError:
logger.warning("netrc file %s is invalid", NETRC_FILE)
return None
return parsed.authenticators(host)
def _resolve_token(host: str, env_var: str) -> Optional[str]:
"""Prefer an environment variable, then fall back to netrc."""
env_value = os.getenv(env_var)
if env_value:
return env_value
creds = _load_netrc_credentials(host)
if creds:
return creds[2]
logger.warning("Token for %s not configured", host)
return None
OPENAI_API_KEY = _resolve_token("openai", "OPENAI_API_KEY")
if openai and OPENAI_API_KEY:
openai.api_key = OPENAI_API_KEY
OPENAICLIENT = openai.AsyncOpenAI(api_key=OPENAI_API_KEY)
else:
OPENAICLIENT = None
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
if spotipy:
_spotify_creds = _load_netrc_credentials("spotipy")
if _spotify_creds and SpotifyClientCredentials:
SPOTIFY_CTRL = spotipy.Spotify(
client_credentials_manager=SpotifyClientCredentials(
client_id=_spotify_creds[0],
client_secret=_spotify_creds[2],
)
)
else:
SPOTIFY_CTRL = None
else:
SPOTIFY_CTRL = None
_youtube_creds = _load_netrc_credentials("youtube")
if _youtube_creds:
YOUTUBE_AUTH = [_youtube_creds[0], _youtube_creds[2]]
else:
YOUTUBE_AUTH = [
os.getenv("YOUTUBE_USERNAME", ""),
os.getenv("YOUTUBE_PASSWORD", ""),
]
def service_headers():
"""Shared header dict for internal service-to-service HTTP calls.
Returns an empty dict when no key is configured, keeping calls backward
compatible with deployments that do not (yet) enforce authentication.
"""
if API_SHARED_KEY:
return {"X-Conjurer-Api-Key": API_SHARED_KEY}
return {}
LATEX_TEX_ENGINE = "tectonic"
LATEX_MAX_COMPILE_SECONDS = 45
LATEX_MAX_ATTACH_MB = 8
LATEX_MAX_ZIP_MB = 25
OPENAI_MODEL = "gpt-4o-mini"
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)