Bläddra i källkod

PDF et fichiers texte : Rika lit plus de pièces jointes

Rika lit un PDF joint au message (texte, tableaux, schémas, pages
scannées), envoyé tel quel à Claude, dans son salon et en mention : un
par message, sans image, 20 pages au plus (PDF_MAX_PAGES, 0 pour les
refuser) et 20 Mo. Les PDF protégés par un mot de passe sont refusés,
et seul leur nom reste en mémoire. Nouvelle dépendance : pypdf, pour
compter les pages.

En plus des .txt, elle lit les .md, .csv, .json, .log, sous-titres,
fichiers de configuration, HTML et le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 dagar sedan
förälder
incheckning
bc4629edb5
9 ändrade filer med 154 tillägg och 30 borttagningar
  1. 5 2
      .env.exemple
  2. 4 2
      README.md
  3. 20 9
      bot/claude_client.py
  4. 4 1
      bot/config.py
  5. 54 14
      bot/discord_bot.py
  6. 49 0
      bot/pdf_files.py
  7. 8 2
      bot/text_files.py
  8. 9 0
      changelog.txt
  9. 1 0
      requirements.txt

+ 5 - 2
.env.exemple

@@ -38,9 +38,12 @@ HISTORY_FILE=conversation_history.json
 # Pour une personnalité perso non publiée sur GitHub : personality_prompt.local.txt
 # Pour une personnalité perso non publiée sur GitHub : personality_prompt.local.txt
 PERSONALITY_FILE=personality_prompt.txt
 PERSONALITY_FILE=personality_prompt.txt
 
 
-# --- Images --------------------------------------------------------------
-# Taille max en octets
+# --- Images et PDF -------------------------------------------------------
+# Taille max d'une image en octets
 MAX_IMAGE_SIZE=25000000
 MAX_IMAGE_SIZE=25000000
+# Pages max d'un PDF (0 = PDF refusés). Claude lit le texte et l'image de chaque page :
+# compter souvent plusieurs milliers de tokens par page.
+PDF_MAX_PAGES=20
 
 
 # --- Statut Discord ------------------------------------------------------
 # --- Statut Discord ------------------------------------------------------
 # Statut affiché sous le pseudo du bot (anime regardé, musique écoutée, jeu…) : on ou off
 # Statut affiché sous le pseudo du bot (anime regardé, musique écoutée, jeu…) : on ou off

+ 4 - 2
README.md

@@ -7,7 +7,8 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 - **Salon dédié** : répond à tous les messages du salon défini par `CHANNEL_ID`, avec une mémoire des derniers échanges, sauvegardée entre deux redémarrages.
 - **Salon dédié** : répond à tous les messages du salon défini par `CHANNEL_ID`, avec une mémoire des derniers échanges, sauvegardée entre deux redémarrages.
 - **Mode mention** : dans les autres salons, répond quand on le mentionne, en lisant les messages récents pour comprendre la discussion.
 - **Mode mention** : dans les autres salons, répond quand on le mentionne, en lisant les messages récents pour comprendre la discussion.
 - **Images** : commente une image envoyée dans le salon dédié (JPEG, PNG, GIF, WebP, une par message).
 - **Images** : commente une image envoyée dans le salon dédié (JPEG, PNG, GIF, WebP, une par message).
