Quellcode durchsuchen

Images : jusqu'à 4 images par message

Rika voit toutes les images d'un message (et d'un message cité ou
transféré), numérotées dans l'ordre. Une erreur sur l'une d'elles
indique son nom de fichier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet vor 1 Tag
Ursprung
Commit
26e9f3f5d0
4 geänderte Dateien mit 88 neuen und 69 gelöschten Zeilen
  1. 2 0
      CHANGELOG.md
  2. 3 3
      README.md
  3. 10 8
      bot/claude_client.py
  4. 73 58
      bot/discord_bot.py

+ 2 - 0
CHANGELOG.md

@@ -10,6 +10,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 ### Changements
 
+- **Plusieurs images dans un même message.** On peut envoyer jusqu'à 4 images à la fois : Rika les voit toutes, dans l'ordre, et peut les comparer ou parler de « la deuxième ». Les images d'un message cité ou transféré sont lues elles aussi, jusqu'à 4. Si une image pose problème, le message d'erreur dit laquelle. Un PDF reste seul, sans image.
+
 - **Un message de démarrage plus soigné.** Le bloc « Bot en ligne » affiche maintenant l'avatar du bot, sa version, le modèle Claude utilisé (« Sonnet 5.5 ») et l'heure du démarrage, sur le vert du statut « en ligne » de Discord.
 
 ### Corrections

+ 3 - 3
README.md

@@ -10,16 +10,16 @@ Le bot parle français. Sa façon de parler, son caractère et son nom sont ceux
 
 - **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).
-- **Messages cités et transférés** : avec « Répondre », il lit le message cité, même ancien, et son image. Il lit aussi les messages transférés dans son salon.
+- **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.
 
 ### Pièces jointes
 
-Dans son salon comme dans les mentions, le bot lit une image ou un PDF, et un fichier texte, par message :
+Dans son salon comme dans les mentions, le bot lit jusqu'à 4 images ou un PDF, et un fichier texte, par message :
 
-- **Images** (JPEG, PNG, GIF, WebP, BMP, TIFF) : réduites et converties automatiquement si besoin.
+- **Images** (JPEG, PNG, GIF, WebP, BMP, TIFF) : réduites et converties automatiquement si besoin. Avec plusieurs images, il peut les comparer ou parler de « la deuxième ».
 - **PDF** : texte, tableaux, schémas et pages scannées, 20 pages (`PDF_MAX_PAGES`) et 20 Mo au plus. Les PDF protégés par un mot de passe sont refusés.
 - **Fichiers texte** (`.txt`, `.md`, `.csv`, `.json`, `.log`, sous-titres, configuration, HTML, code…), 100 Ko au plus.
 

+ 10 - 8
bot/claude_client.py

@@ -222,13 +222,15 @@ class ClaudeClient:
     # ------------------------------------------------------------------ appel
 
     @staticmethod
