Explorar o código

CHANNEL_ID faux : le bot s'arrête en expliquant quoi corriger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet hai 15 horas
pai
achega
3b5b497dea
Modificáronse 5 ficheiros con 138 adicións e 7 borrados
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 50 6
      bot/discord_bot.py
  4. 2 0
      main.py
  5. 83 0
      tests/test_channel.py

+ 2 - 0
CHANGELOG.md

@@ -32,6 +32,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Un dossier absent est signalé au démarrage.** Si le dossier de `LOG_FILE`, de `HISTORY_FILE` ou de `MEMORY_DIR` n'existe pas (volume Docker mal monté, faute de frappe) ou n'est pas accessible en écriture, le bot refuse de démarrer avec un message « Erreur de configuration » clair, au lieu d'une trace Python (`LOG_FILE`) ou de tourner sans jamais rien sauvegarder (`HISTORY_FILE`). L'historique est aussi enregistré comme les autres fichiers : envoyé jusqu'au disque avant de remplacer l'ancien, pour ne pas finir vide après une coupure de courant.
 
+- **Un `CHANNEL_ID` faux arrête le bot.** Si le salon était introuvable (ID du serveur ou d'un autre salon collé par erreur), le bot restait connecté sans répondre nulle part : sur OpenShift, le pod paraissait « Running » alors que Rika était muette. Un ID de catégorie ou de forum faisait planter le message de démarrage, et une permission manquante n'était pas signalée. Le bot vérifie maintenant au démarrage que `CHANNEL_ID` désigne un salon textuel où il peut voir, écrire et lire l'historique, et sinon s'arrête avec un message « Erreur de configuration » qui dit quoi corriger. Si un serveur est seulement indisponible chez Discord (panne), il patiente et s'annonce dès son retour.
+
 ---
 
 ## Version 5.11

+ 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
    ```
-   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, intent non activé), il s'arrête en expliquant quoi corriger.
+   Il annonce son arrivée dans son salon avec sa version. En cas d'erreur de configuration (variable manquante, token invalide, 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.
 
 ## Configuration
 

+ 50 - 6
bot/discord_bot.py

@@ -66,6 +66,35 @@ def looks_like_pdf(attachment: discord.Attachment) -> bool:
     return attachment.content_type == "application/pdf" or attachment.filename.lower().endswith(PDF_EXTENSIONS)
 
 
+def channel_problem(client: discord.Client, channel_id: int) -> str | None:
+    """Pourquoi le salon dédié (CHANNEL_ID) est inutilisable, ou None s'il convient."""
+    channel = client.get_channel(channel_id)
+    if channel is None:
+        if client.get_guild(channel_id):
+            return (f"CHANNEL_ID ({channel_id}) est l'identifiant du serveur, pas du salon : fais un clic droit "
+                    "sur le salon > Copier l'identifiant du salon.")
+        return (f"Salon {channel_id} introuvable : vérifie CHANNEL_ID (clic droit sur le salon > Copier l'identifiant "
+                "du salon, avec le mode développeur de Discord) et que le bot est bien invité sur ce serveur.")
+    guild = getattr(channel, "guild", None)
+    if guild is None or not isinstance(channel, discord.abc.Messageable):
+        kind = ("une catégorie" if isinstance(channel, discord.CategoryChannel)
+                else "un forum" if isinstance(channel, discord.ForumChannel)
+                else "une conversation privée" if guild is None else "un salon où l'on ne peut pas écrire")
+        return f"CHANNEL_ID ({channel_id}) désigne {kind} : donne l'identifiant d'un salon textuel."
+    permissions = channel.permissions_for(guild.me)
+    in_thread = isinstance(channel, discord.Thread)
+    needed = (("view_channel", "Voir les salons"),
+              ("send_messages_in_threads" if in_thread else "send_messages",
+               "Envoyer des messages dans les fils" if in_thread else "Envoyer des messages"),
+              ("read_message_history", "Voir les anciens messages"))
+    if missing := [label for name, label in needed if not getattr(permissions, name)]:
+        names = ", ".join(f"« {label} »" for label in missing)
+        plural = "les permissions" if len(missing) > 1 else "la permission"
+        return (f"Il manque au bot {plural} {names} dans #{channel} : ajoute-{'les' if len(missing) > 1 else 'la'} "
+                "à son rôle ou dans les permissions du salon.")
+    return None
+
+
 @contextlib.asynccontextmanager
 async def typing(channel: discord.abc.Messageable):
     """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
@@ -156,6 +185,7 @@ class ChatBot(commands.Bot):
         self.history = ConversationHistory(settings.history_file, settings.max_history_length)
         self._pending: dict[int, list[PendingMessage]] = {}  # messages en attente de réponse, par salon
         self._announced = False
+        self.config_error: str | None = None  # salon dédié inutilisable : main.py sort en erreur
         self._memory_warning = memory_warning(settings) if self.memory else None
         self.presence =PresenceManager(self, self.claude, settings) if settings.presence else None
         # Absent ou en « Ne pas déranger » : réponses différées, par salon.
@@ -233,16 +263,30 @@ class ChatBot(commands.Bot):
         logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
         if self.presence:
             await self.presence.apply()  # une reconnexion complète efface le statut
-        if self._announced:
-            return  # simple reconnexion : on ne reposte pas le message de démarrage
+        if not self._announced:  # simple reconnexion : on ne reposte pas le message de démarrage
+            await self.announce()
+
+    async def on_guild_available(self, guild: discord.Guild) -> None:
+        # Serveur du salon indisponible au démarrage (panne Discord), revenu depuis : le bot s'annonce enfin.
+        if self.is_ready() and not self._announced and self.home_guild_id() == guild.id:
+            await self.announce()
+
+    async def announce(self) -> None:
+        """Vérifie le salon dédié, puis y poste le message de démarrage. Un salon inutilisable arrête le bot."""
+        if problem := channel_problem(self, self.settings.channel_id):
+            if self.get_channel(self.settings.channel_id) is None and any(g.unavailable for g in self.guilds):
+                # Le salon est peut-être sur ce serveur : Discord n'en envoie le contenu qu'à son retour.
+                logger.warning("Salon %s introuvable pour l'instant : un serveur du bot est indisponible chez "
+                               "Discord. Le bot s'annoncera dès son retour.", self.settings.channel_id)
+                return
+            logger.critical("Erreur de configuration : %s", problem)
+            self.config_error = problem
+            await self.close()
+            return
         self._announced = True
         self._replay_task = asyncio.create_task(self.replay_waiting())  # référence gardée : pas de ramasse-miettes
 
         channel = self.get_channel(self.settings.channel_id)
-        if channel is None:
-            logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot. "
-                         "En attendant, le bot ne répond nulle part.", self.settings.channel_id)
-            return
         if others := [guild for guild in self.guilds if guild.id != self.home_guild_id()]:
             logger.warning("Le bot est aussi sur %d autre(s) serveur(s), où il ne répond pas : %s. Retire-le de ces "
                            "serveurs, et décoche Public Bot dans le portail Discord (voir le README).", len(others),

+ 2 - 0
main.py

@@ -51,6 +51,8 @@ def main() -> None:
             "Discord (onglet Bot > Privileged Gateway Intents)."
         )
         sys.exit(1)
+    if bot.config_error:  # salon dédié inutilisable (voir ChatBot.announce), déjà expliqué dans le journal
+        sys.exit(1)
 
 
 if __name__ == "__main__":

+ 83 - 0
tests/test_channel.py

@@ -0,0 +1,83 @@
+"""Tests de la vérification du salon dédié (CHANNEL_ID). Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+from unittest import mock
+
+import discord
+
+from bot.discord_bot import ChatBot, channel_problem
+
+CHANNEL_ID, GUILD_ID = 10, 1000
+
+
+def fake_client(channel=None, guild=None) -> mock.Mock:
+    client = mock.Mock()
+    client.get_channel.return_value = channel
+    client.get_guild.return_value = guild
+    return client
+
+
+def fake_channel(cls=discord.TextChannel, **permissions) -> mock.Mock:
+    channel = mock.Mock(spec=cls)
+    channel.guild = mock.Mock(id=GUILD_ID)
+    channel.__str__ = lambda self: "salon"
+    allowed = {"view_channel": True, "send_messages": True, "send_messages_in_threads": True,
+               "read_message_history": True} | permissions
+    channel.permissions_for.return_value = mock.Mock(**allowed)
+    return channel
+
+
+class ChannelProblemTest(unittest.TestCase):
+    def test_usable_channel(self):
+        self.assertIsNone(channel_problem(fake_client(fake_channel()), CHANNEL_ID))
+
+    def test_unknown_channel(self):
+        self.assertIn("introuvable", channel_problem(fake_client(), CHANNEL_ID))
+
+    def test_server_id_instead_of_channel(self):
+        self.assertIn("identifiant du serveur", channel_problem(fake_client(guild=mock.Mock()), CHANNEL_ID))
+
+    def test_category_and_forum_refused(self):
+        self.assertIn("une catégorie", channel_problem(fake_client(fake_channel(discord.CategoryChannel)), CHANNEL_ID))
+        self.assertIn("un forum", channel_problem(fake_client(fake_channel(discord.ForumChannel)), CHANNEL_ID))
+
+    def test_missing_permissions(self):
+        problem = channel_problem(fake_client(fake_channel(send_messages=False)), CHANNEL_ID)
+        self.assertIn("la permission « Envoyer des messages »", problem)
+        problem = channel_problem(fake_client(fake_channel(view_channel=False, read_message_history=False)), CHANNEL_ID)
+        self.assertIn("les permissions « Voir les salons », « Voir les anciens messages »", problem)
+
+    def test_thread_uses_thread_permission(self):
+        thread = fake_channel(discord.Thread, send_messages=False)
+        self.assertIsNone(channel_problem(fake_client(thread), CHANNEL_ID))
+        thread = fake_channel(discord.Thread, send_messages_in_threads=False)
+        self.assertIn("Envoyer des messages dans les fils", channel_problem(fake_client(thread), CHANNEL_ID))
+
+
+class AnnounceTest(unittest.IsolatedAsyncioTestCase):
+    def fake_bot(self, *, unavailable: bool) -> mock.Mock:
+        bot = mock.Mock(_announced=False, config_error=None)
+        bot.settings.channel_id = CHANNEL_ID
+        bot.get_channel.return_value = None
+        bot.get_guild.return_value = None
+        bot.guilds = [mock.Mock(unavailable=unavailable)]
+        bot.close = mock.AsyncMock()
+        return bot
+
+    async def test_wrong_channel_stops_bot(self):
+        bot = self.fake_bot(unavailable=False)
+        await ChatBot.announce(bot)
+        self.assertIn("introuvable", bot.config_error)
+        bot.close.assert_awaited_once()
+
+    async def test_unavailable_server_waits(self):
+        # Panne Discord : le salon n'est pas encore connu, mais le bot reste allumé.
+        bot = self.fake_bot(unavailable=True)
+        await ChatBot.announce(bot)
+        self.assertIsNone(bot.config_error)
+        self.assertFalse(bot._announced)
+        bot.close.assert_not_awaited()
+
+
+if __name__ == "__main__":
+    unittest.main()