فهرست منبع

Pièces jointes : des fichiers trop lourds ensemble partent dans plusieurs réponses

Les messages arrivés pendant une réponse partaient tous dans un seul appel à Claude, sans limite
sur le total de leurs pièces jointes. Plusieurs gros PDF ou images pouvaient dépasser les limites
de l'API (128 Mo par requête ; 100 pages et 100 images pour les modèles à 200 000 tokens de
contexte) ou sa fenêtre de contexte, et occuper beaucoup de mémoire pendant l'envoi.

Un budget par requête (24 Mo en base64, 100 pages de PDF et images, dans media.py) répartit
maintenant les messages en plusieurs appels, dans l'ordre ; le premier part toujours. Dans le
salon dédié, answer_main_channel boucle jusqu'au dernier message (une réponse différée est la
seule tâche pour toute son attente), et une erreur sur un lot n'arrête pas les suivants. Pour
les mentions, answer_mention répond groupe par groupe en relisant le contexte, qui contient la
réponse précédente. Une erreur 413 donne un message clair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 روز پیش
والد
کامیت
f98fe9d3d9
9فایلهای تغییر یافته به همراه340 افزوده شده و 82 حذف شده
  1. 2 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 5 0
      bot/claude_client.py
  4. 99 78
      bot/discord_bot.py
  5. 32 1
      bot/media.py
  6. 4 2
      bot/pdf_files.py
  7. 1 0
      tests/test_attachments_prep.py
  8. 32 0
      tests/test_mentions.py
  9. 164 0
      tests/test_request_limits.py

+ 2 - 0
CHANGELOG.md

@@ -14,6 +14,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une coupure réseau pendant un téléchargement ne fait plus ignorer le message.** Si une pièce jointe ne se télécharge pas (connexion coupée, ou plus de 30 secondes), Rika demande de réessayer ; une image illisible dans un message cité ou transféré est simplement ignorée. Avant, le message restait sans réponse.
 
+- **Plusieurs gros fichiers envoyés pendant que Rika écrit ne font plus échouer sa réponse.** Ils partaient tous dans un seul appel à Claude, qui pouvait dépasser ses limites. Quand leurs PDF et images sont trop lourds ensemble (plus de 18 Mo, ou plus de 100 pages et images), Rika répond maintenant en plusieurs fois, dans l'ordre.
+
 ---
 
 ## Version 5.12

+ 1 - 1
README.md

@@ -8,7 +8,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.
+- **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 ; s'ils contiennent des fichiers trop lourds ensemble, il y répond en plusieurs fois.
 - **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.

+ 5 - 0
bot/claude_client.py

@@ -362,6 +362,11 @@ class ClaudeClient:
         except anthropic.NotFoundError as exc:
             logger.critical("Modèle introuvable : vérifie CLAUDE_MODEL dans le .env (%s).", exc)
             raise ClaudeError(CONFIG_ERROR)
+        except anthropic.RequestTooLargeError as exc:
+            # Seules les pièces jointes peuvent atteindre la limite (128 Mo), et le budget de media.py
+            # garde chaque requête bien en dessous : simple garde-fou.
+            logger.error("Requête trop lourde pour l'API : %s", exc)
+            raise ClaudeError("Ces fichiers sont trop lourds pour moi tous ensemble, renvoie-les un par un.")
         except anthropic.APIStatusError as exc:
             logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
             raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")

+ 99 - 78
bot/discord_bot.py

@@ -25,7 +25,7 @@ from .config import Settings, memory_warning
 from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import ConversationHistory, render_line
 from .images import ImageError
-from .media import ImageData
+from .media import ImageData, fit_in_request
 from .members import MemberRegistry, with_username
 from .memory import DATE_FORMAT, MemoryStore
 from .memory_views import reply_error, see_user
@@ -475,7 +475,8 @@ class ChatBot(commands.Bot):
             line["attachments"] = markers
 
         # 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.
+        # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel
+        # (ou plusieurs, si leurs pièces jointes sont trop lourdes ensemble : voir fit_in_request).
         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)