-    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 (ou PDF) éventuel."""
-        if all(image is None for _, image in parts):
+    def build_user_content(parts: list[tuple[str, list[ImageData]]]) -> str | list[dict]:
+        """Un ou plusieurs messages à la suite, chacun précédé de ses images (ou de son PDF) éventuels."""
+        if not any(images for _, images in parts):
             return "\n".join(text for text, _ in parts)
         content: list[dict] = []
-        for text, image in parts:
-            if image is not None:
+        for text, images in parts:
+            for number, image in enumerate(images, 1):
+                if len(images) > 1:  # numérotées : Claude peut parler de « la deuxième image »
+                    content.append({"type": "text", "text": f"Image {number} :"})
                 content.append({
                     "type": "document" if image.is_pdf else "image",
                     "source": {
@@ -240,9 +242,9 @@ class ClaudeClient:
             content.append({"type": "text", "text": text})
         return content
 
-    async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
+    async def ask(self, history: list[dict], parts: list[tuple[str, list[ImageData]]],
                   emojis: list[str] | None = None, cache_history: bool = True, member_notes: str = "") -> Answer:
-        """Envoie l'historique + le ou les nouveaux messages (texte, image ou PDF), et renvoie la réponse de Claude.
+        """Envoie l'historique + le ou les nouveaux messages (texte, images ou PDF), et renvoie la réponse de Claude.
 
         emojis : noms des emojis du serveur que Claude peut écrire (voir EMOJI_NOTE).
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
@@ -261,7 +263,7 @@ class ClaudeClient:
                 history_marker = False  # marqueur déjà placé, avant les fiches
             content.append({"type": "text", "text": member_notes})
         messages = list(history) + [{"role": "user", "content": content}]
-        attachments = {("pdf" if image.is_pdf else "image") for _, image in parts if image is not None}
+        attachments = {("pdf" if image.is_pdf else "image") for _, images in parts for image in images}
 
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         if self.settings.web_search:

+ 73 - 58
bot/discord_bot.py

@@ -40,6 +40,8 @@ IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".
 ONLINE_COLOR = discord.Color(0x23A55A)
 # Longueur maximale du texte d'un message cité ou transféré transmis à Claude.
 QUOTE_MAX_LENGTH = 500
+# Images lues par message : chacune coûte jusqu'à ~1 600 tokens.
+MAX_IMAGES = 4
 # Messages écrits par quelqu'un ; les autres types sont des messages système
 # (arrivée d'un membre, message épinglé, boost, fil créé…).
 USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
@@ -103,11 +105,11 @@ class PendingMessage:
     message: discord.Message
     line: dict   # ligne conservée en mémoire (voir history.render_line)
     extra: str   # ajouté pour Claude seulement : contenu du fichier texte joint
-    image: ImageData | None
+    images: list[ImageData]  # images, ou un seul PDF
 
     @property
     def user_text(self) -> str:
-        """Texte envoyé à Claude : l'image ou le fichier lui-même remplace son repère."""
+        """Texte envoyé à Claude : les images ou le fichier eux-mêmes remplacent leur repère."""
         line = {key: value for key, value in self.line.items() if key != "attachments"}
         return render_line(line) + self.extra
 
@@ -376,18 +378,18 @@ class ChatBot(commands.Bot):
             if await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
                 return
 
-        forward, forward_image = await self.read_forward(message)
+        forward, forward_images = await self.read_forward(message)
         if not (content or message.attachments or message.stickers or forward):
             return  # sondage, transfert d'un message vide… : rien à lire
 
         attachments = await self.read_attachments(message)
         if attachments is False:
             return  # pièce jointe refusée, l'utilisateur a été prévenu
-        image, text_file = attachments
+        images, text_file = attachments
 
-        # L'image du message cité n'est utilisée que si le message n'en contient pas lui-même.
-        reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
-        image = image or forward_image or reply_image
+        # Les images du message cité ne sont utilisées que si le message n'en contient pas lui-même.
+        reply, reply_images = await self.read_reply(message, want_images=not (images or forward_images))
+        images = images or forward_images or reply_images
 
         line: dict = {"id": message.id, "date": self.timestamp(message), "author": author_name(message.author)}
         if forward:
@@ -397,7 +399,7 @@ class ChatBot(commands.Bot):
                 line["reply_to"] = message.reference.message_id  # citation retirée si ce message est supprimé
                 line["reply"] = reply
             line["content"] = self.message_body(message)
-        markers = [attachment_marker(image)] if image else []
+        markers = [attachment_marker(image) for image in images]
         extra = ""
         if text_file:
             extra = text_file_prompt(*text_file)
@@ -407,7 +409,7 @@ class ChatBot(commands.Bot):
 
         # 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.
-        self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, line, extra, image))
+        self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, line, extra, images))
         if not self.available() or message.channel.id in self._waiters:
             self.answer_later(message, self.answer_main_channel)
             return
@@ -425,13 +427,13 @@ class ChatBot(commands.Bot):
             if len(batch) > 1:
                 logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
             # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
-            parts = [(p.user_text, p.image) for p in batch]
+            parts = [(p.user_text, p.images) for p in batch]
             lines = [p.line for p in batch]
             notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
             if note := self.presence_note():
                 notes.append(note)
             for note in reversed(notes):
-                parts.insert(0, (note, None))
+                parts.insert(0, (note, []))
                 lines.insert(0, {"content": note})
             try:
                 async with typing(channel):
@@ -483,21 +485,21 @@ class ChatBot(commands.Bot):
                 logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
         lines.reverse()
 
-        # Seuls la première image (à défaut, le premier PDF) et le premier fichier texte de la mention
-        # sont lus (les autres pièces jointes sont ignorées). L'image du message cité ne sert que s'il
-        # n'y a ni image ni PDF.
-        images = [a for a in message.attachments if looks_like_image(a)]
+        # 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.
+        image_files = [a for a in message.attachments if looks_like_image(a)][:MAX_IMAGES]
         pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
         try:
