Kaynağa Gözat

Une rafale de mentions ne lance plus un appel par message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 15 saat önce
ebeveyn
işleme
102ebdfc12
4 değiştirilmiş dosya ile 188 ekleme ve 38 silme
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 63 37
      bot/discord_bot.py
  4. 122 0
      tests/test_mentions.py

+ 2 - 0
CHANGELOG.md

@@ -20,6 +20,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **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.
 
+- **Une rafale de mentions ne lance plus un appel par message.** Hors du salon dédié, chaque mention lançait son propre appel à Claude, en parallèle : 10 « @Rika ? » envoyés en 5 secondes dans #général donnaient 10 appels, chacun avec ses 20 messages de contexte, puis 10 réponses à la suite. Les mentions fonctionnent maintenant comme le salon dédié : une seule réponse à la fois par salon, et les mentions arrivées pendant qu'elle écrit partent ensemble dans l'appel suivant, avec leurs pièces jointes (deux appels au lieu de dix). Si une pièce jointe est refusée, seule cette mention est retirée. Les mentions reçues pendant une absence reçoivent aussi une seule réponse au lieu d'une par message.
+
 - **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

@@ -9,7 +9,7 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 ### Conversation
 
 - **Salon dédié** : le bot répond à tous les messages du salon choisi (`CHANNEL_ID`). Il se souvient des derniers échanges, même après un redémarrage. Les messages envoyés pendant qu'il écrit reçoivent une seule réponse groupée, comme le ferait quelqu'un qui lit plusieurs messages d'un coup.
-- **Mentions ailleurs** : dans les autres salons, il répond quand on le mentionne, en lisant les derniers messages pour comprendre la discussion (sans mémoire d'un appel à l'autre).
+- **Mentions ailleurs** : dans les autres salons, il répond quand on le mentionne, en lisant les derniers messages pour comprendre la discussion (sans mémoire d'un appel à l'autre). Comme dans son salon, les mentions reçues dans un salon pendant qu'il écrit reçoivent une seule réponse groupée.
 - **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.

+ 63 - 37
bot/discord_bot.py

@@ -548,26 +548,38 @@ class ChatBot(commands.Bot):
         await self.send_answer(last, answer.text)
 
     async def handle_mention(self, message: discord.Message) -> None:
+        # Comme dans le salon dédié : les mentions arrivées pendant une réponse attendent dans la file,
+        # puis partent ensemble dans un seul appel.
+        self._mentions.setdefault(message.channel.id, []).append(message)
         if not self.available() or message.channel.id in self._waiters:
-            self._mentions.setdefault(message.channel.id, []).append(message)
             self.answer_later(message, self.answer_mentions)
             return
-        await self.answer_mention(message)
+        await self.answer_mentions(message.channel)
 
     async def answer_mentions(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
-        """Répond, une par une, aux mentions reçues pendant une absence (avec « Répondre »)."""
-        for message in sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id):
+        """Répond en une fois aux mentions en attente d'un salon (hors salon dédié)."""
+        # Un seul appel à la fois par salon, envoi compris : la réponse suivante voit la précédente dans le contexte.
+        async with self.history.lock(channel.id):
+            batch = sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id)
+            if not batch:
+                return  # déjà parties avec une mention précédente
+            if len(batch) > 1:
+                logger.info("%s mentions regroupées en une seule réponse dans #%s", len(batch), channel)
             try:
-                await self.answer_mention(message, late)
+                await self.answer_mention(batch, late)
             finally:
-                self.forget_waiting(message)
+                self.forget_waiting(*batch)
 
-    async def answer_mention(self, message: discord.Message, late: Status | None = None) -> None:
-        """Hors du salon dédié : répond à une mention en lisant les messages récents."""
+    async def answer_mention(self, mentions: list[discord.Message], late: Status | None = None) -> None:
+        """Hors du salon dédié : répond aux mentions d'un même salon en lisant les messages récents."""
+        channel = mentions[-1].channel
+        mention_ids = {m.id for m in mentions}
         lines: list[str] = []
         if self.settings.context_message_limit > 0:
             try:
-                async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
+                async for msg in channel.history(limit=self.settings.context_message_limit, before=mentions[-1]):
+                    if msg.id in mention_ids:
+                        continue  # décrite plus bas, avec ses pièces jointes
                     content = short_emojis(msg.clean_content.strip())
                     if msg.attachments:
                         content = (content + " [pièce jointe]").strip()
@@ -576,9 +588,46 @@ class ChatBot(commands.Bot):
                     if content:
                         lines.append(f"{author_name(msg.author)} : {content}")
             except discord.Forbidden:
-                logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
+                logger.warning("Pas la permission de lire l'historique de #%s.", channel)
         lines.reverse()
 
