c91ec03b83
CI / compile (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 26s
build / build (push) Successful in 12s
CI / compile (push) Successful in 5s
CI / unit (push) Successful in 22s
CI / integration (push) Successful in 26s
Two things the field report asked for. 1) PERSONAL ASSISTANTS (replacing the sunset OpenAI Assistants API) The old implementation gave three capabilities. Two are reimplemented here, the third was confirmed unused and is deliberately not replaced: * per-user persona - it already lived in system_gpt_settings.json; it was only ever being shipped to OpenAI. It is now the system prompt. * per-user conversation thread - OpenAI held this server-side. It now lives in assistant_memory.json, keyed by discord user id, trimmed to the most recent turns (CONJURER_ASSISTANT_MEMORY_TURNS) and written atomically so a torn write cannot lose someone's history. Deliberately a plain trim, not the AI summarisation used for the bar's shared memory: these are private DMs and must not end up in a public "legend". * file_search - not replaced. Confirmed not in use. The conversation goes through handle_response with request_type="NONE" and an explicit message list, which keeps it out of the bar's shared memory. The big win: create_chat_assistant hardcoded model="gpt-4o", so assistants were locked to OpenAI. They now run on whatever $gadaj_teraz selects - Claude and Ollama included. create_chat_assistant / chat_with_assistant are gone, and with them the last call to beta.threads in the startup path - so the cog cannot be killed by that API again. (add_files_to_vector_store / delete_files_from_vector_store still reference beta.assistants but are dead code - nothing calls them - so they cannot crash anything; left alone rather than widening this change.) 2) KEEPING A SELF-HOSTED MODEL WARM Loading is the slow part - the GPU is shared with other users - so we preload via Ollama's documented mechanism: /api/generate with a model, a keep_alive and NO prompt. It loads the model and generates nothing. * on switching to ollama, $gadaj_teraz fires a preload in the BACKGROUND (not awaited: loading can take minutes and the command must answer at once), so the wait lands on the operator rather than the first user; * a warm loop re-asserts keep_alive every CONJURER_OLLAMA_WARM_MINUTES. Both are hard-guarded on the ACTIVE provider being ollama. Warming a metered API would burn tokens and money for nothing, so that guard is pinned by a test asserting the preload is never called for gpt/claude, and another asserting the preload body carries no prompt (a prompt would make every warm-up generate). Tests: 82 unit + 71 integration green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
599 lines
25 KiB
Python
599 lines
25 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 anthropic
|
|
except ImportError: # pragma: no cover - optional at runtime
|
|
anthropic = 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"
|
|
GET_SHARE_LIST = "/get_share_list"
|
|
GET_SHARE_LINKS = "/get_share_links"
|
|
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"
|
|
# Health-check round-trip: a pseudo-query that the librarian must pull off its
|
|
# own internal queue and answer (same uuid) WITHOUT running a real search.
|
|
LIBRARIAN_PING = "/ping"
|
|
# Per-query watchdog probe: "do you still know this uuid?" (queued/processing).
|
|
# A uuid that vanishes here without its result reaching the bot was lost.
|
|
QUERY_STATUS = "/query_status"
|
|
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 = "./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"
|
|
ENCODING = "utf-8"
|
|
GRAPHICS_PATH = "./Conjurer_graphics/"
|
|
DIR_PATH_SADOX = "./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.
|
|
|
|
# Container-friendly shortcut: point CONJURER_DATA_DIR at a single mounted
|
|
# volume and every writable data file/dir is rooted under it. The per-variable
|
|
# CONJURER_* overrides below still take precedence, so granular control remains
|
|
# possible and the native (Pi) deployment is unaffected when it is unset.
|
|
_DATA_DIR = os.getenv("CONJURER_DATA_DIR")
|
|
if _DATA_DIR:
|
|
LOGFILE = os.path.join(_DATA_DIR, "discord.log")
|
|
SETTINGS_FILE = os.path.join(_DATA_DIR, "settings.json")
|
|
MEMORY_FIVE_SIARA = os.path.join(_DATA_DIR, "pamiec.json")
|
|
MEMORY_FIVE_MUZYKA = os.path.join(_DATA_DIR, "pamiec_muzyki.json")
|
|
SYSTEM_GPT_SETTINGS = os.path.join(_DATA_DIR, "system_gpt_settings.json")
|
|
ACCIDENT_LOG = os.path.join(_DATA_DIR, "accident_log.json")
|
|
LOGSTORE = os.path.join(_DATA_DIR, "logs") + os.sep
|
|
GRAPHICS_PATH = os.path.join(_DATA_DIR, "Conjurer_graphics") + os.sep
|
|
MUSIC_FOLDER = os.path.join(_DATA_DIR, "music") + os.sep
|
|
DIR_PATH_SADOX = os.path.join(_DATA_DIR, "Fansadox") + os.sep
|
|
|
|
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)
|
|
|
|
# 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,
|
|
)
|
|
|
|
# The bar's growing menu of AI-invented drinks (bar_commands). Lives next to the
|
|
# conversation memory by default; seeded empty on first run.
|
|
MENU_FILE = os.getenv(
|
|
"CONJURER_MENU_FILE",
|
|
os.path.join(os.path.dirname(MEMORY_FIVE_SIARA) or ".", "menu.json"),
|
|
)
|
|
# "Legendy Baru": archive of AI-written summaries of old conversation memory
|
|
# (lore_commands). Lives next to pamiec.json by default; seeded empty.
|
|
LEGENDS_FILE = os.getenv(
|
|
"CONJURER_LEGENDS_FILE",
|
|
os.path.join(os.path.dirname(MEMORY_FIVE_SIARA) or ".", "legendy.json"),
|
|
)
|
|
# Channel the compacted "legends" are announced to (the #legendy channel).
|
|
LEGENDS_CHANNEL_ID = int(os.getenv("CONJURER_LEGENDS_CHANNEL", "1084448332841230388"))
|
|
# Memory compaction: when pamiec.json grows past THRESHOLD messages, summarise
|
|
# everything except the last KEEP_RECENT into one legend. HOURS is how often the
|
|
# background task checks.
|
|
MEMORY_COMPACT_THRESHOLD = int(os.getenv("CONJURER_MEMORY_COMPACT_THRESHOLD", "400"))
|
|
MEMORY_KEEP_RECENT = int(os.getenv("CONJURER_MEMORY_KEEP_RECENT", "200"))
|
|
MEMORY_COMPACT_HOURS = float(os.getenv("CONJURER_MEMORY_COMPACT_HOURS", "6"))
|
|
|
|
# Durable result-delivery spool (librarian -> bot). The bot persists every
|
|
# incoming search result to RESULT_INBOX_DIR before acking and only forgets it
|
|
# once rendered (uuid recorded in DELIVERED_DIR), so an expensive (hours-long)
|
|
# result survives a bot restart mid-flight and duplicate resends are idempotent.
|
|
# Rooted under CONJURER_DATA_DIR when set (a mounted volume), else next to the
|
|
# log file. DELIVERED_MAX bounds the remembered-uuid set.
|
|
_STATE_ROOT = _DATA_DIR or (os.path.dirname(LOGFILE) or ".")
|
|
RESULT_INBOX_DIR = os.getenv(
|
|
"CONJURER_RESULT_INBOX", os.path.join(_STATE_ROOT, "result_inbox")
|
|
)
|
|
DELIVERED_DIR = os.getenv(
|
|
"CONJURER_DELIVERED_DIR", os.path.join(_STATE_ROOT, "delivered_uuids")
|
|
)
|
|
DELIVERED_MAX = int(os.getenv("CONJURER_DELIVERED_MAX", "10000"))
|
|
|
|
# Personal DM assistants. Replaces the OpenAI Assistants API (threads/runs),
|
|
# which was sunset and answers 404: the persona now rides handle_response, so it
|
|
# works on EVERY backend, and the conversation lives here instead of on OpenAI's
|
|
# server. Kept per user so private DMs never bleed into the bar's shared memory,
|
|
# and trimmed to the most recent turns so it cannot grow without bound.
|
|
ASSISTANT_MEMORY_FILE = os.getenv(
|
|
"CONJURER_ASSISTANT_MEMORY", os.path.join(_STATE_ROOT, "assistant_memory.json")
|
|
)
|
|
ASSISTANT_MEMORY_TURNS = int(os.getenv("CONJURER_ASSISTANT_MEMORY_TURNS", "40"))
|
|
|
|
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")
|
|
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
|
# the musician address so deployments that have not split yet keep working.
|
|
RADIO_SERVICE_ADDRESS = os.getenv("CONJURER_RADIO_SERVICE", FILE_SERVICE_ADDRESS)
|
|
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
|
|
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
|
|
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
|
|
)
|
|
# The address the librarian (and its pongs) should send results BACK to for THIS
|
|
# bot - so one librarian can serve several bots (test + deploy), each getting its
|
|
# own answers. Empty => the librarian falls back to its static CONJURER_MAIN_BOT.
|
|
SELF_CALLBACK = os.getenv("CONJURER_SELF_CALLBACK", "")
|
|
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", "")
|
|
|
|
|
|
# *=========================================== 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(MENU_FILE, "menu.json", "[]")
|
|
_seed_file(LEGENDS_FILE, "legendy.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."""
|
|
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
|
|
|
|
# Claude / Anthropic client, wired analogously to OpenAI above so the AI cog can
|
|
# be pointed at either backend with a single config switch (see AI_CONFIGS and
|
|
# ai_functions.set_active_ai_config). netrc machine name 'anthropic' works too.
|
|
ANTHROPIC_API_KEY = _resolve_token("anthropic", "ANTHROPIC_API_KEY")
|
|
if anthropic and ANTHROPIC_API_KEY:
|
|
CLAUDECLIENT = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
|
else:
|
|
CLAUDECLIENT = None
|
|
|
|
# Ollama (self-hosted models). There is no API key - the endpoint IS the whole
|
|
# configuration, so the feature stays dormant until CONJURER_OLLAMA_URL is set
|
|
# (same pattern as the Conan bridge). We talk to Ollama's OpenAI-COMPATIBLE
|
|
# surface (/v1) with the openai SDK we already depend on, which means the
|
|
# existing message format and _map_openai_error handling work unchanged.
|
|
# How long handle_response waits for ANY backend before giving up. 120s was
|
|
# hardcoded and is fine for hosted APIs, but a self-hosted model on a modest GPU
|
|
# can legitimately take longer, so it is now tunable.
|
|
AI_TIMEOUT_SECONDS = int(os.getenv("CONJURER_AI_TIMEOUT_SECONDS", "120"))
|
|
|
|
OLLAMA_URL = os.getenv("CONJURER_OLLAMA_URL", "").rstrip("/")
|
|
# Keeping a self-hosted model resident. Loading it is the slow part (it is
|
|
# offloaded to a GPU shared with other users), so we preload it - Ollama's
|
|
# /api/generate with a model and NO prompt loads it and generates nothing, which
|
|
# costs no tokens and no money. KEEP_ALIVE is how long Ollama should then hold
|
|
# it; the warm loop re-asserts that well inside the window.
|
|
# STRICTLY Ollama-only: doing this against a paid API would burn tokens for
|
|
# nothing, so every caller checks the active provider first.
|
|
OLLAMA_KEEP_ALIVE = os.getenv("CONJURER_OLLAMA_KEEP_ALIVE", "30m")
|
|
OLLAMA_WARM_MINUTES = float(os.getenv("CONJURER_OLLAMA_WARM_MINUTES", "10"))
|
|
# A preload waits for the model to finish loading, which on a shared GPU is the
|
|
# slow path we are trying to move off the user's first message.
|
|
OLLAMA_PRELOAD_TIMEOUT = int(os.getenv("CONJURER_OLLAMA_PRELOAD_TIMEOUT", "600"))
|
|
|
|
OLLAMA_LATEST_MODEL = os.getenv("CONJURER_OLLAMA_MODEL", "llama3.1:8b")
|
|
OLLAMA_CHEAP_MODEL = os.getenv("CONJURER_OLLAMA_CHEAP_MODEL", OLLAMA_LATEST_MODEL)
|
|
if openai and OLLAMA_URL:
|
|
# api_key is required by the SDK but ignored by Ollama.
|
|
OLLAMACLIENT = openai.AsyncOpenAI(base_url=f"{OLLAMA_URL}/v1", api_key="ollama")
|
|
else:
|
|
OLLAMACLIENT = None
|
|
|
|
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:
|
|
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)
|
|
|
|
# Claude counterparts of LATEST_MODEL / CHEAP_MODEL. Opus 4.8 is the strongest
|
|
# widely available model; Haiku 4.5 is the fast/cheap tier used for MUSIC.
|
|
CLAUDE_LATEST_MODEL = "claude-opus-4-8"
|
|
CLAUDE_CHEAP_MODEL = "claude-haiku-4-5"
|
|
|
|
|
|
# *=========================================== AI provider configs
|
|
# The bot's AI functionality (ai_functions.handle_response) can be pointed at a
|
|
# different backend by flipping a single "active" switch. Each named config
|
|
# collects the *differences* between providers (which backend, which models,
|
|
# generation params). These live in system_gpt_settings.json under an optional
|
|
# third list element (index 2) so future configs are simply added there:
|
|
#
|
|
# [ <system message>, <personal assistants>, { "active": "gpt",
|
|
# "configs": { ... } } ]
|
|
#
|
|
# The block is optional and backward compatible: a settings file with only the
|
|
# original two elements falls back to the built-in defaults below (active
|
|
# "gpt"), so existing deployments behave exactly as before. The active config
|
|
# can be overridden at import time with CONJURER_AI_CONFIG and at runtime with
|
|
# the $gadaj_teraz command (which persists the choice back into index 2).
|
|
def _default_ai_configs():
|
|
return {
|
|
"gpt": {
|
|
"provider": "openai",
|
|
"latest_model": LATEST_MODEL,
|
|
"cheap_model": CHEAP_MODEL,
|
|
"temperature": 0.2,
|
|
},
|
|
"claude": {
|
|
"provider": "anthropic",
|
|
"latest_model": CLAUDE_LATEST_MODEL,
|
|
"cheap_model": CLAUDE_CHEAP_MODEL,
|
|
# Anthropic requires max_tokens; temperature is intentionally not
|
|
# sent for Claude (Opus 4.8 / Sonnet 5 reject sampling params).
|
|
"max_tokens": 2048,
|
|
},
|
|
"ollama": {
|
|
"provider": "ollama",
|
|
"latest_model": OLLAMA_LATEST_MODEL,
|
|
"cheap_model": OLLAMA_CHEAP_MODEL,
|
|
"temperature": 0.2,
|
|
},
|
|
# Template for wiring further providers. Copy it, rename the key, point
|
|
# "provider" at a backend ai_functions.provider_generate implements, and
|
|
# fill in the model ids. Keys starting with "_" are treated as inert
|
|
# templates and are hidden from the $gadaj_teraz picker.
|
|
"_template": {
|
|
"provider": "openai",
|
|
"latest_model": "model-id-here",
|
|
"cheap_model": "cheaper-model-id-here",
|
|
"temperature": 0.2,
|
|
"max_tokens": 2048,
|
|
},
|
|
}
|
|
|
|
|
|
_ai_block = (
|
|
GPT_SETTINGS[2]
|
|
if isinstance(GPT_SETTINGS, list) and len(GPT_SETTINGS) > 2 and isinstance(GPT_SETTINGS[2], dict)
|
|
else {}
|
|
)
|
|
# Built-in defaults FIRST, then whatever the settings file defines on top. The
|
|
# file cannot simply win outright: every provider switch persists a "configs"
|
|
# block, so a file written by an older build would permanently hide providers
|
|
# added later (ollama) from the picker.
|
|
AI_CONFIGS = _default_ai_configs()
|
|
AI_CONFIGS.update(_ai_block.get("configs") or {})
|
|
# Single switch: env var wins, then the settings-file "active" key, then "gpt".
|
|
DEFAULT_AI_CONFIG = (
|
|
os.getenv("CONJURER_AI_CONFIG")
|
|
or _ai_block.get("active")
|
|
or "gpt"
|
|
)
|
|
if DEFAULT_AI_CONFIG not in AI_CONFIGS:
|
|
logger.warning(
|
|
"AI config '%s' not found - falling back to 'gpt'", DEFAULT_AI_CONFIG
|
|
)
|
|
DEFAULT_AI_CONFIG = "gpt" if "gpt" in AI_CONFIGS else next(iter(AI_CONFIGS))
|
|
|
|
|
|
# *=========================================== Conan Exiles bridge
|
|
# Every value is optional; the conanjurer cog stays dormant unless configured.
|
|
# Channel/role ids default to 0 ("not defined"); RCON host/log path default to
|
|
# "" ("disabled"). See conanjurer_commands.py / conanjurer_functions.py.
|
|
CONAN_GM_ROLE_ID = int(os.getenv("CONAN_GM_ROLE_ID", "0"))
|
|
CONAN_CHAT_CHANNEL_ID = int(os.getenv("CONAN_CHAT_CHANNEL_ID", "0"))
|
|
CONAN_EVENTS_CHANNEL_ID = int(os.getenv("CONAN_EVENTS_CHANNEL_ID", "0"))
|
|
# Player-join notifications: leave at 0 to keep the feature disabled.
|
|
CONAN_JOIN_CHANNEL_ID = int(os.getenv("CONAN_JOIN_CHANNEL_ID", "0"))
|
|
CONAN_PLAYER_POLL_SECONDS = int(os.getenv("CONAN_PLAYER_POLL_SECONDS", "60"))
|
|
|
|
CONAN_RCON_HOST = os.getenv("CONAN_RCON_HOST", "")
|
|
CONAN_RCON_PORT = int(os.getenv("CONAN_RCON_PORT", "25575"))
|
|
CONAN_RCON_PASSWORD = os.getenv("CONAN_RCON_PASSWORD", "")
|
|
|
|
CONAN_LOG_MODE = os.getenv("CONAN_LOG_MODE", "local") # "local" | "sftp"
|
|
CONAN_LOG_PATH = os.getenv("CONAN_LOG_PATH", "")
|
|
CONAN_SFTP_HOST = os.getenv("CONAN_SFTP_HOST", "")
|
|
CONAN_SFTP_PORT = int(os.getenv("CONAN_SFTP_PORT", "22"))
|
|
CONAN_SFTP_USER = os.getenv("CONAN_SFTP_USER", "")
|
|
CONAN_SFTP_PASSWORD = os.getenv("CONAN_SFTP_PASSWORD", "")
|