Просмотр исходного кода

Erreurs de démarrage : toutes d'un coup, et dans bot.log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 13 часов назад
Родитель
Сommit
f225feea9a
5 измененных файлов с 129 добавлено и 55 удалено
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 70 46
      bot/config.py
  4. 29 8
      main.py
  5. 27 0
      tests/test_config.py

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 
 ### Corrections
 ### Corrections
 
 
+- **Erreurs de démarrage : toutes d'un coup, et dans `bot.log`.** Le bot s'arrêtait à la première variable fausse du `.env` : avec trois erreurs, il fallait trois redémarrages. Il les liste maintenant toutes. Le message n'était écrit que sur la console : il va aussi dans `bot.log` quand `LOG_FILE` est utilisable, pour qu'un redémarrage raté y laisse une trace. Enfin, un PC hors ligne ou une panne de Discord au démarrage donnaient une longue trace technique ; le bot dit maintenant « Impossible de joindre Discord : vérifie la connexion Internet » ou « Discord semble en panne ».
+
 - **Un `.env` entre guillemets marche aussi sous Docker.** `docker run --env-file` garde les guillemets que le bot retire en local : `DISCORD_TOKEN="abc"` donnait « Token Discord invalide » sous Docker. Le bot retire maintenant lui-même une paire de guillemets autour d'une valeur. Le README précise que `--env-file` ne retire pas les commentaires en fin de ligne, et que le `.env` est prioritaire sur une variable passée en ligne de commande.
 - **Un `.env` entre guillemets marche aussi sous Docker.** `docker run --env-file` garde les guillemets que le bot retire en local : `DISCORD_TOKEN="abc"` donnait « Token Discord invalide » sous Docker. Le bot retire maintenant lui-même une paire de guillemets autour d'une valeur. Le README précise que `--env-file` ne retire pas les commentaires en fin de ligne, et que le `.env` est prioritaire sur une variable passée en ligne de commande.
 
 
 - **« Réflexion : off » n'est plus affiché à tort.** Sur un modèle dont la réflexion ne peut pas être coupée (Opus 5.5), `THINKING=off` laissait la réflexion active alors que le journal de démarrage affichait « off ». Il indique maintenant « impossible à couper sur ce modèle (règle EFFORT) ».
 - **« Réflexion : off » n'est plus affiché à tort.** Sur un modèle dont la réflexion ne peut pas être coupée (Opus 5.5), `THINKING=off` laissait la réflexion active alors que le journal de démarrage affichait « off ». Il indique maintenant « impossible à couper sur ce modèle (règle EFFORT) ».

+ 1 - 1
README.md

@@ -93,7 +93,7 @@ Le bot ne sert qu'un serveur, celui du salon `CHANNEL_ID` : ailleurs (autre serv
    ```
    ```
    python main.py
    python main.py
    ```
    ```
-   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, clé Anthropic invalide, modèle inexistant, intent non activé, `CHANNEL_ID` qui ne désigne pas un salon textuel où le bot peut écrire, permission manquante), il s'arrête en expliquant quoi corriger. Seule exception : si l'un de ses serveurs est indisponible chez Discord au démarrage (panne), il patiente et s'annonce dès le retour du serveur.
+   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, clé Anthropic invalide, modèle inexistant, intent non activé, `CHANNEL_ID` qui ne désigne pas un salon textuel où le bot peut écrire, permission manquante), il s'arrête en expliquant quoi corriger. Toutes les erreurs du `.env` sont signalées d'un coup, et le message est aussi écrit dans `bot.log`. Sans connexion Internet, ou si Discord est en panne, il s'arrête aussi avec un message clair. Seule exception : si l'un de ses serveurs est indisponible chez Discord au démarrage (panne), il patiente et s'annonce dès le retour du serveur.
 
 
 ## Configuration
 ## Configuration
 
 

+ 70 - 46
bot/config.py

@@ -27,7 +27,16 @@ DEFAULT_DEEZER_PLAYLISTS = "3155776842,1109890291,1362508955"
 
 
 
 
 class ConfigError(Exception):
 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:
 def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
@@ -196,20 +205,31 @@ class Settings:
 
 
 
 
 def load_settings() -> 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.
     # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
     load_dotenv(override=True)
     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")
     tz_name = _get_str("TIMEZONE", default="Europe/Paris")
     try:
     try:
         timezone = ZoneInfo(tz_name)
         timezone = ZoneInfo(tz_name)
     # ValueError : nom mal formé (« Europe/Paris/ », « ../Paris ») ; OSError : nom trop long pour un fichier.
     # ValueError : nom mal formé (« Europe/Paris/ », « ../Paris ») ; OSError : nom trop long pour un fichier.
     except (ZoneInfoNotFoundError, ValueError, OSError):
     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()
     thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
     if thinking_raw not in ("on", "off"):
     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"
     thinking = thinking_raw == "on"
 
 
     model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
     model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