-            if images:
-                image = await self.download_image(images[0])
+            if image_files:
+                images = await self.download_images(image_files)
             else:
-                image = await self.download_pdf(pdfs[0]) if pdfs else None
+                images = [await self.download_pdf(pdfs[0])] if pdfs else []
         except (ImageError, PdfError) as exc:
             await self.safe_reply(message, exc.user_message)
             return
-        reply, reply_image = await self.read_reply(message, want_image=image is None)
-        image = image or reply_image
+        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)"
@@ -524,7 +526,7 @@ class ChatBot(commands.Bot):
 
         async with typing(message.channel):
             try:
-                answer = await self.claude.ask([], [(prompt, image)], emojis=emoji_names(message.guild),
+                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)
@@ -574,7 +576,7 @@ class ChatBot(commands.Bot):
             # Attend la fin d'une réponse en cours ; le statut a pu changer encore entre-temps.
             if self.presence.status is not after:
                 return
-            parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
+            parts = [(note, []) for note in (self.presence_note(), self.presence.departure_note()) if note]
             try:
                 async with typing(channel):
                     answer = await self.claude.ask(self.history.get(channel.id), parts,
@@ -670,14 +672,14 @@ class ChatBot(commands.Bot):
 
     async def read_attachments(
         self, message: discord.Message
-    ) -> tuple[ImageData | None, tuple[str, str] | None] | bool:
-        """Vérifie les pièces jointes, puis télécharge l'image (ou le PDF) et le fichier texte.
+    ) -> tuple[list[ImageData], tuple[str, str] | None] | bool:
+        """Vérifie les pièces jointes, puis télécharge les images (ou le PDF) et le fichier texte.
 
-        Renvoie (image ou PDF, (nom, contenu) du fichier texte), chacun pouvant valoir None,
+        Renvoie (images ou PDF, éventuellement aucun ; (nom, contenu) du fichier texte ou None),
         ou False si l'envoi est refusé (l'utilisateur est alors prévenu).
         """
         if not message.attachments:
-            return None, None
+            return [], None
 
         images = [a for a in message.attachments if looks_like_image(a)]
         pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
@@ -688,8 +690,8 @@ class ChatBot(commands.Bot):
             await self.safe_reply(message, f"Je ne peux lire que {kinds} et des fichiers texte (.txt, .md, .csv, code…). "
                                            f"Fichier(s) non pris en charge : {', '.join(others)}.")
             return False
-        if len(images) > 1:
-            await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
+        if len(images) > MAX_IMAGES:
+            await self.safe_reply(message, f"{MAX_IMAGES} images par message au maximum, s'il te plaît.")
             return False
         if pdfs and (images or len(pdfs) > 1):
             await self.safe_reply(message, "Un seul PDF par message, sans image, s'il te plaît.")
@@ -700,14 +702,14 @@ class ChatBot(commands.Bot):
 
         try:
             if images:
-                image = await self.download_image(images[0])
+                files = await self.download_images(images)
             else:
-                image = await self.download_pdf(pdfs[0]) if pdfs else None
+                files = [await self.download_pdf(pdfs[0])] if pdfs else []
             text_file = await self.download_text_file(texts[0]) if texts else None
         except (ImageError, PdfError, TextFileError) as exc:
             await self.safe_reply(message, exc.user_message)
             return False
-        return image, text_file
+        return files, text_file
 
     async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
         """Télécharge un fichier texte et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
@@ -751,6 +753,17 @@ class ChatBot(commands.Bot):
         # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
         return await asyncio.to_thread(prepare_image, data)
 
+    async def download_images(self, attachments: list[discord.Attachment]) -> list[ImageData]:
+        """Télécharge et prépare plusieurs images en parallèle (lève ImageError si l'une pose problème)."""
+        async def download(attachment: discord.Attachment) -> ImageData:
+            try:
+                return await self.download_image(attachment)
+            except ImageError as exc:
+                if len(attachments) > 1:  # précise laquelle, sinon « cette image » est ambigu
+                    raise ImageError(f"« {attachment.filename} » : {exc.user_message}")
+                raise
+        return list(await asyncio.gather(*(download(a) for a in attachments)))
+
     # ------------------------------------------- message cité ou transféré
 
     async def fetch_cited(self, message: discord.Message) -> discord.Message | None:
@@ -774,59 +787,61 @@ class ChatBot(commands.Bot):
         cited = await self.fetch_cited(message)
         return cited is not None and cited.author == self.user
 