@@ -483,46 +484,54 @@ class ChatBot(commands.Bot):
         await self.answer_main_channel(message.channel)
 
     async def answer_main_channel(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
-        """Répond en une fois aux messages en attente du salon dédié."""
+        """Répond aux messages en attente du salon dédié : en une fois, ou en plusieurs réponses à la suite
+        si leurs pièces jointes ne tiennent pas dans une seule requête."""
         pending = self._pending.setdefault(channel.id, [])
-        async with self.history.lock(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), channel)
-            # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
-            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, []))
-                lines.insert(0, {"content": note})
-            try:
-                async with typing(channel):
-                    try:
-                        notes = await self.member_notes([p.message for p in batch])
-                        answer = await self.claude.ask(self.history.get(channel.id), parts,
-                                                       emojis=emoji_names(last.guild), member_notes=notes)
-                    except ClaudeError as exc:
-                        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(last, "Désolé, une erreur inattendue est survenue.")
-                        return
-            finally:
-                self.forget_waiting(*(p.message for p in batch))
-            await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
-        await self.send_answer(last, answer.text)
+        # Jusqu'au dernier message : une réponse différée est la seule tâche pour tous ceux de son attente.
+        while True:
+            async with self.history.lock(channel.id):
+                if not pending:
+                    return  # déjà parti avec un message précédent
+                pending.sort(key=lambda p: p.message.id)
+                count = fit_in_request([p.images for p in pending])
+                if count < len(pending):
+                    logger.info("Pièces jointes trop lourdes pour une seule réponse dans #%s : %s message(s) sur %s "
+                                "d'abord, les autres ensuite.", channel, count, len(pending))
+                batch = pending[:count]
+                del pending[:count]
+                last = batch[-1].message
+                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.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, []))
+                    lines.insert(0, {"content": note})
+                try:
+                    async with typing(channel):
+                        try:
+                            notes = await self.member_notes([p.message for p in batch])
+                            answer = await self.claude.ask(self.history.get(channel.id), parts,
+                                                           emojis=emoji_names(last.guild), member_notes=notes)
+                        except ClaudeError as exc:
+                            await self.safe_reply(last, exc.user_message)
+                            continue
+                        except Exception:
+                            logger.exception("Erreur inattendue pendant le traitement d'un message")
+                            await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
+                            continue
+                finally:
+                    self.forget_waiting(*(p.message for p in batch))
+                await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
+            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.
+        # puis partent ensemble dans un seul appel (ou plusieurs, si leurs pièces jointes sont trop lourdes).
         self._mentions.setdefault(message.channel.id, []).append(message)
         if not self.available() or message.channel.id in self._waiters:
             self.answer_later(message, self.answer_mentions)
