Procházet zdrojové kódy

Mémoire : la fenêtre « Modifier » n'écrase plus un fichier changé depuis son ouverture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet před 2 dny
rodič
revize
0425166ba4
4 změnil soubory, kde provedl 76 přidání a 9 odebrání
  1. 9 2
      bot/memory.py
  2. 14 6
      bot/memory_views.py
  3. 7 0
      changelog.txt
  4. 46 1
      tests/test_memory.py

+ 9 - 2
bot/memory.py

@@ -299,9 +299,13 @@ class MemoryStore:
     def limit(self, name: str) -> int:
         return self.server_limit if name == SERVER_FILE else self.member_limit
 
-    async def overwrite(self, name: str, text: str, by: str) -> str | None:
+    async def overwrite(self, name: str, text: str, by: str, expected: str | None = None) -> str | None:
         """Remplace tout un fichier, ou le supprime si text est vide : modification faite par un membre
-        avec /memoire (by : son nom, pour les logs). Renvoie un message d'erreur, ou None."""
+        avec /memoire (by : son nom, pour les logs). Renvoie un message d'erreur, ou None.
+
+        expected : le contenu (lu avec read) à partir duquel text a été écrit ; si le fichier a changé
+        depuis, rien n'est enregistré, pour ne pas remettre ce qui a été retiré entre-temps (forget) ni
+        perdre une note de Claude."""
         text = "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
         limit = self.limit(name)
         if len(text) > limit:
@@ -311,6 +315,9 @@ class MemoryStore:
             # Fenêtre /memoire ouverte avant la désactivation : la fiche ne peut plus qu'être effacée.
             if text and (match := MEMBER_FILE.match(name)) and not self.members.memory_enabled(match.group(1)):
                 return "La mémoire est désactivée pour ce membre : rien n'a été enregistré."
+            if expected is not None and self.read(name) != expected:
+                return ("Le fichier a changé pendant ta modification (nouvelle note, ou autre modification) : "
+                        "rien n'a été enregistré. Recommence avec « Modifier ».")
             try:
                 if text:
                     await asyncio.to_thread(self._write_atomic, path, text)

+ 14 - 6
bot/memory_views.py

@@ -4,6 +4,7 @@ Tout s'affiche dans des messages éphémères : seule la personne qui a lancé l
 
 from __future__ import annotations
 
+import io
 import logging
 from collections.abc import Awaitable, Callable
 from dataclasses import dataclass
@@ -124,11 +125,17 @@ class MemoryCard:
     async def edit(self, interaction: discord.Interaction) -> None:
         await interaction.response.send_modal(EditModal(self))
 
-    async def save(self, interaction: discord.Interaction, text: str) -> None:
-        """Enregistre le texte de la fenêtre « Modifier »."""
-        error = await self.store.overwrite(self.target.file, text, by=str(interaction.user))
+    async def save(self, interaction: discord.Interaction, text: str, original: str | None = None) -> None:
+        """Enregistre le texte de la fenêtre « Modifier » (original : le fichier à son ouverture)."""
+        error = await self.store.overwrite(self.target.file, text, by=str(interaction.user), expected=original)
         if error:
-            await interaction.response.send_message(error, ephemeral=True)
+            if not text.strip():
+                await interaction.response.send_message(error, ephemeral=True)
+                return
+            # Le texte saisi est renvoyé en fichier joint (trop long pour un message) : rien n'est perdu.
+            file = discord.File(io.BytesIO(text.encode("utf-8")), filename="texte_non_enregistre.txt")
+            await interaction.response.send_message(f"{error} Ton texte est joint, pour le recopier.",
+                                                    file=file, ephemeral=True)
             return
         await self.refresh(interaction, "C'est enregistré." if text.strip() else "C'est effacé.")
 
@@ -203,7 +210,8 @@ class EditModal(discord.ui.Modal):
         self.card = card
         limit = card.store.limit(card.target.file)
         count = min(MAX_INPUTS, -(-limit // INPUT_MAX))
-        parts = split_for_inputs(card.store.read(card.target.file), count)
+        self.original = card.store.read(card.target.file)  # comparé à l'enregistrement (voir overwrite)
+        parts = split_for_inputs(self.original, count)
         self.inputs = []
         for i in range(count):
             label = "Contenu" if count == 1 else f"Contenu ({i + 1}/{count})"
@@ -217,7 +225,7 @@ class EditModal(discord.ui.Modal):
 
     async def on_submit(self, interaction: discord.Interaction) -> None:
         text = "\n".join(text_input.value.strip() for text_input in self.inputs if text_input.value.strip())
-        await self.card.save(interaction, text)
+        await self.card.save(interaction, text, self.original)
 
     async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
         logger.exception("Erreur pendant la modification de %s", self.card.target.file, exc_info=error)

+ 7 - 0
changelog.txt

@@ -110,6 +110,13 @@ Corrections
   peuvent voir et corriger leur fiche (message de bienvenue, et note
   sous leur fiche dans /memoire). Chaque ouverture de la fiche d'un
   autre membre avec /memoire est notée dans le log, sans son contenu.
+- Mémoire : la fenêtre « Modifier » n'enregistre plus rien si le
+  fichier a changé depuis son ouverture (note de Rika, désactivation
+  d'un membre qui retire des lignes du fichier général). Avant, elle
+  réécrivait le fichier tel qu'il était à l'ouverture : la note était
+  perdue, ou la ligne retirée revenait. Quand un texte n'est pas
+  enregistré (fichier changé, trop long…), il est renvoyé en fichier
+  joint pour ne pas être perdu.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 46 - 1
tests/test_memory.py

@@ -9,7 +9,7 @@ from unittest import mock
 
 from bot.members import MemberRegistry, with_username
 from bot.discord_bot import memory_command
-from bot.memory_views import ADMINS_NOTE, MemoryCard, Target
+from bot.memory_views import ADMINS_NOTE, EditModal, MemoryCard, Target
 from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
                         SERVER_FILE, MemoryStore)
 
@@ -432,6 +432,51 @@ class ConfirmationTextsTest(MemoryTestCase):
         self.assertNotIn("plus rien ne sera retenu", text)
 
 
+class EditWindowTest(MemoryTestCase):
+    """La fenêtre « Modifier » n'écrase pas ce qui a changé depuis son ouverture."""
+
+    def window(self, target: Target) -> EditModal:
+        return EditModal(MemoryCard(self.store, target, owner_id=999, bot_name="Rika"))
+
+    @staticmethod
+    def submit(window: EditModal, text: str) -> mock.Mock:
+        window.inputs[0]._value = text
+        for text_input in window.inputs[1:]:
+            text_input._value = ""
+        interaction = mock.Mock()
+        interaction.response.send_message = mock.AsyncMock()
+        interaction.response.edit_message = mock.AsyncMock()
+        return interaction
+
+    async def test_line_removed_by_forget_not_restored(self):
+        # Scénario de l'audit : un administrateur ouvre le fichier général, Bob désactive sa mémoire,
+        # l'administrateur ajoute une ligne et valide.
+        self.write(SERVER_FILE, "soirée jeux le vendredi\nBob adore le chocolat")
+        window = self.window(Target(SERVER_FILE, None))
+        await self.store.forget(MEMBER_ID, by="bob")
+        interaction = self.submit(window, "soirée jeux le vendredi\nBob adore le chocolat\ntournoi en mai")
+        await window.on_submit(interaction)
+        self.assertEqual(self.server_file(), "soirée jeux le vendredi")
+        message = interaction.response.send_message.call_args
+        self.assertIn("a changé pendant ta modification", message.args[0])
+        self.assertIn(b"tournoi en mai", message.kwargs["file"].fp.getvalue())
+
+    async def test_note_written_meanwhile_kept(self):
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        window = self.window(Target(f"membres/{MEMBER_ID}.md", MEMBER_ID, "Bob", is_self=True))
+        await self.tool(commande="ajouter", fichier=f"membres/{MEMBER_ID}.md", texte="joue à Zelda")
+        await window.on_submit(self.submit(window, "habite à Paris"))
+        self.assertEqual(self.store.read(f"membres/{MEMBER_ID}.md"), "habite à Lyon\njoue à Zelda")
+
+    async def test_unchanged_file_saved(self):
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        window = self.window(Target(f"membres/{MEMBER_ID}.md", MEMBER_ID, "Bob", is_self=True))
+        interaction = self.submit(window, "habite à Paris")
+        await window.on_submit(interaction)
+        self.assertEqual(self.store.read(f"membres/{MEMBER_ID}.md"), "habite à Paris")
+        interaction.response.edit_message.assert_awaited_once()
+
+
 class AdminAccessTest(MemoryTestCase):
     """Les membres savent que les administrateurs voient leur fiche, et chaque consultation est tracée."""