소스 검색

Regroupement des messages envoyés pendant une réponse

Dans le salon dédié, les messages qui arrivent pendant que le bot répond
attendent dans une file par salon, puis partent ensemble dans un seul appel
à Claude : une seule réponse au lieu d'une par message. ask() accepte
désormais plusieurs messages, chacun avec son image éventuelle.

Changelog 5.4.0 complété avec ce changement et le correctif du /reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 5 일 전
부모
커밋
2dff79994f
3개의 변경된 파일과 71개의 추가작업 그리고 30개의 파일을 삭제
  1. 35 24
      bot/claude_client.py
  2. 30 6
      bot/discord_bot.py
  3. 6 0
      changelog.txt

+ 35 - 24
bot/claude_client.py

@@ -36,6 +36,13 @@ MEMORY_NOTE = (
     "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
 )
 
+# Ajouté lui aussi automatiquement : les messages reçus pendant une réponse sont regroupés.
+GROUP_NOTE = (
+    "\n\nUn même message peut regrouper plusieurs messages Discord, un par ligne, envoyés "
+    "pendant que tu répondais au précédent : réponds à l'ensemble en une seule réponse, "
+    "naturellement, sans signaler qu'ils ont été regroupés."
+)
+
 # Ajouté quand WEB_SEARCH=on.
 WEB_NOTE = (
     "\n\nTu as accès à Internet (recherche web et lecture de pages). "
@@ -108,26 +115,30 @@ class ClaudeClient:
     # ------------------------------------------------------------------ appel
 
     @staticmethod
-    def build_user_content(text: str, image: ImageData | None = None) -> str | list[dict]:
-        if image is None:
-            return text
-        return [
-            {
-                "type": "image",
-                "source": {
-                    "type": "base64",
-                    "media_type": image.media_type,
-                    "data": base64.standard_b64encode(image.data).decode("ascii"),
-                },
-            },
-            {"type": "text", "text": text},
-        ]
-
-    async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> Answer:
-        """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
-        messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
-
-        system = self.personality() + MEMORY_NOTE
+    def build_user_content(parts: list[tuple[str, ImageData | None]]) -> str | list[dict]:
+        """Un ou plusieurs messages à la suite, chacun précédé de son image éventuelle."""
+        if all(image is None for _, image in parts):
+            return "\n".join(text for text, _ in parts)
+        content: list[dict] = []
+        for text, image in parts:
+            if image is not None:
+                content.append({
+                    "type": "image",
+                    "source": {
+                        "type": "base64",
+                        "media_type": image.media_type,
+                        "data": base64.standard_b64encode(image.data).decode("ascii"),
+                    },
+                })
+            content.append({"type": "text", "text": text})
+        return content
+
+    async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]]) -> Answer:
+        """Envoie l'historique + le ou les nouveaux messages (texte, image), et renvoie la réponse de Claude."""
+        messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
+        has_image = any(image is not None for _, image in parts)
+
+        system = self.personality() + MEMORY_NOTE + GROUP_NOTE
         if self.settings.web_search:
             system += WEB_NOTE
 
@@ -158,12 +169,12 @@ class ClaudeClient:
                 {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
             ]
 
-        responses = [await self._call(params, image)]
+        responses = [await self._call(params, has_image)]
         # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
         # on renvoie alors sa réponse partielle telle quelle pour qu'elle continue.
         while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
             messages = messages + [{"role": "assistant", "content": responses[-1].content}]
-            responses.append(await self._call({**params, "messages": messages}, image))
+            responses.append(await self._call({**params, "messages": messages}, has_image))
 
         text = self._final_text(responses)
         web_used = self._log_usage(responses)
@@ -180,7 +191,7 @@ class ClaudeClient:
             raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
         return Answer(text, web_used)
 
-    async def _call(self, params: dict, image: ImageData | None):
+    async def _call(self, params: dict, has_image: bool):
         try:
             return await self._client.messages.create(**params)
         except anthropic.RateLimitError:
@@ -194,7 +205,7 @@ class ClaudeClient:
             raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
         except anthropic.BadRequestError as exc:
             logger.error("Requête refusée par l'API : %s", exc)
-            if image is not None:
+            if has_image:
                 raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
             raise ClaudeError("Ta demande a été refusée par l'API, essaie de la reformuler.")
         except anthropic.AuthenticationError:

+ 30 - 6
bot/discord_bot.py

@@ -6,6 +6,7 @@ import asyncio
 import logging
 import random
 import re
+from dataclasses import dataclass
 
 import discord
 from discord import app_commands
@@ -49,6 +50,15 @@ def text_file_marker(name: str) -> str:
     return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
 
 
+@dataclass
+class PendingMessage:
+    """Message du salon dédié, prêt à être envoyé à Claude."""
+    message: discord.Message
+    user_text: str    # texte envoyé à Claude
+    stored_text: str  # texte conservé en mémoire
+    image: ImageData | None
+
+
 class ChatBot(commands.Bot):
     def __init__(self, settings: Settings):
         intents = discord.Intents.default()
@@ -63,6 +73,7 @@ class ChatBot(commands.Bot):
         self.settings = settings
         self.claude = ClaudeClient(settings)
         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
 
     # ------------------------------------------------------------ cycle de vie
@@ -151,22 +162,35 @@ class ChatBot(commands.Bot):
             user_text += text_file_prompt(*text_file)
             stored_text += f" {text_file_marker(text_file[0])}"
 
-        # Un seul appel à la fois par salon : l'historique reste cohérent.
+        # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
+        # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
+        pending = self._pending.setdefault(message.channel.id, [])
+        pending.append(PendingMessage(message, user_text, stored_text, image))
         async with self.history.lock(message.channel.id):
+            if not pending:
+                return  # déjà parti avec un message précédent
+            batch = sorted(pending, key=lambda p: p.message.id)
+            pending.clear()
+            last = batch[-1].message
+            if len(batch) > 1:
+                logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), message.channel)
             async with message.channel.typing():
                 try:
