Ver código fonte

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 dias atrás
pai
commit
bc4629edb5
9 arquivos alterados com 154 adições e 30 exclusões
  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
 PERSONALITY_FILE=personality_prompt.txt
 
-# --- Images --------------------------------------------------------------
-# Taille max en octets
+# --- Images et PDF -------------------------------------------------------
+# Taille max d'une image en octets
 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 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.
 - **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).
-- **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).
 - **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.
@@ -63,6 +64,7 @@ bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
 bot/presence.py       statut Discord : tirage au sort et changements
 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
 ```

+ 20 - 9
bot/claude_client.py

@@ -16,6 +16,7 @@ from .models import thinking_off
 logger = logging.getLogger("chatbot.claude")
 
 SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
+PDF_MEDIA_TYPE = "application/pdf"
 
 DEFAULT_PERSONALITY = (
     "Tu es un membre sympathique d'un serveur Discord francophone. "
@@ -112,8 +113,14 @@ class ClaudeError(Exception):
 
 @dataclass
 class ImageData:
+    """Image ou PDF joint à un message, prêt à être envoyé à Claude."""
     media_type: str
     data: bytes
+    name: str | None = None  # nom du fichier (PDF)
+
+    @property
+    def is_pdf(self) -> bool:
+        return self.media_type == PDF_MEDIA_TYPE
 
 
 @dataclass
@@ -158,14 +165,14 @@ class ClaudeClient:
 
     @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 é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):
             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",
+                    "type": "document" if image.is_pdf else "image",
                     "source": {
                         "type": "base64",
                         "media_type": image.media_type,
@@ -177,12 +184,12 @@ class ClaudeClient:
 
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
                   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
         reprendra pas (mentions : contexte différent à chaque fois)."""
         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
         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},
             ]
 
-        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) :
         # 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}, has_image))
+            responses.append(await self._call({**params, "messages": messages}, attachments))
 
         text = self._final_text(responses)
         web_used = self._log_usage(responses)
@@ -261,7 +268,7 @@ class ClaudeClient:
         if (off := thinking_off(self.settings.model)) is not None:
             params["thinking"] = off
         try:
-            response = await self._call(params, has_image=False)
+            response = await self._call(params)
         except ClaudeError:
             return None
         self._log_usage([response])
@@ -274,7 +281,8 @@ class ClaudeClient:
             return None
         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:
             return await self._client.messages.create(**params)
         except anthropic.RateLimitError:
@@ -289,7 +297,10 @@ class ClaudeClient:
         except anthropic.BadRequestError as exc:
             # 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()
-            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)
                 raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
             if "prompt is too long" in message:

+ 4 - 1
bot/config.py

@@ -126,8 +126,9 @@ class Settings:
     history_file: Path
     personality_file: Path
 
-    # Images
+    # Images et PDF
     max_image_size: int
+    pdf_max_pages: int  # 0 : PDF refusés
 
     # Statut Discord (voir presence.py)
     presence: bool
@@ -202,6 +203,8 @@ def load_settings() -> Settings:
         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.
         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_speed=presence_speed,
         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 .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
 from .images import ImageError, prepare_image
+from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .presence import PresenceManager, Status
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 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)
 
 
+def looks_like_pdf(attachment: discord.Attachment) -> bool:
+    return attachment.content_type == "application/pdf" or attachment.filename.lower().endswith(PDF_EXTENSIONS)
+
+
 @contextlib.asynccontextmanager
 async def typing(channel: discord.abc.Messageable):
     """« 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:
-    """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]"
 
 
+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
 class PendingMessage:
     """Message du salon dédié, prêt à être envoyé à Claude."""
@@ -209,7 +221,7 @@ class ChatBot(commands.Bot):
             text = text or "(message sans texte)"
             reply_part = f" ({reply})" if reply else ""
             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:
             user_text += text_file_prompt(*text_file)
             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)
         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)]
+        pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
         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)
             return
         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(
         self, message: discord.Message
     ) -> 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).
         """
         if not message.attachments:
             return None, 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 []
         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:
-            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
         if len(images) > 1:
             await self.safe_reply(message, "Une seule image par message, 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.")
+            return False
         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
 
         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
-        except (ImageError, TextFileError) as exc:
+        except (ImageError, PdfError, TextFileError) as exc:
             await self.safe_reply(message, exc.user_message)
             return False
         return image, text_file
 
     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:
             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)
 
+    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:
         """Télécharge et prépare une image (lève ImageError en cas de problème)."""
         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
 
 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).
 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
   les 3 dernières minutes, et après avoir fini sa réponse en cours ; elle
   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
 - 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
 tzdata>=2024.1
 Pillow>=10.0
+pypdf>=5.0