@@ -544,18 +553,63 @@ class ChatBot(commands.Bot):
                 self.forget_waiting(*batch)
 
     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."""
+        """Hors du salon dédié : répond aux mentions d'un même salon en lisant les messages récents, en une
+        fois, ou en plusieurs réponses à la suite si leurs pièces jointes ne tiennent pas dans une seule requête."""
         channel = mentions[-1].channel
-        mention_ids = {m.id for m in mentions}
+        answered, parts = [], []
+        for message in mentions:
+            if part := await self.mention_part(message):
+                answered.append(message)
+                parts.append(part)
+        # Liste vide : pièces jointes refusées, les auteurs ont été prévenus.
+        while parts:
+            count = fit_in_request([images for _, images in parts])
+            if count < len(parts):
+                logger.info("Pièces jointes trop lourdes pour une seule réponse dans #%s : %s mention(s) sur %s "
+                            "d'abord, les autres ensuite.", channel, count, len(parts))
+            group, group_parts = answered[:count], parts[:count]
+            del answered[:count], parts[:count]
+            last = group[-1]
+
+            # Contexte jusqu'à maintenant, relu pour chaque réponse : elle voit celle que le bot vient d'envoyer
+            # (postée après ces mentions). En retard, jusqu'à la dernière mention : la discussion qui a suivi
+            # recouvrirait celle-ci.
+            lines = await self.mention_context(channel, {m.id for m in mentions}, before=mentions[-1] if late else None)
+            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(group_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 = group_parts[0]
+            group_parts[0] = ("\n\n".join([*header, text]), images)
+
+            async with typing(channel):
+                try:
+                    answer = await self.claude.ask([], group_parts, emojis=emoji_names(last.guild), cache_history=False,
+                                                   member_notes=await self.member_notes(group))
+                except ClaudeError as exc:
+                    await self.safe_reply(last, exc.user_message)
+                    continue
+                except Exception:
+                    logger.exception("Erreur inattendue pendant le traitement d'une mention")
+                    await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
+                    continue
+            # 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_context(self, channel: discord.abc.Messageable, mention_ids: set[int],
+                              before: discord.Message | None) -> list[str]:
+        """Derniers messages du salon avant before (ou jusqu'à maintenant), du plus ancien au plus récent,
+        sans les mentions auxquelles le bot répond : elles sont décrites à part, avec leurs pièces jointes."""
         lines: list[str] = []
-        # Jusqu'à maintenant : une réponse groupée voit celle que le bot vient d'envoyer (postée après ces
-        # mentions). En retard, jusqu'à la dernière mention : la discussion qui a suivi recouvrirait celle-ci.
-        before = mentions[-1] if late else None
         if self.settings.context_message_limit > 0:
             try:
                 async for msg in channel.history(limit=self.settings.context_message_limit, before=before):
                     if msg.id in mention_ids:
-                        continue  # décrite plus bas, avec ses pièces jointes
+                        continue
                     content = short_emojis(msg.clean_content.strip())
                     if msg.attachments:
                         content = (content + " [pièce jointe]").strip()
@@ -566,40 +620,7 @@ class ChatBot(commands.Bot):
             except discord.Forbidden:
                 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)
+        return lines
 
     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

+ 32 - 1
bot/media.py

@@ -1,4 +1,4 @@
-"""Pièces jointes préparées pour Claude (images et PDF)."""
+"""Pièces jointes préparées pour Claude (images et PDF), et ce qu'elles pèsent dans une requête."""
 
 from __future__ import annotations
 
@@ -6,6 +6,13 @@ from dataclasses import dataclass
 
 PDF_MEDIA_TYPE = "application/pdf"
 
+# Budget de pièces jointes par requête. Les messages arrivés pendant une réponse partent ensemble :
+# sans budget, plusieurs gros fichiers dépasseraient les limites de l'API (128 Mo par requête ; 100 pages
+# de PDF et 100 images pour les modèles à 200 000 tokens de contexte) ou sa fenêtre de contexte, et
+# occuperaient beaucoup de mémoire le temps de l'envoi.
+REQUEST_MAX_BASE64 = 24_000_000  # octets une fois encodés en base64 (+33 %), soit 18 Mo bruts
+REQUEST_MAX_PAGES = 100  # pages de PDF et images, jusqu'à plusieurs milliers de tokens chacune
+
 
 @dataclass
 class ImageData:
@@ -13,7 +20,31 @@ class ImageData:
     media_type: str
     data: bytes
     name: str | None = None  # nom du fichier (PDF)
+    pages: int = 1  # pages d'un PDF ; une image compte pour une page
 
     @property
     def is_pdf(self) -> bool:
         return self.media_type == PDF_MEDIA_TYPE
+
+
+def base64_size(size: int) -> int:
+    """Taille de size octets une fois encodés en base64."""
+    return 4 * -(-size // 3)
+
+
+def request_weight(attachments: list[ImageData]) -> tuple[int, int]:
+    """Poids de pièces jointes dans une requête : (octets en base64, pages de PDF et images)."""
+    return sum(base64_size(len(a.data)) for a in attachments), sum(a.pages for a in attachments)
+
+
+def fit_in_request(messages: list[list[ImageData]]) -> int:
+    """Nombre de messages, pris dans l'ordre, qui partent ensemble dans la prochaine requête : tant que
+    leurs pièces jointes (une liste par message) tiennent dans le budget. Le premier part toujours, même
+    seul au-delà du budget : il tient déjà dans les limites de l'API (voir PDF_MAX_BYTES, MAX_IMAGES)."""
+    size = pages = 0
+    for count, attachments in enumerate(messages):
+        message_size, message_pages = request_weight(attachments)
+        size, pages = size + message_size, pages + message_pages
+        if count and (size > REQUEST_MAX_BASE64 or pages > REQUEST_MAX_PAGES):
+            return count
+    return len(messages)

+ 4 - 2
bot/pdf_files.py

@@ -14,7 +14,9 @@ logger = logging.getLogger("chatbot.pdf")
 logging.getLogger("pypdf").setLevel(logging.ERROR)  # avertissements sur les PDF mal formés, sans gravité
 
 PDF_EXTENSIONS = (".pdf",)
-# Limite de l'API : 32 Mo par requête, une fois encodé en base64 (+33 %), historique compris.
+# 26,7 Mo une fois encodé en base64 (+33 %) : bien en dessous de la limite de l'API (128 Mo par requête).
+# Plusieurs PDF arrivés pendant une réponse partent au besoin dans des requêtes séparées (voir
+# media.REQUEST_MAX_BASE64) ; seul, un PDF peut dépasser ce budget.
 PDF_MAX_BYTES = 20_000_000
 
 
@@ -43,4 +45,4 @@ def prepare_pdf(name: str, data: bytes, max_pages: int) -> ImageData:
         raise PdfError(f"Ce PDF a {pages} pages : je n'en lis que {max_pages} au maximum. "
                        "Envoie-moi seulement les pages utiles.")
     logger.info("PDF %s : %d page(s), %.1f Mo", name, pages, len(data) / 1e6)
-    return ImageData(PDF_MEDIA_TYPE, data, name=name)
+    return ImageData(PDF_MEDIA_TYPE, data, name=name, pages=pages)

+ 1 - 0
tests/test_attachments_prep.py

@@ -273,6 +273,7 @@ class PreparePdfTest(unittest.TestCase):
         self.assertTrue(result.is_pdf)
         self.assertEqual(result.name, "cours.pdf")
         self.assertIs(result.data, data)
+        self.assertEqual(result.pages, 3)  # compte dans le budget de la requête (voir media.fit_in_request)
 
     def test_too_many_pages(self):
         with self.assertRaises(PdfError) as ctx:

+ 32 - 0
tests/test_mentions.py

@@ -7,6 +7,7 @@ import unittest
 from unittest import mock
 
 from bot.discord_bot import ChatBot
+from bot.media import PDF_MEDIA_TYPE, ImageData
 
 CHANNEL_ID = 20
 
@@ -21,6 +22,7 @@ def fake_bot() -> mock.Mock:
     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.mention_context = lambda channel, ids, before=None: ChatBot.mention_context(bot, channel, ids, before)
     bot.settings.context_message_limit = 20
     bot.settings.pdf_max_pages = 100
     bot.presence_note.return_value = None
@@ -137,6 +139,36 @@ class MentionTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(bot.claude.ask.await_args.args[1], [("texte", [])])
         bot.member_notes.assert_awaited_once_with([second])
 
+    async def test_heavy_mentions_split(self):
+        # Deux mentions avec chacune un PDF de 15 Mo : trop lourdes pour une seule requête, elles reçoivent
+        # deux réponses à la suite, et la seconde voit la première dans le contexte.
+        bot = fake_bot()
+        history = []
+        channel = fake_channel(history)
+        history += [mock.Mock(id=1, clean_content="salut", attachments=[], stickers=[]),
+                    fake_mention(2, "lis ça", channel), fake_mention(3, "et ça", channel)]
+        history[0].author.display_name = history[0].author.name = "membre1"
+        heavy = ImageData(PDF_MEDIA_TYPE, bytes(15_000_000), name="doc.pdf")
+        bot.mention_part = mock.AsyncMock(side_effect=[("lis ça", [heavy]), ("et ça", [heavy])])
+
+        async def post(message, text, reply=False):
+            answer = mock.Mock(id=len(history) + 1, clean_content=text, attachments=[], stickers=[])
+            answer.author.display_name = answer.author.name = "Rika"
+            history.append(answer)
+        bot.send_answer = mock.AsyncMock(side_effect=post)
+        await ChatBot.answer_mention(bot, history[1:3])
+
+        first, second = (call.args[1] for call in bot.claude.ask.await_args_list)
+        self.assertEqual([len(first), len(second)], [1, 1])
+        self.assertIn("membre1 : salut", first[0][0])
+        self.assertTrue(first[0][0].endswith("lis ça"))
+        self.assertNotIn("Rika : Coucou", first[0][0])
+        self.assertIn("Rika : Coucou", second[0][0])
+        self.assertTrue(second[0][0].endswith("et ça"))
+        self.assertEqual(bot.member_notes.await_args_list, [mock.call([history[1]]), mock.call([history[2]])])
+        self.assertEqual(bot.send_answer.await_args_list,
+                         [mock.call(history[1], "Coucou", reply=False), mock.call(history[2], "Coucou", reply=False)])
+
     async def test_all_refused_no_call(self):
         bot = fake_bot()
         channel = fake_channel([])

+ 164 - 0
tests/test_request_limits.py

@@ -0,0 +1,164 @@
+"""Tests du budget de pièces jointes par requête : des messages arrivés pendant une réponse, trop lourds
+ensemble, partent dans plusieurs appels à Claude. Lancer depuis la racine du projet : python -m unittest"""
+
+import asyncio
+import base64
+import contextlib
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import anthropic
+
+from bot.claude_client import ClaudeClient, ClaudeError
+from bot.discord_bot import ChatBot, PendingMessage
+from bot.media import PDF_MEDIA_TYPE, ImageData, base64_size, fit_in_request
+
+CHANNEL_ID = 10
+
+
+def fake_bot() -> mock.Mock:
+    bot = mock.Mock()
+    bot._pending = {}
+    bot.history.lock.return_value = asyncio.Lock()
+    bot.history.get.return_value = []
+    bot.history.add_exchange = mock.AsyncMock()
+    bot.presence_note.return_value = None
+    bot.member_notes = mock.AsyncMock(return_value="")
+    bot.claude.ask = mock.AsyncMock(return_value=mock.Mock(text="Réponse", web_used=False))
+    bot.send_answer = mock.AsyncMock()
+    bot.safe_reply = mock.AsyncMock()
+    return bot
+
+
+def pdf(megabytes: float, pages: int = 1) -> ImageData:
+    return ImageData(PDF_MEDIA_TYPE, bytes(int(megabytes * 1_000_000)), name="doc.pdf", pages=pages)
+
+
+def image() -> ImageData:
+    return ImageData("image/png", bytes(1000))
+
+
+def pending(message_id: int, channel: mock.Mock, images: list[ImageData]) -> PendingMessage:
+    message = mock.Mock(id=message_id, channel=channel)
+    line = {"id": message_id, "date": "03/11/2026 18:00", "author": f"membre{message_id}",
+            "content": f"message {message_id}"}
+    return PendingMessage(message, line, "", images)
+
+
+def asked_texts(bot: mock.Mock) -> list[list[str]]:
+    """Textes envoyés à chaque appel à Claude."""
+    return [[text for text, _ in call.args[1]] for call in bot.claude.ask.await_args_list]
+
+
+def text(message_id: int) -> str:
+    return f"03/11/2026 18:00, membre{message_id} a écrit : message {message_id}"
+
+
+class BudgetTest(unittest.TestCase):
+    def test_base64_size(self):
+        for size in (0, 1, 2, 3, 4, 1000, 15_000_001):
+            with self.subTest(size=size):
+                self.assertEqual(base64_size(size), len(base64.standard_b64encode(bytes(size))))
+
+    def test_light_messages_together(self):
+        self.assertEqual(fit_in_request([[pdf(5)], [image(), image()], [], [pdf(5)]]), 4)
+
+    def test_heavy_messages_apart(self):
+        # 15 Mo bruts = 20 Mo en base64 : deux ne tiennent pas dans les 24 Mo du budget.
+        self.assertEqual(fit_in_request([[pdf(15)], [pdf(15)], [pdf(1)]]), 1)
+        self.assertEqual(fit_in_request([[pdf(15)], [pdf(1)], [pdf(15)]]), 2)
+
+    def test_first_message_always_goes(self):
+        # Un PDF de 20 Mo (26,7 Mo en base64) dépasse le budget, mais part quand même, seul.
+        self.assertEqual(fit_in_request([[pdf(20)]]), 1)
+        self.assertEqual(fit_in_request([[pdf(20)], []]), 1)
+
+    def test_page_limit(self):
+        self.assertEqual(fit_in_request([[pdf(1, pages=40)], [pdf(1, pages=40)], [pdf(1, pages=40)]]), 2)
+        self.assertEqual(fit_in_request([[pdf(1, pages=100)], [pdf(1, pages=1)]]), 1)
+        # Une image compte pour une page : 100 au plus par requête.
+        self.assertEqual(fit_in_request([[image()] * 4] * 30), 25)
+
+
+@mock.patch("bot.discord_bot.typing", lambda channel: contextlib.nullcontext())
+@mock.patch("bot.discord_bot.emoji_names", lambda guild: [])
+class MainChannelTest(unittest.IsolatedAsyncioTestCase):
+    async def test_heavy_messages_split(self):
+        # Deux PDF de 15 Mo (40 Mo en base64) : trop lourds pour partir dans la même requête.
+        bot = fake_bot()
+        channel = mock.Mock(id=CHANNEL_ID)
+        bot._pending[CHANNEL_ID] = [pending(1, channel, [pdf(15)]), pending(2, channel, [pdf(15)])]
+        await ChatBot.answer_main_channel(bot, channel)
+        self.assertEqual(asked_texts(bot), [[text(1)], [text(2)]])
+        self.assertEqual(bot.history.add_exchange.await_count, 2)
+        self.assertEqual([call.args[0].id for call in bot.send_answer.await_args_list], [1, 2])
+        self.assertEqual(bot._pending[CHANNEL_ID], [])
+
+    async def test_light_messages_grouped(self):
+        bot = fake_bot()
+        channel = mock.Mock(id=CHANNEL_ID)
+        bot._pending[CHANNEL_ID] = [pending(2, channel, [pdf(5)]), pending(1, channel, [pdf(5)])]
+        await ChatBot.answer_main_channel(bot, channel)
+        self.assertEqual(asked_texts(bot), [[text(1), text(2)]])
+        bot.send_answer.assert_awaited_once()
+
+    async def test_page_limit(self):
+        # 3 PDF de 40 pages : les deux premiers ensemble (80 pages), le troisième ensuite.
+        bot = fake_bot()
+        channel = mock.Mock(id=CHANNEL_ID)
+        bot._pending[CHANNEL_ID] = [pending(i, channel, [pdf(1, pages=40)]) for i in (1, 2, 3)]
+        await ChatBot.answer_main_channel(bot, channel)
+        self.assertEqual(asked_texts(bot), [[text(1), text(2)], [text(3)]])
+
+    async def test_error_does_not_stop_the_rest(self):
+        bot = fake_bot()
+        channel = mock.Mock(id=CHANNEL_ID)
+        first, second = pending(1, channel, [pdf(15)]), pending(2, channel, [pdf(15)])
+        bot._pending[CHANNEL_ID] = [first, second]
+        bot.claude.ask.side_effect = [ClaudeError("Erreur."), mock.Mock(text="Réponse", web_used=False)]
+        await ChatBot.answer_main_channel(bot, channel)
+        bot.safe_reply.assert_awaited_once_with(first.message, "Erreur.")
+        bot.send_answer.assert_awaited_once_with(second.message, "Réponse")
+        bot.history.add_exchange.assert_awaited_once()
+        self.assertEqual(bot.forget_waiting.call_args_list, [mock.call(first.message), mock.call(second.message)])
+
+    async def test_deferred_answer_handles_all(self):
+        # Absent : une seule tâche attend pour tous les messages du salon, et doit répondre à chacun.
+        bot = fake_bot()
+        bot._waiters = {}
+        bot.presence.wait_turn = mock.AsyncMock()
+        bot.presence.late_note.return_value = "[en retard]"
+        bot.answer_main_channel = lambda channel, late=None: ChatBot.answer_main_channel(bot, channel, late)
+        bot._answer_when_possible = lambda *args: ChatBot._answer_when_possible(bot, *args)
+        channel = mock.Mock(id=CHANNEL_ID)
+        for message_id in (1, 2, 3):
+            waiting = pending(message_id, channel, [pdf(15)])
+            bot._pending.setdefault(CHANNEL_ID, []).append(waiting)
+            ChatBot.answer_later(bot, waiting.message, bot.answer_main_channel)
+        await bot._waiters[CHANNEL_ID]
+
+        # Chaque réponse rappelle qu'elle arrive en retard, et la garde en mémoire.
+        self.assertEqual(asked_texts(bot), [["[en retard]", text(i)] for i in (1, 2, 3)])
+        for call in bot.history.add_exchange.await_args_list:
+            self.assertEqual(call.args[1][0], {"content": "[en retard]"})
+        self.assertEqual([call.args[0].id for call in bot.send_answer.await_args_list], [1, 2, 3])
+        self.assertEqual(bot._pending[CHANNEL_ID], [])
+        self.assertEqual(bot._waiters, {})
+
+
+class TooLargeTest(unittest.IsolatedAsyncioTestCase):
+    async def test_clear_message(self):
+        error = anthropic.RequestTooLargeError("trop lourd", response=mock.Mock(status_code=413, headers={}),
+                                               body={"error": {"type": "request_too_large"}})
+        client = ClaudeClient.__new__(ClaudeClient)  # sans __init__ : pas de client Anthropic à créer
+        client.settings = SimpleNamespace(api_timeout=1)
+        client._client = SimpleNamespace(messages=SimpleNamespace(create=mock.AsyncMock(side_effect=error)))
+        with self.assertLogs("chatbot.claude", "ERROR"), self.assertRaises(ClaudeError) as ctx:
+            await client._call({}, {"pdf"})
+        self.assertEqual(ctx.exception.user_message,
+                         "Ces fichiers sont trop lourds pour moi tous ensemble, renvoie-les un par un.")
+
+
+if __name__ == "__main__":
+    unittest.main()