-                    answer = await self.claude.ask(self.history.get(message.channel.id), user_text, image or None)
+                    answer = await self.claude.ask(
+                        self.history.get(message.channel.id), [(p.user_text, p.image) for p in batch]
+                    )
                 except ClaudeError as exc:
-                    await self.safe_reply(message, exc.user_message)
+                    await self.safe_reply(last, exc.user_message)
                     return
                 except Exception:
                     logger.exception("Erreur inattendue pendant le traitement d'un message")
-                    await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
+                    await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
                     return
+            stored_text = "\n".join(p.stored_text for p in batch)
             if answer.web_used:
                 stored_text += f" {WEB_MARKER}"
             await self.history.add_exchange(message.channel.id, stored_text, answer.text)
-        await self.send_answer(message, answer.text)
+        await self.send_answer(last, answer.text)
 
     async def handle_mention(self, message: discord.Message) -> None:
         """Hors du salon dédié : répond à une mention en lisant les messages récents."""
@@ -209,7 +233,7 @@ class ChatBot(commands.Bot):
 
         async with message.channel.typing():
             try:
-                answer = await self.claude.ask([], prompt, image)
+                answer = await self.claude.ask([], [(prompt, image)])
             except ClaudeError as exc:
                 await self.safe_reply(message, exc.user_message)
                 return

+ 6 - 0
changelog.txt

@@ -20,11 +20,17 @@ Nouveautés
 - Discussion entre bots : le bot répond aux autres bots quand ils le
   mentionnent ou répondent à l'un de ses messages ; le reste de ce qu'ils
   postent est toujours ignoré.
+- Messages regroupés : dans le salon dédié, les messages envoyés pendant que
+  le bot répond ne reçoivent plus chacun leur réponse ; ils lui sont transmis
+  ensemble (avec leurs images) et il y répond en une seule fois.
 
 Corrections
 - Les messages système (arrivée d'un membre, message épinglé, boost, fil
   créé…) et les messages sans rien à lire (sondages…) ne déclenchent plus de
   réponse payante de Claude.
+- /reset vidait mal la mémoire s'il était lancé pendant une réponse : l'échange
+  en cours y était réécrit juste après. Le reset attend désormais la fin de la
+  réponse avant de tout effacer.
 
 
 Version 5.3.0 — 29/10/2026