@@ -217,57 +237,55 @@ def load_settings() -> Settings:
     if effort is not None:
     if effort is not None:
         effort = effort.lower()
         effort = effort.lower()
         if effort not in EFFORT_LEVELS:
         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.
         # 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.
     # Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
     default_input, default_output = PRICES.get(model, (None, None))
     default_input, default_output = PRICES.get(model, (None, None))
 
 
     web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
     web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
     if web_search_raw not in ("on", "off"):
     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()
     memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
     if memory_raw not in ("on", "off"):
     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"))
     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).
     # 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_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, 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()
     presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
     if presence_raw not in ("on", "off"):
     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).
     # 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(
     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,
         model=model,
         # Une limite haute ne coûte que si elle est atteinte ; trop basse, elle coupe la réécriture
         # 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).
         # 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).
         # 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,
         thinking=thinking,
         effort=effort,
         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",
         web_search=web_search_raw == "on",
         # Chaque recherche est facturée : au-delà de 10, une seule question peut coûter cher.
         # 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).
         # 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,
         history_file=history_file,
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         memory=memory_raw == "on",
         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.
         # Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
         memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
         memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
         memory_server_limit=memory_limits["MEMORY_SERVER_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.
         # 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).
         # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
         # L'API refuse les PDF de plus de 100 pages.
         # 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=presence_raw == "on",
         presence_speed=presence_speed,
         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"),
         steam_api_key=_get_str("STEAM_API_KEY"),
         timezone=timezone,
         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")),
         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:
     if settings.memory:
         # Le dossier de la mémoire se crée au premier enregistrement : son parent doit déjà exister.
         # Le dossier de la mémoire se crée au premier enregistrement : son parent doit déjà exister.
         if settings.memory_dir.is_dir():
         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:
         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
     # Sans clé Steam, « jeu » est retiré au tirage : s'il était le seul statut possible, le bot resterait
     # « en ligne » sans activité.
     # « 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"):
             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
     return settings
 
 
 
 

+ 29 - 8
main.py

@@ -3,24 +3,27 @@
 import logging
 import logging
 import sys
 import sys
 from logging.handlers import RotatingFileHandler
 from logging.handlers import RotatingFileHandler
+from pathlib import Path
 
 
+import aiohttp
 import discord
 import discord
 
 
 from bot.config import ConfigError, load_settings
 from bot.config import ConfigError, load_settings
 from bot.discord_bot import ChatBot
 from bot.discord_bot import ChatBot
 
 
 
 
-def setup_logging(log_file) -> None:
+def setup_logging(log_file: Path | None) -> None:
+    """Journal sur la console et dans log_file (seulement sur la console si log_file vaut None)."""
     formatter = logging.Formatter("[%(asctime)s] %(levelname)s %(name)s : %(message)s", "%Y-%m-%d %H:%M:%S")
     formatter = logging.Formatter("[%(asctime)s] %(levelname)s %(name)s : %(message)s", "%Y-%m-%d %H:%M:%S")
     root = logging.getLogger()
     root = logging.getLogger()
     root.setLevel(logging.INFO)
     root.setLevel(logging.INFO)
 
 
-    file_handler = RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=2, encoding="utf-8")
-    file_handler.setFormatter(formatter)
-    console_handler = logging.StreamHandler()
-    console_handler.setFormatter(formatter)
-    root.addHandler(file_handler)
-    root.addHandler(console_handler)
+    handlers = [logging.StreamHandler()]
+    if log_file is not None:
+        handlers.append(RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=2, encoding="utf-8"))
+    for handler in handlers:
+        handler.setFormatter(formatter)
+        root.addHandler(handler)
 
 
     # discord.py et le SDK Anthropic sont bavards : on ne garde que leurs avertissements.
     # discord.py et le SDK Anthropic sont bavards : on ne garde que leurs avertissements.
     # Le SDK 2.x passe par httpx2 ; httpx reste pour les versions plus anciennes.
     # Le SDK 2.x passe par httpx2 ; httpx reste pour les versions plus anciennes.
@@ -32,7 +35,13 @@ def main() -> None:
     try:
     try:
         settings = load_settings()
         settings = load_settings()
     except ConfigError as exc:
     except ConfigError as exc:
