فهرست منبع

Mémoire : une fiche illisible ne bloque plus les réponses et n'est plus écrasée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 روز پیش
والد
کامیت
c81ffea25b
4فایلهای تغییر یافته به همراه100 افزوده شده و 5 حذف شده
  1. 1 1
      README.md
  2. 46 4
      bot/memory.py
  3. 6 0
      changelog.txt
  4. 47 0
      tests/test_memory.py

+ 1 - 1
README.md

@@ -160,7 +160,7 @@ Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque app
 | `presence_state.json` | Statut Discord en cours, repris après un redémarrage |
 | `slash_commands.sha256` | Empreinte des commandes slash, pour ne les envoyer à Discord que quand elles changent |
 | `memories/registre.json` | Avec `MEMORY=on` : membres vus par le bot, leurs noms et anciens pseudos, et leurs réglages de la mémoire. Si le fichier est illisible, il est mis de côté sous un nom daté |
-| `memories/membres/<id>.md`, `memories/serveur.md` | Avec `MEMORY=on` : fiche de chaque membre et fichier général du serveur, tenus par Claude |
+| `memories/membres/<id>.md`, `memories/serveur.md` | Avec `MEMORY=on` : fiche de chaque membre et fichier général du serveur, tenus par Claude, en UTF-8. Un fichier illisible (enregistré dans un autre encodage, par exemple) est signalé dans le log : le bot continue de répondre, mais n'y écrit plus rien tant qu'il n'est pas corrigé |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 
 Le détail de chaque version est dans `changelog.txt`.

+ 46 - 4
bot/memory.py

@@ -75,15 +75,32 @@ class MemoryStore:
         self.member_limit = member_limit
         self.server_limit = server_limit
         self._lock = asyncio.Lock()  # deux réponses en parallèle ne modifient pas un fichier en même temps
+        self._unreadable: set[str] = set()  # fichiers illisibles déjà signalés dans le log
 
     # ------------------------------------------------------------------ accès
 
     def read(self, name: str) -> str:
-        """Contenu d'un fichier de mémoire (vide s'il n'existe pas)."""
+        """Contenu d'un fichier de mémoire, pour le prompt ou l'affichage (vide s'il n'existe pas).
+
+        Un fichier illisible ne bloque pas les réponses : caractères invalides remplacés par « � »
+        (fichier enregistré dans un autre encodage), ou vide s'il ne peut pas être lu du tout (droits,
+        dossier à ce nom). Avant une modification, voir _read_for_update."""
         try:
-            return (self.root / name).read_text(encoding="utf-8").strip()
+            data = (self.root / name).read_bytes()
         except FileNotFoundError:
+            self._unreadable.discard(name)
+            return ""
+        except OSError as exc:
+            self._report(name, exc)
             return ""
+        try:
+            text = data.decode("utf-8-sig")  # -sig : BOM laissé par certains éditeurs Windows retiré
+            self._unreadable.discard(name)
+        except UnicodeDecodeError as exc:
+            self._report(name, exc)
+            text = data.decode("utf-8-sig", errors="replace")
+        # Fins de ligne Windows ramenées à « \n », comme le fait read_text.
+        return text.replace("\r\n", "\n").replace("\r", "\n").strip()
 
     def member_file(self, member_id: int | str) -> str:
         return f"membres/{member_id}.md"
@@ -190,7 +207,12 @@ class MemoryStore:
             if limit is None:
                 logger.warning("Mémoire : %s refusé sur %r (%s)", action, command.get("fichier"), label)
                 return ToolResult(label, is_error=True)
-            before = self.read(name)
+            try:
+                before = self._read_for_update(name)
+            except (OSError, UnicodeDecodeError) as exc:
+                self._report(name, exc)
+                return ToolResult(f"{name} est illisible pour l'instant, à cause d'une erreur du bot : "
+                                  f"rien n'a été modifié.", is_error=True)
             if action == "ajouter":
                 if not text:
                     return ToolResult("Rien à ajouter : « texte » est vide.", is_error=True)
@@ -280,7 +302,12 @@ class MemoryStore:
             # Mot entier : « Bob » ne retire pas une ligne sur « Bobine ».
             cites = (re.compile(r"(?<!\w)(?:" + "|".join(map(re.escape, names)) + r")(?!\w)", re.IGNORECASE)
                      if names else None)
-            server = self.read(SERVER_FILE).splitlines()
+            try:
+                server = self._read_for_update(SERVER_FILE).splitlines()
+            except (OSError, UnicodeDecodeError) as exc:
+                # Rien n'est effacé ni désactivé : le membre réessaiera une fois le fichier réparé.
+                self._report(SERVER_FILE, exc)
+                return "Effacement impossible pour l'instant, à cause d'une erreur du bot."
             kept = [line for line in server if cites is None or not cites.search(line)]
             try:
                 (self.root / name).unlink(missing_ok=True)