+        answered, parts = [], []
+        for message in mentions:
+            if part := await self.mention_part(message):
+                answered.append(message)
+                parts.append(part)
+        if not parts:
+            return  # pièces jointes refusées, les auteurs ont été prévenus
+        last = answered[-1]
+
+        channel_name = f"#{channel.name}" if hasattr(channel, "name") else "un message privé"
+        header = []
+        if late:
+            header.append(self.presence.late_note(late, several=len(parts) > 1))
+        if note := self.presence_note():
+            header.append(note)
+        if lines:
+            header.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
+        text, images = parts[0]
+        parts[0] = ("\n\n".join([*header, text]), images)
+
+        async with typing(channel):
+            try:
+                answer = await self.claude.ask([], parts, emojis=emoji_names(last.guild), cache_history=False,
+                                               member_notes=await self.member_notes(answered))
+            except ClaudeError as exc:
+                await self.safe_reply(last, exc.user_message)
+                return
+            except Exception:
+                logger.exception("Erreur inattendue pendant le traitement d'une mention")
+                await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
+                return
+        # En retard, la réponse cite la dernière mention : on sait à quoi elle répond.
+        await self.send_answer(last, answer.text, reply=late is not None)
+
+    async def mention_part(self, message: discord.Message) -> tuple[str, list[ImageData]] | None:
+        """Une mention telle qu'elle est transmise à Claude, ou None si une pièce jointe est refusée
+        (l'auteur a été prévenu)."""
         # Seuls les MAX_IMAGES premières images (à défaut, le premier PDF) et le premier fichier texte
         # de la mention sont lus (les autres pièces jointes sont ignorées). Les images du message cité
         # ne servent que s'il n'y a ni image ni PDF.
@@ -591,24 +640,14 @@ class ChatBot(commands.Bot):
                 images = [await self.download_pdf(pdfs[0])] if pdfs else []
         except (ImageError, PdfError) as exc:
             await self.safe_reply(message, exc.user_message)
-            return
+            return None
         reply, reply_images = await self.read_reply(message, want_images=not images)
         images = images or reply_images
 
         bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
         text = short_emojis(message.clean_content.replace(bot_name, "").strip()) or "(simple mention, sans texte)"
-        channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
-
-        parts = []
-        if late:
-            parts.append(self.presence.late_note(late, several=False))
-        if note := self.presence_note():
-            parts.append(note)
-        if lines:
-            parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
         reply_part = f" ({reply})" if reply else ""
-        parts.append(f"{self.timestamp(message)}, {author_name(message.author)} te mentionne{reply_part} et écrit : {text}")
-        prompt = "\n\n".join(parts)
+        prompt = f"{self.timestamp(message)}, {author_name(message.author)} te mentionne{reply_part} et écrit : {text}"
 
         text_files = [a for a in message.attachments if looks_like_text(a)]
         if text_files:
@@ -616,21 +655,8 @@ class ChatBot(commands.Bot):
                 prompt += text_file_prompt(*await self.download_text_file(text_files[0]))
             except TextFileError as exc:
                 await self.safe_reply(message, exc.user_message)
-                return
-
-        async with typing(message.channel):
-            try:
-                answer = await self.claude.ask([], [(prompt, images)], emojis=emoji_names(message.guild),
-                                               cache_history=False, member_notes=await self.member_notes([message]))
-            except ClaudeError as exc:
-                await self.safe_reply(message, exc.user_message)
-                return
-            except Exception:
-                logger.exception("Erreur inattendue pendant le traitement d'une mention")
-                await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
-                return
-        # En retard, la réponse cite la mention : on sait à quoi elle répond.
-        await self.send_answer(message, answer.text, reply=late is not None)
+                return None
+        return prompt, images
 
     # ------------------------------------------------------ réponses différées
 

+ 122 - 0
tests/test_mentions.py