-- **Fichiers texte** : lit un fichier `.txt` joint au message (un par message, 100 Ko maximum), dans le salon dédié et en mode mention. Seul le nom du fichier est gardé en mémoire, pas son contenu.
+- **Fichiers texte** : lit un fichier texte joint au message (`.txt`, `.md`, `.csv`, `.json`, `.log`, sous-titres, code…, un par message, 100 Ko maximum), dans le salon dédié et en mode mention. Seul le nom du fichier est gardé en mémoire, pas son contenu.
+- **PDF** : lit un PDF joint au message (texte, tableaux, schémas et pages scannées), à la place d'une image, dans le salon dédié et en mode mention. 20 pages maximum par défaut (`PDF_MAX_PAGES`, 0 pour refuser les PDF) et 20 Mo ; les PDF protégés par un mot de passe sont refusés. Chaque page coûte souvent plusieurs milliers de tokens. Seul le nom du fichier est gardé en mémoire.
 - **Réponses** : quand on utilise « Répondre » sur un message, même ancien, le bot lit le message cité (et son image, si on n'en joint pas une autre).
 - **Réponses** : quand on utilise « Répondre » sur un message, même ancien, le bot lit le message cité (et son image, si on n'en joint pas une autre).
 - **Transferts** : dans le salon dédié, le bot lit un message transféré (son texte et son image).
 - **Transferts** : dans le salon dédié, le bot lit un message transféré (son texte et son image).
 - **Autres bots** : le bot ignore les autres bots, sauf quand ils le mentionnent ou répondent à l'un de ses messages (bouton « Répondre ») : deux bots peuvent ainsi discuter ensemble. Les messages système (arrivées, épinglages, boosts…) sont ignorés.
 - **Autres bots** : le bot ignore les autres bots, sauf quand ils le mentionnent ou répondent à l'un de ses messages (bouton « Répondre ») : deux bots peuvent ainsi discuter ensemble. Les messages système (arrivées, épinglages, boosts…) sont ignorés.
@@ -63,6 +64,7 @@ bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
 bot/discord_bot.py    événements Discord, réactions, /reset
 bot/presence.py       statut Discord : tirage au sort et changements
 bot/presence.py       statut Discord : tirage au sort et changements
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
-bot/text_files.py     lecture des fichiers .txt joints
+bot/text_files.py     lecture des fichiers texte joints (.txt, .md, code…)
+bot/pdf_files.py      vérification des PDF joints (pages, mot de passe)
 bot/text_utils.py     découpage des longues réponses
 bot/text_utils.py     découpage des longues réponses
 ```
 ```

+ 20 - 9
bot/claude_client.py

@@ -16,6 +16,7 @@ from .models import thinking_off
 logger = logging.getLogger("chatbot.claude")
 logger = logging.getLogger("chatbot.claude")
 
 
 SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
 SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
+PDF_MEDIA_TYPE = "application/pdf"
 
 
 DEFAULT_PERSONALITY = (
 DEFAULT_PERSONALITY = (
     "Tu es un membre sympathique d'un serveur Discord francophone. "
     "Tu es un membre sympathique d'un serveur Discord francophone. "
@@ -112,8 +113,14 @@ class ClaudeError(Exception):
 
 
 @dataclass
 @dataclass
 class ImageData:
 class ImageData:
+    """Image ou PDF joint à un message, prêt à être envoyé à Claude."""
     media_type: str
     media_type: str
     data: bytes
     data: bytes
+    name: str | None = None  # nom du fichier (PDF)
+
+    @property
+    def is_pdf(self) -> bool:
+        return self.media_type == PDF_MEDIA_TYPE
 
 
 
 
 @dataclass
 @dataclass
@@ -158,14 +165,14 @@ class ClaudeClient:
 
 
     @staticmethod
     @staticmethod
     def build_user_content(parts: list[tuple[str, ImageData | None]]) -> str | list[dict]:
     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."""
+        """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):
         if all(image is None for _, image in parts):
             return "\n".join(text for text, _ in parts)
             return "\n".join(text for text, _ in parts)
         content: list[dict] = []
         content: list[dict] = []
         for text, image in parts:
         for text, image in parts:
             if image is not None:
             if image is not None:
                 content.append({
                 content.append({
-                    "type": "image",
+                    "type": "document" if image.is_pdf else "image",
                     "source": {
                     "source": {
                         "type": "base64",
                         "type": "base64",
                         "media_type": image.media_type,
                         "media_type": image.media_type,
@@ -177,12 +184,12 @@ class ClaudeClient:
 
 
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
                   cache_history: bool = True) -> Answer:
                   cache_history: bool = True) -> Answer:
-        """Envoie l'historique + le ou les nouveaux messages (texte, image), et renvoie la réponse de Claude.
+        """Envoie l'historique + le ou les nouveaux messages (texte, image ou PDF), et renvoie la réponse de Claude.
 
 
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
         reprendra pas (mentions : contexte différent à chaque fois)."""
         reprendra pas (mentions : contexte différent à chaque fois)."""
         messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
         messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
-        has_image = any(image is not None for _, image in parts)
+        attachments = {("pdf" if image.is_pdf else "image") for _, image in parts if image is not None}
 
 
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         system = self.personality() + MEMORY_NOTE + GROUP_NOTE + OUTPUT_NOTE
         if self.settings.web_search:
         if self.settings.web_search:
@@ -219,12 +226,12 @@ class ClaudeClient:
                 {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
                 {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": self.settings.web_max_uses},
             ]
             ]
 
 
-        responses = [await self._call(params, has_image)]
+        responses = [await self._call(params, attachments)]
         # Lors de longues recherches, l'API peut rendre la main avant la fin (pause_turn) :
         # 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.
         # 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:
         while responses[-1].stop_reason == "pause_turn" and len(responses) <= MAX_CONTINUATIONS:
             messages = messages + [{"role": "assistant", "content": responses[-1].content}]
             messages = messages + [{"role": "assistant", "content": responses[-1].content}]
-            responses.append(await self._call({**params, "messages": messages}, has_image))
+            responses.append(await self._call({**params, "messages": messages}, attachments))
 
 
         text = self._final_text(responses)
         text = self._final_text(responses)
         web_used = self._log_usage(responses)
         web_used = self._log_usage(responses)
@@ -261,7 +268,7 @@ class ClaudeClient:
         if (off := thinking_off(self.settings.model)) is not None:
         if (off := thinking_off(self.settings.model)) is not None:
             params["thinking"] = off
             params["thinking"] = off
         try:
         try:
-            response = await self._call(params, has_image=False)
+            response = await self._call(params)
         except ClaudeError:
         except ClaudeError:
             return None
             return None
         self._log_usage([response])
         self._log_usage([response])
@@ -274,7 +281,8 @@ class ClaudeClient:
             return None
             return None
         return thought[:max_length]
         return thought[:max_length]
 
 
-    async def _call(self, params: dict, has_image: bool):
+    async def _call(self, params: dict, attachments: set[str] | frozenset[str] = frozenset()):
+        """Appel à l'API ; attachments : pièces jointes envoyées ("image", "pdf"), pour les messages d'erreur."""
         try:
         try:
             return await self._client.messages.create(**params)
             return await self._client.messages.create(**params)
         except anthropic.RateLimitError:
         except anthropic.RateLimitError:
@@ -289,7 +297,10 @@ class ClaudeClient:
         except anthropic.BadRequestError as exc:
         except anthropic.BadRequestError as exc:
             # Toutes les erreurs 400 ont le même type : seul le texte de l'API en donne la cause.
             # Toutes les erreurs 400 ont le même type : seul le texte de l'API en donne la cause.
             message = self._api_message(exc).lower()
             message = self._api_message(exc).lower()
-            if has_image and "image" in message:
+            if "pdf" in attachments and ("pdf" in message or "document" in message):
+                logger.error("PDF refusé par l'API : %s", exc)
+                raise ClaudeError("Je n'ai pas réussi à lire ce PDF, essaie avec un autre.")
+            if "image" in attachments and "image" in message:
                 logger.error("Image refusée par l'API : %s", exc)
                 logger.error("Image refusée par l'API : %s", exc)
                 raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
                 raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
             if "prompt is too long" in message:
             if "prompt is too long" in message:

+ 4 - 1
bot/config.py

@@ -126,8 +126,9 @@ class Settings:
     history_file: Path
     history_file: Path
     personality_file: Path
     personality_file: Path
 
 
-    # Images
+    # Images et PDF
     max_image_size: int
     max_image_size: int
+    pdf_max_pages: int  # 0 : PDF refusés
 
 
     # Statut Discord (voir presence.py)
     # Statut Discord (voir presence.py)
     presence: bool
     presence: bool
@@ -202,6 +203,8 @@ def load_settings() -> Settings:
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
+        # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).
+        pdf_max_pages=_get_int("PDF_MAX_PAGES", default=20, minimum=0),
         presence=presence_raw == "on",
         presence=presence_raw == "on",
         presence_speed=presence_speed,
         presence_speed=presence_speed,
         presence_weights=_get_presence_weights(),
         presence_weights=_get_presence_weights(),

+ 54 - 14
bot/discord_bot.py

@@ -20,6 +20,7 @@ from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
 from .config import Settings
 from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
 from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
 from .images import ImageError, prepare_image
 from .images import ImageError, prepare_image
+from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .presence import PresenceManager, Status
 from .presence import PresenceManager, Status
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
 from .text_utils import split_message
@@ -46,6 +47,10 @@ def looks_like_text(attachment: discord.Attachment) -> bool:
     return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
     return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
 
 
 
 
+def looks_like_pdf(attachment: discord.Attachment) -> bool:
+    return attachment.content_type == "application/pdf" or attachment.filename.lower().endswith(PDF_EXTENSIONS)
+
+
 @contextlib.asynccontextmanager
 @contextlib.asynccontextmanager
 async def typing(channel: discord.abc.Messageable):
 async def typing(channel: discord.abc.Messageable):
     """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
     """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
@@ -68,10 +73,17 @@ def text_file_prompt(name: str, content: str) -> str:
 
 
 
 
 def text_file_marker(name: str) -> str:
 def text_file_marker(name: str) -> str:
-    """Remplace un fichier .txt dans la mémoire (son contenu n'est pas conservé)."""
+    """Remplace un fichier texte dans la mémoire (son contenu n'est pas conservé)."""
     return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
     return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
 
 
 
 
+def attachment_marker(attachment: ImageData) -> str:
+    """Remplace une image ou un PDF dans la mémoire (son contenu n'est pas conservé)."""
+    if attachment.is_pdf:
+        return f"[PDF joint « {attachment.name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
+    return IMAGE_MARKER
+
+
 @dataclass
 @dataclass
 class PendingMessage:
 class PendingMessage:
     """Message du salon dédié, prêt à être envoyé à Claude."""
     """Message du salon dédié, prêt à être envoyé à Claude."""
@@ -209,7 +221,7 @@ class ChatBot(commands.Bot):
             text = text or "(message sans texte)"
             text = text or "(message sans texte)"
             reply_part = f" ({reply})" if reply else ""
             reply_part = f" ({reply})" if reply else ""
             user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
             user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
-        stored_text = user_text + (f" {IMAGE_MARKER}" if image else "")
+        stored_text = user_text + (f" {attachment_marker(image)}" if image else "")
         if text_file:
         if text_file:
             user_text += text_file_prompt(*text_file)
             user_text += text_file_prompt(*text_file)
             stored_text += f" {text_file_marker(text_file[0])}"
             stored_text += f" {text_file_marker(text_file[0])}"
@@ -293,12 +305,17 @@ class ChatBot(commands.Bot):
                 logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
                 logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
         lines.reverse()
         lines.reverse()
 
 
-        # Seules la première image et le premier fichier .txt 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 en a pas.
+        # 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)]
         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 []
         try:
         try:
-            image = await self.download_image(images[0]) if images else None
-        except ImageError as exc:
+            if images:
+                image = await self.download_image(images[0])
+            else:
+                image = await self.download_pdf(pdfs[0]) if pdfs else None
+        except (ImageError, PdfError) as exc:
             await self.safe_reply(message, exc.user_message)
             await self.safe_reply(message, exc.user_message)
             return
             return
         reply, reply_image = await self.read_reply(message, want_image=image is None)
         reply, reply_image = await self.read_reply(message, want_image=image is None)
@@ -476,37 +493,46 @@ class ChatBot(commands.Bot):
     async def read_attachments(
     async def read_attachments(
         self, message: discord.Message
         self, message: discord.Message
     ) -> tuple[ImageData | None, tuple[str, str] | None] | bool:
     ) -> tuple[ImageData | None, tuple[str, str] | None] | bool:
-        """Vérifie les pièces jointes, puis télécharge l'image et le fichier .txt.
+        """Vérifie les pièces jointes, puis télécharge l'image (ou le PDF) et le fichier texte.
 
 
-        Renvoie (image, (nom, contenu) du fichier texte), chacun pouvant valoir None,
+        Renvoie (image ou PDF, (nom, contenu) du fichier texte), chacun pouvant valoir None,
         ou False si l'envoi est refusé (l'utilisateur est alors prévenu).
         ou False si l'envoi est refusé (l'utilisateur est alors prévenu).
         """
         """
         if not message.attachments:
         if not message.attachments:
             return None, None
             return None, None
 
 
         images = [a for a in message.attachments if looks_like_image(a)]
         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 []
         texts = [a for a in message.attachments if looks_like_text(a)]
         texts = [a for a in message.attachments if looks_like_text(a)]
-        others = [a.filename for a in message.attachments if a not in images and a not in texts]
+        others = [a.filename for a in message.attachments if a not in images + pdfs + texts]
         if others:
         if others:
-            await self.safe_reply(message, f"Je ne peux lire que des images et des fichiers .txt. Fichier(s) non pris en charge : {', '.join(others)}.")
+            kinds = "des images, des PDF" if self.settings.pdf_max_pages else "des images"
+            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
             return False
         if len(images) > 1:
         if len(images) > 1:
             await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
             await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
             return False
             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.")
+            return False
         if len(texts) > 1:
         if len(texts) > 1:
-            await self.safe_reply(message, "Un seul fichier .txt par message, s'il te plaît.")
+            await self.safe_reply(message, "Un seul fichier texte par message, s'il te plaît.")
             return False
             return False
 
 
         try:
         try:
-            image = await self.download_image(images[0]) if images else None
+            if images:
+                image = await self.download_image(images[0])
+            else:
+                image = await self.download_pdf(pdfs[0]) if pdfs else None
             text_file = await self.download_text_file(texts[0]) if texts else None
             text_file = await self.download_text_file(texts[0]) if texts else None
-        except (ImageError, TextFileError) as exc:
+        except (ImageError, PdfError, TextFileError) as exc:
             await self.safe_reply(message, exc.user_message)
             await self.safe_reply(message, exc.user_message)
             return False
             return False
         return image, text_file
         return image, text_file
 
 
     async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
     async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
-        """Télécharge un fichier .txt et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
+        """Télécharge un fichier texte et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
         if attachment.size > TEXT_FILE_MAX_BYTES:
         if attachment.size > TEXT_FILE_MAX_BYTES:
             raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
             raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
 
 
@@ -518,6 +544,20 @@ class ChatBot(commands.Bot):
 
 
         return attachment.filename, decode_text_file(data)
         return attachment.filename, decode_text_file(data)
 
 
+    async def download_pdf(self, attachment: discord.Attachment) -> ImageData:
+        """Télécharge et vérifie un PDF (lève PdfError en cas de problème)."""
+        if attachment.size > PDF_MAX_BYTES:
+            raise PdfError(f"Ce PDF est trop lourd (maximum {PDF_MAX_BYTES // 1_000_000} Mo).")
+
+        try:
+            data = await attachment.read()
+        except discord.HTTPException as exc:
+            logger.error("Téléchargement du PDF impossible : %s", exc)
+            raise PdfError("Je n'ai pas réussi à télécharger ton PDF, réessaie.")
+
+        # Lecture du PDF dans un thread : ne bloque pas le bot.
+        return await asyncio.to_thread(prepare_pdf, attachment.filename, data, self.settings.pdf_max_pages)
+
     async def download_image(self, attachment: discord.Attachment) -> ImageData:
     async def download_image(self, attachment: discord.Attachment) -> ImageData:
         """Télécharge et prépare une image (lève ImageError en cas de problème)."""
         """Télécharge et prépare une image (lève ImageError en cas de problème)."""
         if attachment.size > self.settings.max_image_size:
         if attachment.size > self.settings.max_image_size:

+ 49 - 0
bot/pdf_files.py

@@ -0,0 +1,49 @@
+"""Lecture des PDF joints aux messages : envoyés tels quels à Claude, qui en lit le texte et les pages."""
+
+from __future__ import annotations
+
+import io
+import logging
+
+from pypdf import PdfReader
+
+from .claude_client import PDF_MEDIA_TYPE, ImageData
+
+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.
+PDF_MAX_BYTES = 20_000_000
+
+
+class PdfError(Exception):
+    """PDF illisible ou refusé, avec un message affichable sur Discord."""
+
+    def __init__(self, user_message: str):
+        super().__init__(user_message)
+        self.user_message = user_message
+
+
+def prepare_pdf(name: str, data: bytes, max_pages: int) -> ImageData:
+    """Vérifie un PDF avant l'envoi à Claude : lisible, sans mot de passe, max_pages pages au plus.
+
+    Fonction bloquante (lecture du PDF) : à appeler via asyncio.to_thread.
+    """
+    try:
+        reader = PdfReader(io.BytesIO(data))
+        if reader.is_encrypted and not reader.decrypt(""):
+            raise PdfError("Ce PDF est protégé par un mot de passe, je ne peux pas le lire.")
+        pages = len(reader.pages)
+    except PdfError:
+        raise
+    except Exception as exc:  # fichier abîmé ou pas un PDF : pypdf lève des erreurs très variées
+        logger.info("PDF %s illisible : %s", name, exc)
+        raise PdfError("Je n'arrive pas à lire ce PDF.")
+    if pages == 0:
+        raise PdfError("Ce PDF ne contient aucune page.")
+    if pages > max_pages:
+        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)

+ 8 - 2
bot/text_files.py

@@ -1,10 +1,16 @@
-"""Lecture des fichiers texte (.txt) joints aux messages."""
+"""Lecture des fichiers texte (.txt, .md, .csv, code…) joints aux messages."""
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
 import codecs
 import codecs
 
 
-TEXT_EXTENSIONS = (".txt",)
+# Fichiers lus comme du texte brut : notes, données, sous-titres, configuration et code.
+TEXT_EXTENSIONS = (
+    ".txt", ".md", ".log", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg",
+    ".srt", ".vtt", ".html", ".htm", ".css", ".tex", ".sql",
+    ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".cs",
+    ".go", ".rs", ".rb", ".php", ".lua", ".sh", ".bat", ".ps1",
+)
 # Taille maximale d'un fichier .txt (environ 25 000 tokens, pour limiter le coût d'un message).
 # Taille maximale d'un fichier .txt (environ 25 000 tokens, pour limiter le coût d'un message).
 TEXT_FILE_MAX_BYTES = 100_000
 TEXT_FILE_MAX_BYTES = 100_000
 
 

+ 9 - 0
changelog.txt

@@ -11,6 +11,15 @@ Nouveautés
   accord avec son statut. Elle ne le fait que si quelqu'un y a écrit dans
   accord avec son statut. Elle ne le fait que si quelqu'un y a écrit dans
   les 3 dernières minutes, et après avoir fini sa réponse en cours ; elle
   les 3 dernières minutes, et après avoir fini sa réponse en cours ; elle
   s'en souvient ensuite.
   s'en souvient ensuite.
+- PDF : Rika lit un PDF joint au message (texte, tableaux, schémas, pages
+  scannées), dans son salon et quand on la mentionne. Un PDF par message,
+  sans image ; 20 pages au plus (PDF_MAX_PAGES dans le .env, 0 pour
+  refuser les PDF) et 20 Mo. Les PDF protégés par un mot de passe sont
+  refusés. Comme pour les images, seul le nom du fichier reste en mémoire.
+  Nouvelle dépendance : pypdf (pip install -r requirements.txt).
+- Fichiers texte : en plus des .txt, Rika lit les .md, .csv, .json, .log,
+  sous-titres (.srt, .vtt), fichiers de configuration, pages HTML et le
+  code (.py, .js, .java, .cpp, .cs…).
 
 
 Corrections
 Corrections
 - Les captures d'écran trop grandes restent en PNG au lieu d'être
 - Les captures d'écran trop grandes restent en PNG au lieu d'être

+ 1 - 0
requirements.txt

@@ -3,3 +3,4 @@ anthropic>=0.40
 python-dotenv>=1.0
 python-dotenv>=1.0
 tzdata>=2024.1
 tzdata>=2024.1
 Pillow>=10.0
 Pillow>=10.0
+pypdf>=5.0