@@ -300,6 +327,21 @@ class MemoryStore:
 
     # ------------------------------------------------------------ internes
 
+    def _read_for_update(self, name: str) -> str:
+        """Contenu d'un fichier avant de le modifier (vide s'il n'existe pas). Lève OSError ou
+        UnicodeDecodeError s'il est illisible : le réécrire effacerait ce qui n'a pas pu être lu."""
+        try:
+            return (self.root / name).read_text(encoding="utf-8-sig").strip()
+        except FileNotFoundError:
+            return ""
+
+    def _report(self, name: str, exc: Exception) -> None:
+        """Signale un fichier illisible, une seule fois tant qu'il le reste (il est relu à chaque message)."""
+        if name not in self._unreadable:
+            self._unreadable.add(name)
+            logger.error("Mémoire : %s illisible (%s). À corriger sur le disque, ou à remplacer avec "
+                         "/memoire ou /memoire-serveur.", name, exc)
+
     @staticmethod
     def _normalize(name: str) -> str:
         """Accepte aussi « /memories/membres/123.md » ou « membres/123 » pour « membres/123.md »."""

+ 6 - 0
changelog.txt

@@ -43,6 +43,12 @@ Corrections
   (les auteurs d'abord, puis l'auteur du message cité, puis les membres
   mentionnés) ; les autres sont seulement nommées, et Rika peut les
   lire si besoin.
+- Une fiche ou un fichier général illisible (enregistré à la main dans
+  un autre encodage, problème de droits) ne bloque plus toutes les
+  réponses du bot : il est signalé une fois dans le log, affiché avec
+  « � » à la place des caractères invalides, et n'est plus modifié
+  tant qu'il n'est pas corrigé. Une fiche enregistrée avec BOM
+  (Bloc-notes) garde sa ligne « Surnoms ».
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 47 - 0
tests/test_memory.py

@@ -139,6 +139,53 @@ class SizeLimitsTest(MemoryTestCase):
         self.assertIn("membre210 : membres/210.md", notes)
 
 
+class UnreadableFileTest(MemoryTestCase):
+    """Un fichier illisible ne bloque pas les réponses, et n'est pas écrasé par une note."""
+
+    def fiche(self) -> Path:
+        return self.root / "memories" / "membres" / f"{MEMBER_ID}.md"
+
+    def write_latin1(self, name: str, text: str) -> bytes:
+        path = self.root / "memories" / name
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_bytes(text.encode("latin-1"))
+        return path.read_bytes()
+
+    async def test_other_encoding_still_shown(self):
+        self.write_latin1(f"membres/{MEMBER_ID}.md", "Surnoms : Bébert\nhabite à Lyon")
+        with self.assertLogs("chatbot.memory", "ERROR") as logs:
+            context = self.store.system_context()
+            self.store.system_context()
+        self.assertIn("B�bert", context)
+        self.assertIn("habite � Lyon", self.store.notes_for([MEMBER_ID]))
+        self.assertEqual(len(logs.output), 1)  # signalé une seule fois
+
+    async def test_unreadable_file_not_overwritten(self):
+        before = self.write_latin1(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        result = await self.tool(expect_error=True, fichier=f"membres/{MEMBER_ID}.md", texte="aime Zelda")
+        self.assertIn("illisible", result.content)
+        self.assertEqual(self.fiche().read_bytes(), before)
+
+    async def test_directory_instead_of_file(self):
+        self.fiche().mkdir(parents=True)
+        self.assertNotIn("membres/111.md", self.store.system_context())
+        self.assertIn("(rien de noté pour l'instant)", self.store.notes_for([MEMBER_ID]))
+
+    async def test_bom_nicknames_recognized(self):
+        self.fiche().parent.mkdir(parents=True)
+        self.fiche().write_text("Surnoms : Bobby\nhabite à Lyon", encoding="utf-8-sig")
+        self.assertEqual(self.store.nicknames(MEMBER_ID), ["Bobby"])
+
+    async def test_forget_with_unreadable_server_file(self):
+        before = self.write_latin1(SERVER_FILE, "Bob a gagné le tournoi")
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        with self.assertLogs("chatbot.memory", "ERROR"):
+            self.assertIsNotNone(await self.store.forget(MEMBER_ID, by="bob"))
+        self.assertTrue(self.members.memory_enabled(MEMBER_ID))
+        self.assertTrue(self.fiche().exists())
+        self.assertEqual((self.root / "memories" / SERVER_FILE).read_bytes(), before)
+
+
 class NotNotifiedTest(MemoryTestCase):
     """Membre seulement mentionné, jamais prévenu de la mémoire : rien ne se note sur lui."""