mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
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>
This commit is contained in:
+190
-46
@@ -1,13 +1,51 @@
|
||||
"""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 netrc
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from platform import uname
|
||||
from sys import platform
|
||||
from typing import List, Optional, TypedDict
|
||||
|
||||
import openai
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyClientCredentials
|
||||
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",
|
||||
@@ -30,16 +68,12 @@ MUSIC_FOLDER = ""
|
||||
MEMORY_FIVE_SIARA = ""
|
||||
MEMORY_FIVE_MUZYKA = ""
|
||||
SETTINGS_FILE = ""
|
||||
ENCODING = ""
|
||||
ENCODING = "utf-8"
|
||||
GRAPHICS_PATH = ""
|
||||
MUZYKA_MOJEGO_LUDU_HISTORIA = 1500
|
||||
MUZYKA_MOJEGO_LUDU_SLOWA_KLUCZOWE = 15
|
||||
MUZYKA_MOJEGO_LUDU_PLAJLISTA = 30
|
||||
|
||||
FILE_SERVICE_ADDRESS = "http://192.168.1.15:5000"
|
||||
RADIO_HARBOR_ADDRESS = "http://192.168.1.15:54321"
|
||||
SKIP_TRACK = "/skip"
|
||||
|
||||
GET_MP3 = "/mp3"
|
||||
SEND_MP3 = "/update_mp3"
|
||||
GET_PLAYLIST = "/get_music"
|
||||
@@ -48,14 +82,13 @@ CREATE_PRIO_PLAYLIST = "/create_priority_playlist"
|
||||
|
||||
REQUEST_MUSIC = "/request_radio_file"
|
||||
CLEAR_PRIO = "/clear_pr_pls"
|
||||
LIBRARIAN_SERVICE_ADDRESS = "http://192.168.1.192:5001"
|
||||
SEND_QUERY = "/query"
|
||||
TIME_BETWEEN_CALLS = 100000
|
||||
LAST_SPONTANEOUS_CALL = datetime.now()
|
||||
HOST_ADDRESS = "192.168.1.191"
|
||||
PORT_ADDRESS = 5000
|
||||
|
||||
# *=========================================== Platform Specific Predefines
|
||||
# *=========================================== 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 = "/"
|
||||
@@ -101,47 +134,158 @@ elif platform == "win32":
|
||||
ENCODING = "utf-8"
|
||||
DIR_PATH_SADOX = "C:\\Users\\mtusz\\OneDrive\\Dokumenty\\Fansadox\\"
|
||||
SEPARATOR_FILE_PATH = "\\"
|
||||
with open(SETTINGS_FILE, "r", encoding=ENCODING) as f_settings_file:
|
||||
DATA = json.load(f_settings_file)
|
||||
REMOTE_HOST_NAME = "openai"
|
||||
netrc_mod = netrc.netrc(NETRC_FILE)
|
||||
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
|
||||
openai.api_key = authTokens[2]
|
||||
OPENAICLIENT = openai.AsyncOpenAI(api_key=openai.api_key)
|
||||
REMOTE_HOST_NAME = "discord"
|
||||
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
|
||||
TOKEN = authTokens[2]
|
||||
|
||||
REMOTE_HOST_NAME = "spotipy"
|
||||
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
|
||||
SPOTIFY_CTRL = spotipy.Spotify(
|
||||
client_credentials_manager=SpotifyClientCredentials(
|
||||
client_id=authTokens[0],
|
||||
client_secret=authTokens[2],
|
||||
)
|
||||
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"
|
||||
)
|
||||
REMOTE_HOST_NAME = "youtube"
|
||||
authTokens = netrc_mod.authenticators(REMOTE_HOST_NAME)
|
||||
YOUTUBE_AUTH = [authTokens[0],authTokens[2]]
|
||||
HOST_ADDRESS = os.getenv("CONJURER_DISCORD_HOST", "192.168.1.191")
|
||||
PORT_ADDRESS = int(os.getenv("CONJURER_DISCORD_PORT", "5000"))
|
||||
|
||||
WORD_REACTIONS = DATA["word_reactions"]
|
||||
CYCLIC_WORDS = DATA["cyclic_words"]
|
||||
# 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:
|
||||
WORD_REACTIONS[key][2] = datetime.now()
|
||||
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as temp_memory_file:
|
||||
# First we load existing data into a dict.
|
||||
MESSAGE_TABLE = json.load(temp_memory_file)
|
||||
if isinstance(WORD_REACTIONS[key], list) and len(WORD_REACTIONS[key]) >= 3:
|
||||
WORD_REACTIONS[key][2] = datetime.now()
|
||||
|
||||
with open(SYSTEM_GPT_SETTINGS, "r+", encoding=ENCODING) as temp_settings_file:
|
||||
# First we load existing data into a dict.
|
||||
GPT_SETTINGS = json.load(temp_settings_file)
|
||||
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as temp_music_memory_file:
|
||||
# First we load existing data into a dict.
|
||||
MESSAGE_TABLE_MUZYKA = json.load(temp_music_memory_file)
|
||||
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user