-        print(f"Erreur de configuration : {exc}", file=sys.stderr)
+        # Aussi dans le fichier de journal quand LOG_FILE est utilisable : un redémarrage raté y laisse une trace.
+        setup_logging(exc.log_file)
+        if len(exc.errors) == 1:
+            logging.critical("Erreur de configuration : %s", exc)
+        else:
+            logging.critical("%d erreurs de configuration :\n%s", len(exc.errors),
+                             "\n".join(f"  - {error}" for error in exc.errors))
         sys.exit(1)
         sys.exit(1)
 
 
     setup_logging(settings.log_file)
     setup_logging(settings.log_file)
@@ -51,6 +60,18 @@ def main() -> None:
             "Discord (onglet Bot > Privileged Gateway Intents)."
             "Discord (onglet Bot > Privileged Gateway Intents)."
         )
         )
         sys.exit(1)
         sys.exit(1)
+    except discord.DiscordServerError as exc:
+        logging.critical("Discord semble en panne (erreur %s) : réessaie dans quelques minutes.", exc.status)
+        sys.exit(1)
+    except discord.HTTPException as exc:
+        logging.critical("Discord a refusé une requête au démarrage (erreur %s : %s).",
+                         exc.status, exc.text or "sans détail")
+        sys.exit(1)
+    # aiohttp.ClientError : pas de réseau, nom de domaine introuvable, connexion coupée ; TimeoutError : pas de réponse.
+    except (aiohttp.ClientError, TimeoutError) as exc:
+        logging.critical("Impossible de joindre Discord : vérifie la connexion Internet (%s).",
+                         str(exc) or type(exc).__name__)
+        sys.exit(1)
     # Clé API ou modèle refusés (ChatBot.setup_hook), salon dédié inutilisable (ChatBot.announce) :
     # Clé API ou modèle refusés (ChatBot.setup_hook), salon dédié inutilisable (ChatBot.announce) :
     # déjà expliqué dans le journal.
     # déjà expliqué dans le journal.
     if bot.config_error:
     if bot.config_error:

+ 27 - 0
tests/test_config.py

@@ -92,6 +92,33 @@ class MaximumTest(unittest.TestCase):
         self.assertEqual((s.max_tokens, s.pdf_max_pages, s.presence_speed), (64000, 100, 100))
         self.assertEqual((s.max_tokens, s.pdf_max_pages, s.presence_speed), (64000, 100, 100))
 
 
 
 
+class AllErrorsTest(unittest.TestCase):
+    def test_every_error_reported_at_once(self):
+        # Le scénario de l'audit : trois variables fausses, un seul redémarrage pour les voir toutes.
+        with self.assertRaises(ConfigError) as ctx:
+            settings(CHANNEL_ID="abc", THINKING="oui", PDF_MAX_PAGES="101", DISCORD_TOKEN="")
+        self.assertEqual(len(ctx.exception.errors), 4)
+        for text in ("CHANNEL_ID doit être un nombre entier", "THINKING doit valoir", "PDF_MAX_PAGES ne peut pas",
+                     "DISCORD_TOKEN est obligatoire"):
+            self.assertIn(text, str(ctx.exception))
+
+    def test_invalid_value_not_reused_in_other_checks(self):
+        # MAX_TOKENS illisible : pas de second message sur la mémoire, ni pour THINKING invalide avec EFFORT=max.
+        with self.assertRaises(ConfigError) as ctx:
+            settings(MAX_TOKENS="beaucoup", THINKING="oui", EFFORT="max")
+        self.assertEqual(len(ctx.exception.errors), 2)
+
+    def test_log_file_given_when_usable(self):
+        with tempfile.TemporaryDirectory() as folder:
+            log = Path(folder) / "bot.log"
+            with self.assertRaises(ConfigError) as ctx:
+                settings(CHANNEL_ID="abc", LOG_FILE=str(log))
+            self.assertEqual(ctx.exception.log_file, log)
+            with self.assertRaises(ConfigError) as ctx:
+                settings(LOG_FILE=str(Path(folder) / "absent" / "bot.log"))
+            self.assertIsNone(ctx.exception.log_file)
+
+
 class PresenceWeightsTest(unittest.TestCase):
 class PresenceWeightsTest(unittest.TestCase):
     ONLY_GAME = "anime=0,musique=0,pensee=0,jeu=100,en_ligne=0,ne_pas_deranger=0,absent=0"
     ONLY_GAME = "anime=0,musique=0,pensee=0,jeu=100,en_ligne=0,ne_pas_deranger=0,absent=0"