Ver Fonte

Registre des membres illisible : mémoire coupée au lieu de tout oublier

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet há 2 dias atrás
pai
commit
9915eec4a9
5 ficheiros alterados com 110 adições e 20 exclusões
  1. 1 1
      README.md
  2. 13 3
      bot/discord_bot.py
  3. 17 16
      bot/members.py
  4. 5 0
      changelog.txt
  5. 74 0
      tests/test_members.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_sources.json` | Listes d'animes, de musiques et de jeux, mises à jour une fois par jour |
 | `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/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, la mémoire des membres est coupée (comme avec `MEMORY=off`) et le fichier n'est pas modifié : corrige-le d'après le log, puis redémarre le bot. Le supprimer repart de zéro, en réactivant la mémoire de ceux qui l'avaient désactivée |
 | `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é. Après une modification directe sur le disque, redémarre le bot pour que la liste des membres (fiches existantes et surnoms) en tienne compte ; `/memoire` n'a pas besoin de redémarrage |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 

+ 13 - 3
bot/discord_bot.py

@@ -130,6 +130,8 @@ class ChatBot(commands.Bot):
         )
         self.settings = settings
         self.members = MemberRegistry(settings.memory_dir / "registre.json") if settings.memory else None
+        if self.members and self.members.broken:
+            self.members = None  # registre illisible : mémoire coupée comme avec MEMORY=off (voir MemberRegistry)
         self.memory = MemoryStore(settings.memory_dir, self.members, settings.memory_member_limit,
                                   settings.memory_server_limit) if self.members else None
         self.claude = ClaudeClient(settings, self.memory)
@@ -162,9 +164,10 @@ class ChatBot(commands.Bot):
             # Lecture de toutes les fiches, avant la connexion : elle ne bloque aucun message.
             await asyncio.to_thread(self.memory.index)
         s = self.settings
+        memory = "off" if not s.memory else f"on ({s.memory_dir})" if self.memory else "coupée (registre illisible)"
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
                     s.model, "on" if s.thinking else "off", s.effort or "par défaut",
-                    "on" if s.web_search else "off", f"on ({s.memory_dir})" if s.memory else "off")
+                    "on" if s.web_search else "off", memory)
         if not is_known(s.model):
             # Un nom mal écrit ne serait sinon découvert qu'au premier message (erreur 404).
             prices = ""
@@ -911,6 +914,13 @@ async def skip_command(interaction: discord.Interaction) -> None:
 
 
 MEMORY_OFF = "La mémoire des membres est désactivée sur ce bot (MEMORY=off)."
+MEMORY_BROKEN = ("La mémoire des membres est coupée pour l'instant, à cause d'une erreur du bot. "
+                 "Préviens l'administrateur du bot.")
+
+
+def memory_off(bot: ChatBot) -> str:
+    """Pourquoi /memoire est indisponible : MEMORY=off, ou registre des membres illisible."""
+    return MEMORY_BROKEN if bot.settings.memory else MEMORY_OFF
 
 
 def bot_name(interaction: discord.Interaction) -> str:
@@ -923,7 +933,7 @@ def bot_name(interaction: discord.Interaction) -> str:
 async def memory_command(interaction: discord.Interaction, membre: discord.Member | None = None) -> None:
     bot: ChatBot = interaction.client  # type: ignore[assignment]
     if bot.memory is None:
-        await interaction.response.send_message(MEMORY_OFF, ephemeral=True)
+        await interaction.response.send_message(memory_off(bot), ephemeral=True)
         return
     target = membre or interaction.user
     if target.bot:
@@ -950,6 +960,6 @@ async def server_memory_command(interaction: discord.Interaction) -> None:
         return
     bot: ChatBot = interaction.client  # type: ignore[assignment]
     if bot.memory is None:
-        await interaction.response.send_message(MEMORY_OFF, ephemeral=True)
+        await interaction.response.send_message(memory_off(bot), ephemeral=True)
         return
     await MemoryCard(bot.memory, server_target(), interaction.user.id, bot_name(interaction)).send(interaction)

+ 17 - 16
bot/members.py

@@ -26,6 +26,9 @@ class MemberRegistry:
 
     def __init__(self, file_path: Path):
         self.file_path = file_path
+        # Raison pour laquelle le fichier est illisible, ou None. Repartir d'un registre vide oublierait
+        # qui a désactivé la mémoire : la mémoire est alors coupée, et le fichier n'est jamais réécrit.
+        self.broken: str | None = None
         self._data: dict[str, dict] = self._load()
         self._save_lock = asyncio.Lock()
 
@@ -101,35 +104,33 @@ class MemberRegistry:
             raw = json.loads(self.file_path.read_text(encoding="utf-8"))
             if not isinstance(raw, dict) or not isinstance(raw.get("members"), dict):
                 raise ValueError("« members » absent ou n'est pas un dictionnaire")
+            return {key: self._sanitize(key, value) for key, value in raw["members"].items()}
         except (ValueError, OSError) as exc:  # JSON invalide, mauvais encodage ou structure inattendue
-            self._set_aside(exc)
+            self.broken = str(exc)
+            logger.error("Registre des membres illisible (%s) : mémoire des membres coupée jusqu'à sa "
+                         "réparation. Corrige %s puis redémarre le bot ; le supprimer repartirait de zéro, "
+                         "en réactivant la mémoire de ceux qui l'avaient désactivée.", exc, self.file_path)
             return {}
-        return {key: entry for key, value in raw["members"].items() if (entry := self._sanitize(value))}
 
     @staticmethod
-    def _sanitize(value) -> dict | None:
-        """Une entrée du fichier, complétée si besoin, ou None si elle est illisible."""
+    def _sanitize(key: str, value) -> dict:
+        """Une entrée du fichier, complétée si besoin. ValueError si elle est illisible, ou si on ne sait
+        pas si la mémoire est activée : la croire activée reviendrait à noter sur qui l'a désactivée."""
         if not isinstance(value, dict) or not isinstance(value.get("username"), str):
-            return None
+            raise ValueError(f"membre {key} : entrée illisible")
+        if not isinstance(value.get("memory"), bool):
+            raise ValueError(f"membre {key} : « memory » doit valoir true ou false")
         entry = {name_key: value.get(name_key) if isinstance(value.get(name_key), str) else None for name_key in NAME_KEYS}
         former = value.get("former_names")
         entry["former_names"] = [n for n in former if isinstance(n, str)] if isinstance(former, list) else []
         entry["notified"] = value.get("notified") is True
-        entry["memory"] = value.get("memory") is not False
+        entry["memory"] = value["memory"]
         entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
         return entry
 
-    def _set_aside(self, exc: Exception) -> None:
-        """Met de côté un fichier illisible, sous un nom daté, comme pour l'historique."""
-        stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
-        backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
-        logger.error("Registre des membres illisible (%s). Copie de secours : %s", exc, backup)
-        try:
-            os.replace(self.file_path, backup)
-        except OSError as err:
-            logger.error("Copie de secours impossible : %s", err)
-
     async def _save(self) -> None:
+        if self.broken:
+            return  # le fichier illisible attend sa réparation : l'écraser perdrait les désactivations
         payload = {"version": FILE_VERSION, "members": self._data}
         text = json.dumps(payload, ensure_ascii=False, indent=2)
         async with self._save_lock:

+ 5 - 0
changelog.txt

@@ -64,6 +64,11 @@ Corrections
   bienvenue le précise, ainsi que le fait que les autres peuvent lui
   raconter des choses sur toi. Les fiches déjà écrites ne sont pas
   modifiées.
+- Registre des membres illisible (memories/registre.json abîmé ou mal
+  modifié à la main) : la mémoire des membres est coupée jusqu'à sa
+  réparation, au lieu de repartir d'un registre vide qui réactivait la
+  mémoire de ceux qui l'avaient désactivée. Le fichier n'est plus mis
+  de côté ni réécrit ; le log et /memoire signalent la panne.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 74 - 0
tests/test_members.py

@@ -0,0 +1,74 @@
+"""Tests du registre des membres. Lancer depuis la racine du projet : python -m unittest"""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from bot.members import MemberRegistry
+
+ENTRY = {"username": "bob", "global_name": "Bob", "nickname": None, "former_names": [],
+         "notified": True, "memory": False, "first_seen": "2026-10-31"}
+
+
+class BrokenRegistryTest(unittest.IsolatedAsyncioTestCase):
+    """Registre illisible : rien n'est oublié, la mémoire est coupée jusqu'à sa réparation."""
+
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.path = Path(self._tmp.name) / "registre.json"
+
+    def tearDown(self):
+        self._tmp.cleanup()
+
+    def load(self, members: dict | None = None, text: str | None = None) -> MemberRegistry:
+        if text is None:
+            text = json.dumps({"version": 1, "members": members})
+        self.path.write_text(text, encoding="utf-8")
+        return MemberRegistry(self.path)
+
+    async def assert_broken_and_kept(self, registry: MemberRegistry) -> None:
+        before = self.path.read_bytes()
+        self.assertIsNotNone(registry.broken)
+        await registry.see(222, "alice", "Alice")  # rien n'est écrit
+        self.assertEqual(self.path.read_bytes(), before)
+        self.assertEqual(list(self.path.parent.iterdir()), [self.path])  # ni copie, ni fichier temporaire
+
+    async def test_truncated_file(self):
+        text = json.dumps({"version": 1, "members": {"111": ENTRY}})
+        with self.assertLogs("chatbot.members", "ERROR"):
+            registry = self.load(text=text[:-20])
+        await self.assert_broken_and_kept(registry)
+
+    async def test_malformed_memory_field(self):
+        with self.assertLogs("chatbot.members", "ERROR"):
+            registry = self.load({"111": {**ENTRY, "memory": "non"}})
+        await self.assert_broken_and_kept(registry)
+
+    async def test_missing_memory_field(self):
+        entry = dict(ENTRY)
+        del entry["memory"]
+        with self.assertLogs("chatbot.members", "ERROR"):
+            registry = self.load({"111": entry})
+        await self.assert_broken_and_kept(registry)
+
+    async def test_entry_without_username(self):
+        with self.assertLogs("chatbot.members", "ERROR"):
+            registry = self.load({"111": {**ENTRY, "username": None}})
+        await self.assert_broken_and_kept(registry)
+
+    async def test_valid_file(self):
+        registry = self.load({"111": ENTRY})
+        self.assertIsNone(registry.broken)
+        self.assertFalse(registry.memory_enabled(111))
+        await registry.see(222, "alice", "Alice")
+        self.assertIn("222", json.loads(self.path.read_text(encoding="utf-8"))["members"])
+
+    async def test_missing_file(self):
+        registry = MemberRegistry(self.path)
+        self.assertIsNone(registry.broken)
+        self.assertEqual(registry.members(), {})
+
+
+if __name__ == "__main__":
+    unittest.main()