@@ -0,0 +1,122 @@
+"""Tests des mentions hors du salon dédié : une rafale ne lance pas un appel par message.
+Lancer depuis la racine du projet : python -m unittest"""
+
+import asyncio
+import contextlib
+import unittest
+from unittest import mock
+
+from bot.discord_bot import ChatBot
+
+CHANNEL_ID = 20
+
+
+def fake_bot() -> mock.Mock:
+    bot = mock.Mock()
+    bot._mentions = {}
+    bot._waiters = {}
+    bot.available.return_value = True
+    lock = asyncio.Lock()
+    bot.history.lock.return_value = lock
+    bot.answer_mentions = lambda channel, late=None: ChatBot.answer_mentions(bot, channel, late)
+    bot.answer_mention = lambda mentions, late=None: ChatBot.answer_mention(bot, mentions, late)
+    bot.mention_part = lambda message: ChatBot.mention_part(bot, message)
+    bot.settings.context_message_limit = 20
+    bot.settings.pdf_max_pages = 100
+    bot.presence_note.return_value = None
+    bot.timestamp = lambda message: "03/11/2026 18:00"
+    bot.read_reply = mock.AsyncMock(return_value=(None, []))
+    bot.member_notes = mock.AsyncMock(return_value="")
+    bot.claude.ask = mock.AsyncMock(return_value=mock.Mock(text="Coucou"))
+    bot.send_answer = mock.AsyncMock()
+    bot.safe_reply = mock.AsyncMock()
+    return bot
+
+
+def fake_mention(message_id: int, text: str, channel: mock.Mock) -> mock.Mock:
+    message = mock.Mock(id=message_id, clean_content=f"@Rika {text}", attachments=[], stickers=[])
+    message.channel = channel
+    message.guild.me.display_name = "Rika"
+    message.author.display_name = message.author.name = f"membre{message_id}"
+    return message
+
+
+def fake_channel(history: list) -> mock.Mock:
+    channel = mock.Mock(id=CHANNEL_ID)
+    channel.name = "général"
+
+    async def read(limit, before):
+        for msg in reversed([m for m in history if m.id < before.id][-limit:]):
+            yield msg
+    channel.history = read
+    return channel
+
+
+@mock.patch("bot.discord_bot.typing", lambda channel: contextlib.nullcontext())
+@mock.patch("bot.discord_bot.emoji_names", lambda guild: [])
+class MentionTest(unittest.IsolatedAsyncioTestCase):
+    async def test_burst_grouped(self):
+        # 10 mentions en rafale : la première part seule, les 9 autres attendent puis partent ensemble.
+        bot = fake_bot()
+        calls, release = [], asyncio.Event()
+
+        async def answer(mentions, late=None):
+            calls.append([m.id for m in mentions])
+            await release.wait()
+        bot.answer_mention = answer
+        channel = mock.Mock(id=CHANNEL_ID)
+        tasks = [asyncio.create_task(ChatBot.handle_mention(bot, mock.Mock(id=1, channel=channel)))]
+        await asyncio.sleep(0)
+        tasks += [asyncio.create_task(ChatBot.handle_mention(bot, mock.Mock(id=i, channel=channel)))
+                  for i in range(2, 11)]
+        await asyncio.sleep(0)
+        release.set()
+        await asyncio.gather(*tasks)
+        self.assertEqual(calls, [[1], list(range(2, 11))])
+        self.assertEqual(bot._mentions, {})
+
+    async def test_one_call_for_grouped_mentions(self):
+        bot = fake_bot()
+        history = []
+        channel = fake_channel(history)
+        history += [mock.Mock(id=1, clean_content="salut tout le monde", attachments=[], stickers=[]),
+                    fake_mention(2, "ça va ?", channel),
+                    mock.Mock(id=3, clean_content="il dort", attachments=[], stickers=[]),
+                    fake_mention(4, "réveille-toi", channel)]
+        for msg in history:
+            msg.author.display_name = msg.author.name = f"membre{msg.id}"
+        bot._mentions[CHANNEL_ID] = [history[3], history[1]]
+        await ChatBot.answer_mentions(bot, channel)
+
+        bot.claude.ask.assert_awaited_once()
+        parts = bot.claude.ask.await_args.args[1]
+        self.assertEqual(len(parts), 2)  # une partie par mention, dans l'ordre
+        context, second = parts[0][0], parts[1][0]
+        self.assertIn("salut tout le monde", context)
+        self.assertIn("il dort", context)
+        self.assertNotIn("membre2 : ", context)  # la mention n'est pas recopiée dans le contexte
+        self.assertTrue(context.endswith("membre2 te mentionne et écrit : ça va ?"))
+        self.assertEqual(second, "03/11/2026 18:00, membre4 te mentionne et écrit : réveille-toi")
+        bot.member_notes.assert_awaited_once_with([history[1], history[3]])
+        bot.send_answer.assert_awaited_once_with(history[3], "Coucou", reply=False)
+
+    async def test_refused_attachment_drops_only_that_mention(self):
+        bot = fake_bot()
+        channel = fake_channel([])
+        first, second = fake_mention(1, "regarde", channel), fake_mention(2, "et ça ?", channel)
+        bot.mention_part = mock.AsyncMock(side_effect=[None, ("texte", [])])
+        await ChatBot.answer_mention(bot, [first, second])
+        self.assertEqual(bot.claude.ask.await_args.args[1], [("texte", [])])
+        bot.member_notes.assert_awaited_once_with([second])
+
+    async def test_all_refused_no_call(self):
+        bot = fake_bot()
+        channel = fake_channel([])
+        bot.mention_part = mock.AsyncMock(return_value=None)
+        await ChatBot.answer_mention(bot, [fake_mention(1, "regarde", channel)])
+        bot.claude.ask.assert_not_awaited()
+        bot.send_answer.assert_not_awaited()
+
+
+if __name__ == "__main__":
+    unittest.main()