Преглед изворни кода

Version 5.2.0

- Le bot répond à la suite dans le salon au lieu d'utiliser « Répondre »
  (les messages d'erreur restent en « Répondre »).
- Lecture des fichiers .txt joints (un par message, 100 Ko maximum),
  dans le salon dédié et en mode mention.
- Les fichiers *.local.txt (personnalité perso) sont exclus de GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet пре 6 дана
родитељ
комит
4d69dffab0
7 измењених фајлова са 130 додато и 28 уклоњено
  1. 1 0
      .env.exemple
  2. 4 0
      .gitignore
  3. 3 1
      README.md
  4. 1 1
      bot/__init__.py
  5. 66 26
      bot/discord_bot.py
  6. 30 0
      bot/text_files.py
  7. 25 0
      changelog.txt

+ 1 - 0
.env.exemple

@@ -24,6 +24,7 @@ MAX_HISTORY_LENGTH=10
 # Nombre de messages lus comme contexte quand on mentionne le bot ailleurs
 CONTEXT_MESSAGE_LIMIT=20
 HISTORY_FILE=conversation_history.json
+# Pour une personnalité perso non publiée sur GitHub : personality_prompt.local.txt
 PERSONALITY_FILE=personality_prompt.txt
 
 # --- Images --------------------------------------------------------------

+ 4 - 0
.gitignore

@@ -5,3 +5,7 @@ __pycache__/
 venv/
 bot.log*
 conversation_history.json*
+.claude/
+.vscode/
+# Personnalité perso (peut contenir des infos privées)
+*.local.txt

+ 3 - 1
README.md

@@ -7,10 +7,11 @@ 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.
 - **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).
 - **Stickers et emojis** : répond à un sticker par un sticker du serveur, et à un emoji personnalisé seul par un emoji du serveur.
 - **`/reset`** : efface la mémoire du salon (réservé aux modérateurs et administrateurs).
-- **Personnalité** modifiable dans `personality_prompt.txt`, prise en compte sans redémarrer.
+- **Personnalité** modifiable dans `personality_prompt.txt`, prise en compte sans redémarrer. Pour une personnalité perso, crée `personality_prompt.local.txt` et indique-le dans `PERSONALITY_FILE` du `.env` : les fichiers `*.local.txt` ne sont jamais envoyés sur GitHub.
 - **Logs** dans `bot.log`, avec le coût estimé de chaque appel.
 
 ## Installation
@@ -57,5 +58,6 @@ bot/config.py         lecture et validation du .env
 bot/claude_client.py  appels à l'API de Claude
 bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
