Переглянути джерело

Mémoire : « Modifier » répond tout de suite à Discord, plus de faux conflit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 днів тому
батько
коміт
b44f7d5a3a
4 змінених файлів з 43 додано та 10 видалено
  1. 4 0
      CHANGELOG.md
  2. 3 2
      bot/memory.py
  3. 10 3
      bot/memory_views.py
  4. 26 5
      tests/test_memory.py

+ 4 - 0
CHANGELOG.md

@@ -12,6 +12,10 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Un message de démarrage plus soigné.** Le bloc « Bot en ligne » affiche maintenant l'avatar du bot, sa version, le modèle Claude utilisé (« Sonnet 5.5 ») et l'heure du démarrage, sur le vert du statut « en ligne » de Discord.
 
+### Corrections
+
+- **« Modifier » ne signale plus de faux conflit.** Quand l'écriture sur le disque prenait plus de 3 secondes, Discord considérait l'envoi comme échoué alors que le texte était enregistré : la fenêtre restait ouverte, et un second clic répondait que le fichier avait changé entre-temps. Le bot répond maintenant tout de suite à Discord, avant d'enregistrer, et un même texte envoyé deux fois est accepté.
+
 ---
 
 ## Version 5.11

+ 3 - 2
bot/memory.py

@@ -336,7 +336,7 @@ class MemoryStore:
 
         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."""
+        perdre une note de Claude. Sauf s'il contient déjà text (même envoi validé deux fois)."""
         text = "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
         limit = self.limit(name)
         if len(text) > limit:
@@ -349,7 +349,8 @@ class MemoryStore:
             if text and (match := MEMBER_FILE.match(name)) and not self.members.memory_enabled(match.group(1)):
                 return "La mémoire n'est pas activée pour ce membre : rien n'a été enregistré."
             current = self.read(name)
-            if expected is not None and current != expected:
+            # Fichier qui contient déjà ce texte : même envoi validé deux fois, ce n'est pas un conflit.
+            if expected is not None and current != expected and current != text:
                 return ("Le fichier a changé pendant ta modification (nouvelle note, ou autre modification) : "
                         "rien n'a été enregistré. Recommence avec « Modifier ».")
             try:

+ 10 - 3
bot/memory_views.py

@@ -154,6 +154,8 @@ class MemoryCard:
         self._view, view.interaction = view, interaction
         if new:
             await interaction.response.send_message(embeds=embeds, view=view, ephemeral=True)
+        elif interaction.response.is_done():  # réponse différée (voir EditModal.on_submit)
+            await interaction.edit_original_response(content=note, embeds=embeds, view=view)
         else:
             await interaction.response.edit_message(content=note, embeds=embeds, view=view)
 
@@ -175,13 +177,14 @@ class MemoryCard:
         """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:
+            # Réponse déjà différée par la fenêtre : le message part en suivi.
             if not text.strip():
-                await interaction.response.send_message(error, ephemeral=True)
+                await interaction.followup.send(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)
+            await interaction.followup.send(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é.")
 
@@ -318,6 +321,10 @@ 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())
+        # Discord laisse 3 secondes pour répondre, et l'écriture sur le disque peut prendre plus longtemps
+        # (fsync sur un volume réseau) : sans réponse, la fenêtre reste ouverte et un second clic renverrait
+        # le même texte, déjà enregistré.
+        await interaction.response.defer()
         await self.card.save(interaction, text, self.original)
 
     async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:

+ 26 - 5
tests/test_memory.py

@@ -433,6 +433,7 @@ class ConfirmationTextsTest(MemoryTestCase):
 
     async def question(self, target: Target, ask: str) -> str:
         interaction = mock.Mock()
+        interaction.response.is_done.return_value = False
         interaction.response.edit_message = mock.AsyncMock()
         card = MemoryCard(self.store, target, owner_id=MEMBER_ID, bot_name="Rika")
         await getattr(card, ask)(interaction)
@@ -468,6 +469,7 @@ class ConfirmationTextsTest(MemoryTestCase):
         self.assertIn("noté dans sa fiche avec ton nom", text)  # ses propres propos, montrés aux autres
         self.assertIn("administrateurs du serveur peuvent la voir", text)
         interaction = mock.Mock()
+        interaction.response.is_done.return_value = False
         interaction.response.edit_message = mock.AsyncMock()
         await card.enable(interaction)
         self.assertTrue(self.members.memory_enabled(222))
@@ -485,9 +487,12 @@ class EditWindowTest(MemoryTestCase):
         window.inputs[0]._value = text
         for text_input in window.inputs[1:]:
             text_input._value = ""
+        # La fenêtre diffère sa réponse : le résultat arrive en suivi, ou à la place du fichier affiché.
         interaction = mock.Mock()
-        interaction.response.send_message = mock.AsyncMock()
-        interaction.response.edit_message = mock.AsyncMock()
+        interaction.response.defer = mock.AsyncMock()
+        interaction.response.is_done.return_value = True
+        interaction.followup.send = mock.AsyncMock()
+        interaction.edit_original_response = mock.AsyncMock()
         return interaction
 
     async def test_line_removed_by_forget_not_restored(self):
@@ -499,7 +504,7 @@ class EditWindowTest(MemoryTestCase):
         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
+        message = interaction.followup.send.call_args
         self.assertIn("a changé pendant ta modification", message.args[0])
         self.assertIn(b"tournoi en mai", message.kwargs["file"].fp.getvalue())
 
@@ -516,7 +521,20 @@ class EditWindowTest(MemoryTestCase):
         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()
+        interaction.response.defer.assert_awaited_once()
+        interaction.edit_original_response.assert_awaited_once()
+
+    async def test_same_text_sent_twice_saved(self):
+        # Premier envoi enregistré, mais réponse trop lente pour Discord : la fenêtre reste ouverte et
+        # la personne valide à nouveau le même texte.
+        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 window.on_submit(self.submit(window, "habite à Paris"))
+        interaction = self.submit(window, "habite à Paris")
+        await window.on_submit(interaction)
+        self.assertEqual(self.store.read(f"membres/{MEMBER_ID}.md"), "habite à Paris")
+        interaction.followup.send.assert_not_awaited()
+        interaction.edit_original_response.assert_awaited_once()
 
 
 class ManualEditDateTest(MemoryTestCase):
@@ -691,8 +709,10 @@ class TooLongFileTest(MemoryTestCase):
         window = EditModal(self.card())
         window.inputs[0]._value = "a" * 3500
         interaction = self.interaction()
+        interaction.response.defer = mock.AsyncMock()
+        interaction.followup.send = mock.AsyncMock()
         await window.on_submit(interaction)
-        self.assertIn("Trop long", interaction.response.send_message.call_args.args[0])
+        self.assertIn("Trop long", interaction.followup.send.call_args.args[0])
         self.assertEqual(len(self.store.read(f"membres/{MEMBER_ID}.md")), 3500)
 
     async def test_beyond_discord_window(self):
@@ -797,6 +817,7 @@ class DepartedMemberTest(MemoryTestCase):
         self.write(SERVER_FILE, "soirée jeux le vendredi\nBob adore le chocolat")
         card = self.card(in_guild=False)
         interaction = mock.Mock()
+        interaction.response.is_done.return_value = False
         interaction.response.edit_message = mock.AsyncMock()
         await card.ask_disable(interaction)
         self.assertIn("a quitté le serveur", interaction.response.edit_message.call_args.kwargs["embeds"][0].description)