Переглянути джерело

Deux bots ne tournent plus en boucle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 9 годин тому
батько
коміт
ca23c7ae12
4 змінених файлів з 117 додано та 3 видалено
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 36 2
      bot/discord_bot.py
  4. 78 0
      tests/test_bot_streaks.py

+ 2 - 0
CHANGELOG.md

@@ -18,6 +18,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 ### Corrections
 
+- **Deux bots ne tournent plus en boucle.** Rika répond aux autres bots quand ils la mentionnent ou répondent à l'un de ses messages, sans aucune limite : face à un bot qui répond à tout avec « Répondre » (et donc la pinge), chaque réponse déclenchait la suivante, avec un appel à Claude toutes les quelques secondes jusqu'à ce que quelqu'un coupe un des bots. Elle ne répond plus à plus de 5 messages de bots d'affilée dans un salon ; le compte repart de zéro au premier message d'un membre dans ce salon, ou après 10 minutes sans message de bot. Le journal le signale une fois par série.
+
 - **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.

+ 1 - 1
README.md

@@ -13,7 +13,7 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 - **Messages cités et transférés** : avec « Répondre », il lit le message cité, même ancien, et ses images. Il lit aussi les messages transférés dans son salon.
 - **Messages modifiés ou supprimés** : sa mémoire suit. Un message supprimé est oublié, un message modifié prend son nouveau texte.
 - **Recherche web** (en option, `WEB_SEARCH=on`) : il peut chercher sur Internet et lire un lien partagé quand la question porte sur l'actualité ou des informations précises.
-- **Autres bots** : il les ignore, sauf quand ils le mentionnent ou répondent à l'un de ses messages : deux bots peuvent ainsi discuter ensemble.
+- **Autres bots** : il les ignore, sauf quand ils le mentionnent ou répondent à l'un de ses messages : deux bots peuvent ainsi discuter ensemble. Pour éviter une boucle sans fin (et un appel à Claude à chaque tour), il ne répond pas à plus de 5 messages de bots d'affilée dans un salon ; le compte repart de zéro au premier message d'un membre, ou après 10 minutes sans message de bot.
 
 ### Pièces jointes
 

+ 36 - 2
bot/discord_bot.py

@@ -47,6 +47,10 @@ MAX_IMAGES = 4
 USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
 # En passant absent ou en « Ne pas déranger », le bot prévient son salon si on y a parlé depuis moins de :
 DEPARTURE_WINDOW = timedelta(minutes=3)
+# Messages de bots auxquels le bot répond d'affilée dans un salon, sans message d'un membre entre deux.
+MAX_BOT_REPLIES = 5
+# Passé ce délai sans message d'un bot dans le salon, le compte repart de zéro.
+BOT_STREAK_RESET = timedelta(minutes=10)
 
 
 def author_name(user: discord.User | discord.Member) -> str:
@@ -143,6 +147,26 @@ class PendingMessage:
         return render_line(line) + self.extra
 
 
+class BotStreaks:
+    """Messages de bots reçus d'affilée, par salon. Deux bots qui se répondent tourneraient sans fin,
+    avec un appel à Claude à chaque tour : au-delà de MAX_BOT_REPLIES, le bot ne leur répond plus."""
+
+    def __init__(self) -> None:
+        self._streaks: dict[int, tuple[int, datetime]] = {}  # salon : (messages, date du dernier)
+
+    def human_spoke(self, channel_id: int) -> None:
+        self._streaks.pop(channel_id, None)
+
+    def count(self, channel_id: int, when: datetime) -> int:
+        """Compte un message de bot adressé au bot et renvoie son rang dans la série (1 pour le premier).
+        Un message ignoré compte aussi : un bot qui relance sans cesse reste ignoré."""
+        count, last = self._streaks.get(channel_id, (0, when))
+        if when - last > BOT_STREAK_RESET:
+            count = 0
+        self._streaks[channel_id] = (count + 1, when)
+        return count + 1
+
+
 class HomeTree(app_commands.CommandTree):
     """Commandes slash acceptées seulement sur le serveur du bot (voir ChatBot.home_guild_id) : un
     administrateur d'un autre serveur où le bot a été invité ne peut ni lire ni modifier la mémoire."""
@@ -184,6 +208,7 @@ class ChatBot(commands.Bot):
         self.claude = ClaudeClient(settings, self.memory)
         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._bot_streaks = BotStreaks()
         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
@@ -356,8 +381,17 @@ class ChatBot(commands.Bot):
         if message.type not in USER_MESSAGE_TYPES:
             return  # message système
         # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