+bot/text_files.py     lecture des fichiers .txt joints
 bot/text_utils.py     découpage des longues réponses
 ```

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 
-VERSION = "5.1.0"
+VERSION = "5.2.0"

+ 66 - 26
bot/discord_bot.py

@@ -16,6 +16,7 @@ from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
 from .history import IMAGE_MARKER, ConversationHistory
 from .images import ImageError, prepare_image
+from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
 from .text_utils import split_message
 
 logger = logging.getLogger("chatbot.discord")
@@ -30,6 +31,20 @@ def looks_like_image(attachment: discord.Attachment) -> bool:
     return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
 
 
+def looks_like_text(attachment: discord.Attachment) -> bool:
+    return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
+
+
+def text_file_prompt(name: str, content: str) -> str:
+    """Contenu d'un fichier .txt tel qu'il est transmis à Claude."""
+    return f"\n\n[Contenu du fichier joint « {name} » :]\n{content}\n[Fin du fichier « {name} »]"
+
+
+def text_file_marker(name: str) -> str:
+    """Remplace un fichier .txt 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]"
+
+
 class ChatBot(commands.Bot):
     def __init__(self, settings: Settings):
         intents = discord.Intents.default()
@@ -102,9 +117,10 @@ class ChatBot(commands.Bot):
         if await self.reply_to_emoji(message):
             return
 
-        image = await self.read_image(message)
-        if image is False:
+        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
 
         # 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)
@@ -114,6 +130,9 @@ class ChatBot(commands.Bot):
         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 "")
+        if text_file:
+            user_text += text_file_prompt(*text_file)
+            stored_text += f" {text_file_marker(text_file[0])}"
 
         # Un seul appel à la fois par salon : l'historique reste cohérent.
         async with self.history.lock(message.channel.id):
@@ -160,6 +179,15 @@ class ChatBot(commands.Bot):
         parts.append(f"{self.timestamp(message)}, {message.author.display_name} te mentionne{reply_part} et écrit : {text}")
         prompt = "\n\n".join(parts)
 
+        # Seuls les fichiers .txt de la mention sont lus (les autres pièces jointes sont ignorées).
+        text_files = [a for a in message.attachments if looks_like_text(a)]
+        if text_files:
+            try:
+                prompt += text_file_prompt(*await self.download_text_file(text_files[0]))
+            except TextFileError as exc:
+                await self.safe_reply(message, exc.user_message)
+                return
+
         async with message.channel.typing():
             try:
                 answer = await self.claude.ask([], prompt, image)
@@ -206,30 +234,52 @@ class ChatBot(commands.Bot):
             logger.warning("Impossible d'envoyer l'emoji : %s", exc)
         return True
 
-    # ---------------------------------------------------------------- images
+    # ------------------------------------------------------- pièces jointes
 
-    async def read_image(self, message: discord.Message) -> ImageData | None | bool:
-        """Vérifie les pièces jointes et télécharge l'image.
+    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.
 
-        Renvoie l'image, None s'il n'y en a pas, ou False si l'envoi est refusé
-        (l'utilisateur est alors prévenu).
+        Renvoie (image, (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
+            return None, None
 
-        others = [a.filename for a in message.attachments if not looks_like_image(a)]
+        images = [a for a in message.attachments if looks_like_image(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]
         if others:
-            await self.safe_reply(message, f"Je ne peux lire que des images. Fichier(s) non pris en charge : {', '.join(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)}.")
             return False
-        if len(message.attachments) > 1:
+        if len(images) > 1:
             await self.safe_reply(message, "Une seule image par message, 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.")
+            return False
 
         try:
-            return await self.download_image(message.attachments[0])
-        except ImageError as exc:
+            image = await self.download_image(images[0]) if images else None
+            text_file = await self.download_text_file(texts[0]) if texts else None
+        except (ImageError, 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)."""
+        if attachment.size > TEXT_FILE_MAX_BYTES:
+            raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
+
+        try:
+            data = await attachment.read()
+        except discord.HTTPException as exc:
+            logger.error("Téléchargement du fichier texte impossible : %s", exc)
+            raise TextFileError("Je n'ai pas réussi à télécharger ton fichier, réessaie.")
+
+        return attachment.filename, decode_text_file(data)
 
     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)."""
@@ -298,21 +348,11 @@ class ChatBot(commands.Bot):
         return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
 
     async def send_answer(self, message: discord.Message, answer: str) -> None:
-        chunks = split_message(answer)
-        for i, chunk in enumerate(chunks):
+        # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre ».
+        for chunk in split_message(answer):
             try:
-                if i == 0:
-                    await message.reply(chunk, mention_author=False)
-                else:
-                    await message.channel.send(chunk)
+                await message.channel.send(chunk)
             except discord.HTTPException as exc:
-                # Le message d'origine a pu être supprimé entre-temps : on envoie sans répondre.
-                if i == 0:
-                    try:
-                        await message.channel.send(chunk)
-                        continue
-                    except discord.HTTPException:
-                        pass
                 logger.error("Envoi de la réponse impossible : %s", exc)
                 return
 

+ 30 - 0
bot/text_files.py

@@ -0,0 +1,30 @@
+"""Lecture des fichiers texte (.txt) joints aux messages."""
+
+from __future__ import annotations
+
+TEXT_EXTENSIONS = (".txt",)
+# Taille maximale d'un fichier .txt (environ 25 000 tokens, pour limiter le coût d'un message).
+TEXT_FILE_MAX_BYTES = 100_000
+
+
+class TextFileError(Exception):
+    """Fichier texte 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 decode_text_file(data: bytes) -> str:
+    """Décode le contenu d'un fichier .txt (UTF-8, sinon Windows-1252)."""
+    if b"\x00" in data:
+        raise TextFileError("Ce fichier ne contient pas du texte lisible.")
+    try:
+        text = data.decode("utf-8-sig")
+    except UnicodeDecodeError:
+        # Fichiers enregistrés par d'anciens éditeurs Windows (Bloc-notes, etc.).
+        text = data.decode("cp1252", errors="replace")
+    text = text.replace("\r\n", "\n").strip()
+    if not text:
+        raise TextFileError("Ce fichier texte est vide.")
+    return text

+ 25 - 0
changelog.txt

@@ -2,6 +2,31 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.2.0 — 28/10/2026
+--------------------------
+
+Nouveautés
+- Fichiers texte : le bot lit un fichier .txt joint au message et en tient
+  compte dans sa réponse.
+  - Un fichier .txt par message (en plus d'une éventuelle image), 100 Ko maximum.
+  - Fonctionne dans le salon dédié et en mode mention.
+  - Encodage UTF-8 ou Windows (Bloc-notes) reconnu automatiquement.
+  - Dans la mémoire, seul le nom du fichier est conservé, pas son contenu
+    (comme pour les images), pour ne pas faire grimper le coût des messages
+    suivants.
+
+- Personnalité perso : les fichiers *.local.txt sont exclus de GitHub
+  (.gitignore). On peut y mettre une personnalité contenant des infos privées
+  (ex. PERSONALITY_FILE=personality_prompt.local.txt dans le .env) ;
+  personality_prompt.txt reste la personnalité par défaut publiée sur GitHub.
+
+Améliorations
+- Le bot n'utilise plus la fonction « Répondre » de Discord pour ses réponses :
+  il écrit simplement à la suite dans le salon, comme un humain.
+  - Les messages d'erreur (fichier non pris en charge, image en trop, etc.)
+    restent en « Répondre », pour qu'on sache à qui ils s'adressent.
+
+
 Version 5.1.0 — 27/10/2026
 --------------------------