|
|
@@ -27,7 +27,16 @@ DEFAULT_DEEZER_PLAYLISTS = "3155776842,1109890291,1362508955"
|
|
|
|
|
|
|
|
|
class ConfigError(Exception):
|
|
|
- """Erreur de configuration (variable manquante ou invalide)."""
|
|
|
+ """Erreur de configuration (variable manquante ou invalide). Celle de load_settings rassemble toutes les
|
|
|
+ erreurs trouvées (errors) et indique le fichier de journal s'il est utilisable (log_file, sinon None)."""
|
|
|
+
|
|
|
+ def __init__(self, *errors: str, log_file: Path | None = None):
|
|
|
+ super().__init__(*errors)
|
|
|
+ self.errors = list(errors)
|
|
|
+ self.log_file = log_file
|
|
|
+
|
|
|
+ def __str__(self) -> str:
|
|
|
+ return "\n".join(self.errors)
|
|
|
|
|
|
|
|
|
def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
|
|
|
@@ -196,20 +205,31 @@ class Settings:
|
|
|
|
|
|
|
|
|
def load_settings() -> Settings:
|
|
|
- """Lit le fichier .env et renvoie une configuration validée."""
|
|
|
+ """Lit le fichier .env et renvoie une configuration validée. Toutes les erreurs sont rassemblées dans une
|
|
|
+ seule ConfigError : avec trois variables fausses, un seul redémarrage suffit pour les voir."""
|
|
|
# override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
|
|
|
load_dotenv(override=True)
|
|
|
+ errors: list[str] = []
|
|
|
+
|
|
|
+ def check(read, *args, **kwargs):
|
|
|
+ """Appelle read ; en cas d'erreur, la note et renvoie None (les vérifications croisées l'ignorent)."""
|
|
|
+ try:
|
|
|
+ return read(*args, **kwargs)
|
|
|
+ except ConfigError as exc:
|
|
|
+ errors.extend(exc.errors)
|
|
|
+ return None
|
|
|
|
|
|
tz_name = _get_str("TIMEZONE", default="Europe/Paris")
|
|
|
try:
|
|
|
timezone = ZoneInfo(tz_name)
|
|
|
# ValueError : nom mal formé (« Europe/Paris/ », « ../Paris ») ; OSError : nom trop long pour un fichier.
|
|
|
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
|
- raise ConfigError(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
|
|
|
+ timezone = None
|
|
|
+ errors.append(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
|
|
|
|
|
|
thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
|
|
|
if thinking_raw not in ("on", "off"):
|
|
|
- raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
|
|
|
+ errors.append(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
|
|
|
thinking = thinking_raw == "on"
|
|
|
|
|
|
model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
|
|
|
@@ -217,57 +237,55 @@ def load_settings() -> Settings:
|
|
|
if effort is not None:
|
|
|
effort = effort.lower()
|
|
|
if effort not in EFFORT_LEVELS:
|
|
|
- raise ConfigError(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
|
|
|
+ errors.append(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
|
|
|
# Couper la réflexion n'est accepté qu'avec un effort high ou moins.
|
|
|
- if not thinking and effort in ("xhigh", "max") and thinking_off(model) is not None:
|
|
|
- raise ConfigError(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
|
|
|
+ elif thinking_raw == "off" and effort in ("xhigh", "max") and thinking_off(model) is not None:
|
|
|
+ errors.append(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
|
|
|
|
|
|
# Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
|
|
|
default_input, default_output = PRICES.get(model, (None, None))
|
|
|
|
|
|
web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
|
|
|
if web_search_raw not in ("on", "off"):
|
|
|
- raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
|
|
|
+ errors.append(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
|
|
|
|
|
|
memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
|
|
|
if memory_raw not in ("on", "off"):
|
|
|
- raise ConfigError(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
|
|
|
+ errors.append(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
|
|
|
history_file = Path(_get_str("HISTORY_FILE", default="conversation_history.json"))
|
|
|
# Vide : à côté de l'historique, comme les autres fichiers du bot (donc sur le même volume avec Docker).
|
|
|
memory_dir = _get_str("MEMORY_DIR")
|
|
|
- memory_limits = {name: _get_int(name, default=default, minimum=200)
|
|
|
+ # La fenêtre « Modifier » de /memoire contient au plus 5 zones de 4000 caractères.
|
|
|
+ memory_limits = {name: check(_get_int, name, default=default, minimum=200, maximum=20000)
|
|
|
for name, default in (("MEMORY_MEMBER_LIMIT", 3000), ("MEMORY_SERVER_LIMIT", 5000))}
|
|
|
- for name, value in memory_limits.items():
|
|
|
- # La fenêtre « Modifier » de /memoire contient au plus 5 zones de 4000 caractères.
|
|
|
- if value > 20000:
|
|
|
- raise ConfigError(f"{name} ne peut pas dépasser 20000 (valeur actuelle : {value}).")
|
|
|
|
|
|
presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
|
|
|
if presence_raw not in ("on", "off"):
|
|
|
- raise ConfigError(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
|
|
|
+ errors.append(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
|
|
|
# Au-delà, le statut change trop souvent (écriture de presence_state.json, limites de débit de Discord).
|
|
|
- presence_speed = _get_float("PRESENCE_SPEED", default=1.0, maximum=100)
|
|
|
- if presence_speed <= 0:
|
|
|
- raise ConfigError(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
|
|
|
+ presence_speed = check(_get_float, "PRESENCE_SPEED", default=1.0, maximum=100)
|
|
|
+ if presence_speed is not None and presence_speed <= 0:
|
|
|
+ errors.append(f"PRESENCE_SPEED doit être supérieur à 0 (valeur actuelle : {presence_speed}).")
|
|
|
|
|
|
+ # Une valeur invalide vaut None ici : l'erreur est notée, et la configuration n'est pas renvoyée.
|
|
|
settings = Settings(
|
|
|
- discord_token=_get_str("DISCORD_TOKEN", required=True),
|
|
|
- anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
|
|
|
- channel_id=_get_int("CHANNEL_ID", required=True),
|
|
|
+ discord_token=check(_get_str, "DISCORD_TOKEN", required=True),
|
|
|
+ anthropic_api_key=check(_get_str, "ANTHROPIC_API_KEY", required=True),
|
|
|
+ channel_id=check(_get_int, "CHANNEL_ID", required=True),
|
|
|
model=model,
|
|
|
# Une limite haute ne coûte que si elle est atteinte ; trop basse, elle coupe la réécriture
|
|
|
# d'un fichier de mémoire plein (voir memory_max_tokens).
|
|
|
# Au-delà de 64000, l'API refuse la requête (limite de sortie des modèles).
|
|
|
- max_tokens=_get_int("MAX_TOKENS", default=4000, minimum=1, maximum=64000),
|
|
|
+ max_tokens=check(_get_int, "MAX_TOKENS", default=4000, minimum=1, maximum=64000),
|
|
|
thinking=thinking,
|
|
|
effort=effort,
|
|
|
- api_timeout=_get_float("API_TIMEOUT", default=60.0, minimum=1),
|
|
|
+ api_timeout=check(_get_float, "API_TIMEOUT", default=60.0, minimum=1),
|
|
|
web_search=web_search_raw == "on",
|
|
|
# Chaque recherche est facturée : au-delà de 10, une seule question peut coûter cher.
|
|
|
- web_max_uses=_get_int("WEB_MAX_USES", default=3, minimum=1, maximum=10),
|
|
|
- max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
|
|
|
+ web_max_uses=check(_get_int, "WEB_MAX_USES", default=3, minimum=1, maximum=10),
|
|
|
+ max_history_length=check(_get_int, "MAX_HISTORY_LENGTH", default=10, minimum=2),
|
|
|
# Lus à chaque mention puis envoyés à Claude : au-delà, chaque mention coûte cher (ou dépasse la limite).
|
|
|
- context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0, maximum=100),
|
|
|
+ context_message_limit=check(_get_int, "CONTEXT_MESSAGE_LIMIT", default=20, minimum=0, maximum=100),
|
|
|
history_file=history_file,
|
|
|
personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
|
|
|
memory=memory_raw == "on",
|
|
|
@@ -275,42 +293,48 @@ def load_settings() -> Settings:
|
|
|
# Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
|
|
|
memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
|
|
|
memory_server_limit=memory_limits["MEMORY_SERVER_LIMIT"],
|
|
|
- memory_max_operations=_get_int("MEMORY_MAX_OPERATIONS", default=5, minimum=1, maximum=20),
|
|
|
+ memory_max_operations=check(_get_int, "MEMORY_MAX_OPERATIONS", default=5, minimum=1, maximum=20),
|
|
|
# Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
|
|
|
- max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
|
|
|
+ max_image_size=check(_get_int, "MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
|
|
|
# Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
|
|
|
# L'API refuse les PDF de plus de 100 pages.
|
|
|
- pdf_max_pages=_get_int("PDF_MAX_PAGES", default=20, minimum=0, maximum=100),
|
|
|
+ pdf_max_pages=check(_get_int, "PDF_MAX_PAGES", default=20, minimum=0, maximum=100),
|
|
|
presence=presence_raw == "on",
|
|
|
presence_speed=presence_speed,
|
|
|
- presence_weights=_get_presence_weights(),
|
|
|
- presence_night=_get_night(),
|
|
|
- deezer_playlists=_get_deezer_playlists(),
|
|
|
+ presence_weights=check(_get_presence_weights),
|
|
|
+ presence_night=check(_get_night),
|
|
|
+ deezer_playlists=check(_get_deezer_playlists),
|
|
|
steam_api_key=_get_str("STEAM_API_KEY"),
|
|
|
timezone=timezone,
|
|
|
- price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
|
|
|
- price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
|
|
|
- price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
|
|
|
+ price_input_per_mtok=check(_get_float, "PRICE_INPUT_PER_MTOK", default=default_input, minimum=0),
|
|
|
+ price_output_per_mtok=check(_get_float, "PRICE_OUTPUT_PER_MTOK", default=default_output, minimum=0),
|
|
|
+ price_web_search_per_1000=check(_get_float, "PRICE_WEB_SEARCH_PER_1000", default=10.0, minimum=0),
|
|
|
log_file=Path(_get_str("LOG_FILE", default="bot.log")),
|
|
|
)
|
|
|
- _check_output("LOG_FILE", settings.log_file)
|
|
|
- _check_output("HISTORY_FILE", settings.history_file)
|
|
|
+ count = len(errors)
|
|
|
+ check(_check_output, "LOG_FILE", settings.log_file)
|
|
|
+ # Journal utilisable : main.py y écrit les erreurs de configuration, pas seulement sur la console.
|
|
|
+ log_file = settings.log_file if len(errors) == count else None
|
|
|
+ check(_check_output, "HISTORY_FILE", settings.history_file)
|
|
|
if settings.memory:
|
|
|
# Le dossier de la mémoire se crée au premier enregistrement : son parent doit déjà exister.
|
|
|
if settings.memory_dir.is_dir():
|
|
|
- _check_output("MEMORY_DIR", settings.memory_dir, folder_only=True)
|
|
|
+ check(_check_output, "MEMORY_DIR", settings.memory_dir, folder_only=True)
|
|
|
else:
|
|
|
- _check_output("MEMORY_DIR", settings.memory_dir)
|
|
|
+ check(_check_output, "MEMORY_DIR", settings.memory_dir)
|
|
|
# Sans clé Steam, « jeu » est retiré au tirage : s'il était le seul statut possible, le bot resterait
|
|
|
# « en ligne » sans activité.
|
|
|
- if settings.presence and not settings.steam_api_key and not any(
|
|
|
+ if settings.presence and settings.presence_weights and not settings.steam_api_key and not any(
|
|
|
weight for name, weight in settings.presence_weights.items() if name != "jeu"):
|
|
|
- raise ConfigError("PRESENCE_WEIGHTS : le statut jeu est le seul possible, mais il nécessite STEAM_API_KEY. "
|
|
|
- "Renseigne STEAM_API_KEY ou donne une valeur supérieure à 0 à un autre statut.")
|
|
|
- if settings.memory and settings.max_tokens < (minimum := memory_max_tokens(settings)[0]):
|
|
|
- raise ConfigError(f"MAX_TOKENS ({settings.max_tokens}) est trop petit pour la mémoire des membres : la "
|
|
|
- f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "
|
|
|
- f"ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.")
|
|
|
+ errors.append("PRESENCE_WEIGHTS : le statut jeu est le seul possible, mais il nécessite STEAM_API_KEY. "
|
|
|
+ "Renseigne STEAM_API_KEY ou donne une valeur supérieure à 0 à un autre statut.")
|
|
|
+ if (settings.memory and None not in (settings.max_tokens, *memory_limits.values())
|
|
|
+ and settings.max_tokens < (minimum := memory_max_tokens(settings)[0])):
|
|
|
+ errors.append(f"MAX_TOKENS ({settings.max_tokens}) est trop petit pour la mémoire des membres : la "
|
|
|
+ f"réécriture d'un fichier plein serait coupée et la note perdue. Mets au moins {minimum}, "
|
|
|
+ f"ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.")
|
|
|
+ if errors:
|
|
|
+ raise ConfigError(*errors, log_file=log_file)
|
|
|
return settings
|
|
|
|
|
|
|