瀏覽代碼

Mémoire : désactivation non enregistrée signalée au membre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 天之前
父節點
當前提交
60bf6c8c9b
共有 6 個文件被更改,包括 84 次插入 和 10 次删除
  1. 20 6
      bot/members.py
  2. 7 1
      bot/memory.py
  3. 3 3
      bot/memory_views.py
  4. 5 0
      changelog.txt
  5. 39 0
      tests/test_members.py
  6. 10 0
      tests/test_memory.py

+ 20 - 6
bot/members.py

@@ -32,6 +32,9 @@ class MemberRegistry:
         self.broken: str | None = None
         self._data: dict[str, dict] = self._load()
         self._save_lock = asyncio.Lock()
+        # Dernière sauvegarde ratée (disque plein, droits) : le fichier est en retard sur _data. La
+        # suivante, quelle qu'elle soit, réécrit tout et rattrape ce retard.
+        self._unsaved = False
 
     # ------------------------------------------------------------------ accès
 
@@ -89,12 +92,18 @@ class MemberRegistry:
             entry["notified"] = True
             await self._save()
 
-    async def set_memory(self, member_id: int | str, enabled: bool) -> None:
-        """Active ou désactive la mémoire pour ce membre."""
-        if (entry := self.get(member_id)) is not None and entry["memory"] != enabled:
+    async def set_memory(self, member_id: int | str, enabled: bool) -> bool:
+        """Active ou désactive la mémoire pour ce membre. Renvoie False si le réglage n'a pas pu être
+        enregistré : il s'applique quand même, mais serait perdu à un redémarrage avant la prochaine
+        sauvegarde réussie."""
+        if (entry := self.get(member_id)) is None:
+            return True
+        if entry["memory"] != enabled:
             entry["memory"] = enabled
             logger.info("Mémoire %s pour %s (%s)", "activée" if enabled else "désactivée", display_name(entry), member_id)
-            await self._save()
+        elif not self._unsaved:
+            return True
+        return await self._save()
 
     # ------------------------------------------------------------ internes
 
@@ -130,9 +139,10 @@ class MemberRegistry:
         entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
         return entry
 
-    async def _save(self) -> None:
+    async def _save(self) -> bool:
+        """Écrit le registre ; renvoie False s'il n'a pas pu l'être."""
         if self.broken:
-            return  # le fichier illisible attend sa réparation : l'écraser perdrait les désactivations
+            return False  # 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:
@@ -140,6 +150,10 @@ class MemberRegistry:
                 await asyncio.to_thread(write_atomic, self.file_path, text)
             except OSError as exc:
                 logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
+                self._unsaved = True
+                return False
+            self._unsaved = False
+            return True
 
 
 def display_name(entry: dict) -> str:

+ 7 - 1
bot/memory.py

@@ -42,6 +42,9 @@ MAX_FORGOTTEN_LINES = 3
 # Signes « = » en début de ligne, retirés du contenu des fiches jointes : une fiche ne peut pas imiter
 # l'en-tête d'une autre (« === Alice : membres/222.md, auteur du message === »).
 NOTE_HEADER = re.compile(r"^[ \t]*[==]+", re.MULTILINE)
+# Réglage de la mémoire appliqué mais pas enregistré dans le registre (voir MemberRegistry.set_memory).
+NOT_SAVED = ("C'est fait, mais {setting} n'a pas pu être enregistrée, à cause d'une erreur du bot : elle "
+             "serait perdue si le bot redémarrait avant qu'il puisse l'enregistrer. Préviens un administrateur.")
 # Fiches jointes au plus à un message : un message qui mentionne 90 membres n'en joint pas 90.
 MAX_ATTACHED_NOTES = 5
 
@@ -359,10 +362,13 @@ class MemoryStore:
             except OSError as exc:
                 logger.error("Mémoire : impossible d'effacer ce qui concerne %s : %s", name, exc)
                 return "Effacement impossible pour l'instant, à cause d'une erreur du bot."
-            await self.members.set_memory(member_id, False)
+            saved = await self.members.set_memory(member_id, False)
         # Le nombre seulement : recopier les lignes retirées garderait ce que le membre veut voir effacé.
         logger.info("Mémoire : %s effacé par %s, %d ligne(s) retirée(s) de %s",
                     name, by, len(server) - len(kept), SERVER_FILE)
+        if not saved:
+            # Effacement fait, désactivation appliquée : seul son enregistrement manque.
+            return NOT_SAVED.format(setting="la désactivation")
         return None
 
     # ------------------------------------------------------------ internes

+ 3 - 3
bot/memory_views.py

@@ -11,7 +11,7 @@ from dataclasses import dataclass
 import discord
 
 from .members import MemberRegistry, display_name