-        # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble.
-        if message.author.bot and self.user not in message.mentions and not await self.replies_to_me(message):
+        # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble, mais pas sans fin.
+        if not message.author.bot:
+            self._bot_streaks.human_spoke(message.channel.id)
+        elif self.user not in message.mentions and not await self.replies_to_me(message):
+            return
+        elif (rank := self._bot_streaks.count(message.channel.id, message.created_at)) > MAX_BOT_REPLIES:
+            if rank == MAX_BOT_REPLIES + 1:
+                logger.warning("#%s : %s réponses à des bots d'affilée (dernier : %s). Le bot ne leur répond plus "
+                               "jusqu'au prochain message d'un membre, ou après %s minutes sans message de bot.",
+                               message.channel, MAX_BOT_REPLIES, message.author,
+                               BOT_STREAK_RESET.seconds // 60)
             return
 
         in_main_channel = message.channel.id == self.settings.channel_id

+ 78 - 0
tests/test_bot_streaks.py

@@ -0,0 +1,78 @@
+"""Tests des conversations avec d'autres bots : pas de boucle sans fin. Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+from datetime import datetime, timedelta, timezone
+from unittest import mock
+
+import discord
+
+from bot.discord_bot import BOT_STREAK_RESET, MAX_BOT_REPLIES, BotStreaks, ChatBot
+
+HOME = 1000
+CHANNEL_ID = 10
+START = datetime(2026, 11, 3, 18, 0, tzinfo=timezone.utc)
+
+
+class BotStreaksTest(unittest.TestCase):
+    def test_counts_per_channel(self):
+        streaks = BotStreaks()
+        self.assertEqual(streaks.count(1, START), 1)
+        self.assertEqual(streaks.count(1, START), 2)
+        self.assertEqual(streaks.count(2, START), 1)
+
+    def test_human_resets(self):
+        streaks = BotStreaks()
+        streaks.count(1, START)
+        streaks.human_spoke(1)
+        self.assertEqual(streaks.count(1, START), 1)
+
+    def test_quiet_channel_resets(self):
+        streaks = BotStreaks()
+        streaks.count(1, START)
+        self.assertEqual(streaks.count(1, START + BOT_STREAK_RESET), 2)
+        self.assertEqual(streaks.count(1, START + 2 * BOT_STREAK_RESET + timedelta(seconds=1)), 1)
+
+
+class OnMessageTest(unittest.IsolatedAsyncioTestCase):
+    def setUp(self):
+        self.bot = mock.Mock()
+        self.bot.settings.channel_id = CHANNEL_ID
+        self.bot.home_guild_id.return_value = HOME
+        self.bot._bot_streaks = BotStreaks()
+        self.bot.handle_main_channel = mock.AsyncMock()
+        self.bot.register_members = mock.AsyncMock()
+
+    async def receive(self, from_bot: bool, when: datetime = START) -> None:
+        message = mock.Mock(guild=mock.Mock(id=HOME), webhook_id=None, type=discord.MessageType.reply,
+                            created_at=when)
+        message.channel.id = CHANNEL_ID
+        message.author.bot = from_bot
+        message.mentions = [self.bot.user]  # « Répondre » avec ping
+        await ChatBot.on_message(self.bot, message)
+
+    async def test_bot_loop_stopped(self):
+        # Un bot qui répond à chacun de ses messages : le bot s'arrête après MAX_BOT_REPLIES réponses.
+        with self.assertLogs("chatbot.discord", "WARNING") as logs:
+            for _ in range(MAX_BOT_REPLIES + 3):
+                await self.receive(from_bot=True)
+        self.assertEqual(self.bot.handle_main_channel.await_count, MAX_BOT_REPLIES)
+        self.assertEqual(len(logs.records), 1)  # un seul avertissement par série
+
+    async def test_member_message_restarts(self):
+        with self.assertLogs("chatbot.discord", "WARNING"):
+            for _ in range(MAX_BOT_REPLIES + 1):
+                await self.receive(from_bot=True)
+        await self.receive(from_bot=False)
+        await self.receive(from_bot=True)
+        self.assertEqual(self.bot.handle_main_channel.await_count, MAX_BOT_REPLIES + 2)
+
+    async def test_quiet_channel_restarts(self):
+        with self.assertLogs("chatbot.discord", "WARNING"):
+            for _ in range(MAX_BOT_REPLIES + 1):
+                await self.receive(from_bot=True)
+        await self.receive(from_bot=True, when=START + BOT_STREAK_RESET + timedelta(seconds=1))
+        self.assertEqual(self.bot.handle_main_channel.await_count, MAX_BOT_REPLIES + 1)
+
+
+if __name__ == "__main__":
+    unittest.main()