-    async def read_reply(self, message: discord.Message, want_image: bool) -> tuple[str | None, ImageData | None]:
+    async def read_reply(self, message: discord.Message, want_images: bool) -> tuple[str | None, list[ImageData]]:
         """Lit le message auquel l'utilisateur répond.
 
-        Renvoie sa description pour Claude, ainsi que son image si want_image est vrai,
-        ou (None, None) si le message ne répond à rien ou si le message cité est inaccessible.
+        Renvoie sa description pour Claude, ainsi que ses images si want_images est vrai,
+        ou (None, []) si le message ne répond à rien ou si le message cité est inaccessible.
         """
         cited = await self.fetch_cited(message)
         if cited is None:
-            return None, None
+            return None, []
 
-        image = await self.first_image(cited.attachments, "cité") if want_image else None
-        description = self.describe_quoted(cited.clean_content, image, cited.attachments, cited.stickers)
+        images = await self.quoted_images(cited.attachments, "cité") if want_images else []
+        description = self.describe_quoted(cited.clean_content, images, cited.attachments, cited.stickers)
 
         who = "à ton message" if cited.author == self.user else f"au message de {author_name(cited.author)}"
-        return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", image
+        return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", images
 
-    async def read_forward(self, message: discord.Message) -> tuple[str | None, ImageData | None]:
+    async def read_forward(self, message: discord.Message) -> tuple[str | None, list[ImageData]]:
         """Lit le message transféré (bouton « Transférer » de Discord).
 
-        Renvoie sa description pour Claude et son image, ou (None, None) si le message
+        Renvoie sa description pour Claude et ses images, ou (None, []) si le message
         n'est pas un transfert ou si le message transféré ne contient rien de lisible.
         """
         if not message.message_snapshots:
-            return None, None
+            return None, []
         snapshot = message.message_snapshots[0]
 
-        image = await self.first_image(snapshot.attachments, "transféré")
+        images = await self.quoted_images(snapshot.attachments, "transféré")
         # Contrairement à un message normal, le contenu transféré garde ses mentions brutes (<@123>).
         content = self.clean_mentions(snapshot.content)
-        description = self.describe_quoted(content, image, snapshot.attachments, snapshot.stickers)
+        description = self.describe_quoted(content, images, snapshot.attachments, snapshot.stickers)
         if not description:
-            return None, None
-        return f"du {self.timestamp(snapshot)} : {description}", image
-
-    async def first_image(self, attachments: list[discord.Attachment], kind: str) -> ImageData | None:
-        """Télécharge la première image d'un message cité ou transféré (None si aucune ou illisible)."""
-        images = [a for a in attachments if looks_like_image(a)]
-        if not images:
-            return None
-        try:
-            return await self.download_image(images[0])
-        except ImageError as exc:
-            logger.warning("Image du message %s ignorée : %s", kind, exc.user_message)
-            return None
+            return None, []
+        return f"du {self.timestamp(snapshot)} : {description}", images
+
+    async def quoted_images(self, attachments: list[discord.Attachment], kind: str) -> list[ImageData]:
+        """Télécharge les MAX_IMAGES premières images d'un message cité ou transféré (les illisibles sont ignorées)."""
+        images = [a for a in attachments if looks_like_image(a)][:MAX_IMAGES]
+        results = await asyncio.gather(*(self.download_image(a) for a in images), return_exceptions=True)
+        for result in results:
+            if isinstance(result, ImageError):
+                logger.warning("Image du message %s ignorée : %s", kind, result.user_message)
+            elif isinstance(result, BaseException):
+                raise result
+        return [r for r in results if isinstance(r, ImageData)]
 
     @staticmethod
-    def describe_quoted(content: str, image: ImageData | None, attachments: list, stickers: list) -> str:
+    def describe_quoted(content: str, images: list[ImageData], attachments: list, stickers: list) -> str:
         """Description d'un message cité ou transféré (vide s'il n'y a rien de lisible)."""
         content = short_emojis(content.strip())
         if len(content) > QUOTE_MAX_LENGTH:
             content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
         parts = [f"« {content} »"] if content else []
-        if image is not None:
+        if len(images) > 1:
+            parts.append(f"[ses {len(images)} images sont jointes à ce message]")
+        elif images:
             parts.append("[son image est jointe à ce message]")
         elif attachments:
             parts.append("[pièce jointe]")