-from .memory import SERVER_FILE, MemoryStore
+from .memory import NOT_SAVED, SERVER_FILE, MemoryStore
 from .text_utils import split_message
 
 logger = logging.getLogger("chatbot.memory")
@@ -144,8 +144,8 @@ class MemoryCard:
                                                  "dans le fichier général ont été effacés.")
 
     async def enable(self, interaction: discord.Interaction) -> None:
-        await self.store.members.set_memory(self.target.member_id, True)
-        await self.refresh(interaction, "Mémoire réactivée.")
+        saved = await self.store.members.set_memory(self.target.member_id, True)
+        await self.refresh(interaction, "Mémoire réactivée." if saved else NOT_SAVED.format(setting="la réactivation"))
 
 
 class OwnerView(discord.ui.View):

+ 5 - 0
changelog.txt

@@ -86,6 +86,11 @@ Corrections
   lui-même. Si plus de 3 lignes le citent (pseudo qui est un mot
   courant, comme « Soleil »), aucune n'est retirée et le log demande à
   l'administrateur de vérifier avec /memoire-serveur.
+- Mémoire : si le registre des membres ne peut pas être enregistré
+  (disque plein, droits), « Désactiver la mémoire » et « Réactiver la
+  mémoire » le disent, au lieu d'annoncer un réglage qui serait perdu
+  au redémarrage du bot. Le réglage s'applique quand même, et la
+  prochaine sauvegarde réussie du registre l'enregistre.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 39 - 0
tests/test_members.py

@@ -4,6 +4,7 @@ import json
 import tempfile
 import unittest
 from pathlib import Path
+from unittest import mock
 
 from bot.members import MemberRegistry
 
@@ -79,5 +80,43 @@ class BrokenRegistryTest(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(registry.members(), {})
 
 
+class SaveFailureTest(unittest.IsolatedAsyncioTestCase):
+    """Registre impossible à écrire (disque plein, droits) : set_memory le signale."""
+
+    async def asyncSetUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.path = Path(self._tmp.name) / "registre.json"
+        self.registry = MemberRegistry(self.path)
+        await self.registry.see(111, "bob", "Bob")
+
+    async def asyncTearDown(self):
+        self._tmp.cleanup()
+
+    def saved_memory(self) -> bool:
+        return json.loads(self.path.read_text(encoding="utf-8"))["members"]["111"]["memory"]
+
+    async def test_failure_reported_then_caught_up(self):
+        with mock.patch("bot.members.write_atomic", side_effect=OSError("disque plein")), \
+                self.assertLogs("chatbot.members", "ERROR"):
+            self.assertFalse(await self.registry.set_memory(111, False))
+        self.assertFalse(self.registry.memory_enabled(111))  # appliqué quand même
+        self.assertTrue(self.saved_memory())
+        # Même réglage redemandé : la sauvegarde est retentée.
+        self.assertTrue(await self.registry.set_memory(111, False))
+        self.assertFalse(self.saved_memory())
+
+    async def test_any_later_save_catches_up(self):
+        with mock.patch("bot.members.write_atomic", side_effect=OSError("disque plein")), \
+                self.assertLogs("chatbot.members", "ERROR"):
+            await self.registry.set_memory(111, False)
+        await self.registry.see(222, "alice", "Alice")
+        self.assertFalse(self.saved_memory())
+
+    async def test_unchanged_setting_not_rewritten(self):
+        with mock.patch("bot.members.write_atomic") as write:
+            self.assertTrue(await self.registry.set_memory(111, True))
+        write.assert_not_called()
+
+
 if __name__ == "__main__":
     unittest.main()

+ 10 - 0
tests/test_memory.py

@@ -4,6 +4,7 @@ import asyncio
 import tempfile
 import unittest
 from pathlib import Path
+from unittest import mock
 
 from bot.members import MemberRegistry, with_username
 from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
@@ -352,6 +353,15 @@ class ForgetTest(MemoryTestCase):
         self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
         self.assertIsNone(await self.store.overwrite(f"membres/{MEMBER_ID}.md", "", by="bob"))
 
+    async def test_unsaved_disable_reported(self):
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        with mock.patch("bot.members.write_atomic", side_effect=OSError("disque plein")), \
+                self.assertLogs("chatbot.members", "ERROR"):
+            error = await self.store.forget(MEMBER_ID, by="bob")
+        self.assertIn("désactivation n'a pas pu être enregistrée", error)
+        self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
+        self.assertFalse(self.members.memory_enabled(MEMBER_ID))
+
     async def test_removed_lines_not_logged(self):
         self.write(SERVER_FILE, "Bob : adore les lasagnes")
         with self.assertLogs("chatbot.memory", "INFO") as logs: