瀏覽代碼

Mémoire : un administrateur n'écrit plus la fiche d'un membre pas encore prévenu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 天之前
父節點
當前提交
1506935d63
共有 6 個文件被更改,包括 72 次插入 和 11 次删除
  1. 1 1
      README.md
  2. 5 0
      bot/members.py
  3. 9 3
      bot/memory.py
  4. 19 6
      bot/memory_views.py
  5. 4 0
      changelog.txt
  6. 34 1
      tests/test_memory.py

+ 1 - 1
README.md

@@ -38,7 +38,7 @@ Avec `MEMORY=on` :
 
 - **Ce qui est retenu** : goûts, projets, habitudes, y compris ce que les autres membres racontent. Jamais de confidences ni de jugements ; une information sensible (santé, religion, opinions, adresse, argent…) seulement si le membre la donne lui-même, et le bot ne l'évoque ensuite qu'avec lui. Il se sert de ce qu'il sait dans la conversation, mais ne récite pas la fiche d'un membre aux autres.
 - **Présentation** : à son premier message, chaque membre reçoit une seule fois un message privé qui lui présente la mémoire et `/memoire`. Si ses messages privés sont fermés, le message est posté dans le salon et s'efface au bout d'une minute. Rien n'est noté sur un membre avant qu'il ait reçu cette présentation.
-- **`/memoire`** : chacun voit la fiche que le bot tient sur lui, dans un message visible par lui seul. Il peut la modifier dans une fenêtre déjà remplie, l'effacer (après confirmation) ou désactiver la mémoire : sa fiche et les lignes du fichier général qui citent un de ses noms Discord sont alors effacées (au-delà de 3 lignes, aucune n'est retirée et le log le signale à l'administrateur), son nom est remplacé par « un membre » là où il est cité comme source dans les autres fiches (« d'après … », « selon … »), et plus rien n'est noté sur lui, jusqu'à ce qu'il la réactive. Dans les deux cas, ses derniers messages restent dans l'historique de la conversation. Les administrateurs voient, modifient et effacent la fiche de n'importe quel membre avec `/memoire membre:@…` ; seul le membre lui-même peut désactiver la mémoire. Les membres en sont prévenus (message de bienvenue, et sous leur fiche dans `/memoire`), et chaque ouverture de la fiche d'un autre est notée dans le log (qui, quelle fiche, sans le contenu).
+- **`/memoire`** : chacun voit la fiche que le bot tient sur lui, dans un message visible par lui seul. Il peut la modifier dans une fenêtre déjà remplie, l'effacer (après confirmation) ou désactiver la mémoire : sa fiche et les lignes du fichier général qui citent un de ses noms Discord sont alors effacées (au-delà de 3 lignes, aucune n'est retirée et le log le signale à l'administrateur), son nom est remplacé par « un membre » là où il est cité comme source dans les autres fiches (« d'après … », « selon … »), et plus rien n'est noté sur lui, jusqu'à ce qu'il la réactive. Dans les deux cas, ses derniers messages restent dans l'historique de la conversation. Les administrateurs voient, modifient et effacent la fiche de n'importe quel membre avec `/memoire membre:@…` (la modifier seulement une fois que le membre a reçu la présentation) ; seul le membre lui-même peut désactiver la mémoire. Les membres en sont prévenus (message de bienvenue, et sous leur fiche dans `/memoire`), et chaque ouverture de la fiche d'un autre est notée dans le log (qui, quelle fiche, sans le contenu).
 - **`/memoire-serveur`** : le fichier général du serveur, à voir, modifier ou effacer. Réservé aux administrateurs.
 
 ### Modération

+ 5 - 0
bot/members.py

@@ -50,6 +50,11 @@ class MemberRegistry:
         entry = self.get(member_id)
         return entry is None or entry["memory"]
 
+    def notified(self, member_id: int | str) -> bool:
+        """Le membre a reçu la présentation de la mémoire : avant, rien n'est noté sur lui."""
+        entry = self.get(member_id)
+        return entry is not None and entry["notified"]
+
     async def see(self, member_id: int, username: str, global_name: str | None,
                   nickname: str | None = None, in_guild: bool = True) -> None:
         """Enregistre un membre ou met ses noms à jour ; un nom remplacé rejoint les anciens pseudos.

+ 9 - 3
bot/memory.py

@@ -322,9 +322,15 @@ class MemoryStore:
             return f"Trop long : {len(text)} caractères pour {limit} au plus. Rien n'a été modifié."
         path = self.root / name
         async with self._lock:
-            # 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 text and (match := MEMBER_FILE.match(name)):
+                # Fenêtre /memoire ouverte avant la désactivation : la fiche ne peut plus qu'être effacée.
+                if not self.members.memory_enabled(match.group(1)):
+                    return "La mémoire est désactivée pour ce membre : rien n'a été enregistré."
+                # Comme pour l'outil (_check) : rien n'est noté sur un membre avant sa présentation,
+                # même par un administrateur. Une fiche existante peut toujours être effacée.
+                if not self.members.notified(match.group(1)):
+                    return ("Ce membre n'a pas encore reçu la présentation de la mémoire : rien n'a été "
+                            "enregistré. Rien ne peut être noté dans sa fiche avant son premier message.")
             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 ».")

+ 19 - 6
bot/memory_views.py

@@ -62,6 +62,10 @@ class MemoryCard:
     def memory_enabled(self) -> bool:
         return self.target.member_id is None or self.store.members.memory_enabled(self.target.member_id)
 
+    @property
+    def notified(self) -> bool:
+        return self.target.member_id is None or self.store.members.notified(self.target.member_id)
+
     def about(self) -> str:
         """« sur toi », « sur Penta » ou « sur le serveur »."""
         if self.target.member_id is None:
@@ -81,13 +85,20 @@ class MemoryCard:
             text = (f"{who} désactivé la mémoire : {self.bot_name} ne retient rien "
                     f"{self.about()} et ne voit aucune fiche dans ses messages.")
             return [discord.Embed(title=title, description=text, color=discord.Color.light_grey())]
-        # Le membre doit savoir que sa fiche n'est pas vue par lui seul (voir /memoire membre:@…).
-        admins = ADMINS_NOTE if self.target.member_id is not None and self.target.is_self else ""
+        if self.target.member_id is None:
+            note = ""
+        elif self.target.is_self:
+            note = ADMINS_NOTE  # le membre doit savoir que sa fiche n'est pas vue par lui seul
+        elif not self.notified:
+            note = (f"{self.target.name} n'a pas encore reçu la présentation de la mémoire : rien ne peut "
+                    f"être noté dans sa fiche avant son premier message.")
+        else:
+            note = ""
         if not content:
             embed = discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.",
                                   color=discord.Color.blurple())
-            if admins:
-                embed.set_footer(text=admins)
+            if note:
+                embed.set_footer(text=note)
             return [embed]
         size = len(content)
         if size > EMBEDS_TOTAL_MAX:  # seulement si MEMORY_*_LIMIT dépasse ce que Discord affiche
@@ -96,13 +107,15 @@ class MemoryCard:
                   for chunk in split_message(content, EMBED_MAX)]
         embeds[0].title = title
         footer = f"{size}/{self.store.limit(self.target.file)} caractères"
-        embeds[-1].set_footer(text=f"{footer} · {admins}" if admins else footer)
+        embeds[-1].set_footer(text=f"{footer} · {note}" if note else footer)
         return embeds
 
     def view(self) -> discord.ui.View:
         view = OwnerView(self.owner_id)
         content = self.store.read(self.target.file)
-        add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit, disabled=not self.memory_enabled)
+        # Pas encore prévenu : la fiche ne peut qu'être effacée (voir MemoryStore.overwrite).
+        add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit,
+                   disabled=not self.memory_enabled or not self.notified)
         add_button(view, "Tout effacer" if self.target.member_id is None else "Effacer la fiche", "🗑️",
                    discord.ButtonStyle.secondary, self.ask_erase, disabled=not content or not self.memory_enabled)
         # Seul le membre lui-même choisit d'activer ou non la mémoire.

+ 4 - 0
changelog.txt

@@ -121,6 +121,10 @@ Corrections
   (pseudo, nom d'utilisateur ou ancien pseudo) comme surnom dans sa
   fiche. Ce surnom est ignoré dans la liste des membres : Rika ne note
   plus dans sa fiche ce qu'on raconte sur l'autre.
+- Mémoire : un administrateur ne peut plus écrire la fiche d'un membre
+  qui n'a pas encore reçu la présentation de la mémoire (« Modifier »
+  grisé, avec une explication) ; il peut toujours l'effacer. Comme pour
+  Rika, rien n'est noté sur un membre avant son premier message.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 34 - 1
tests/test_memory.py

@@ -497,6 +497,37 @@ class EditWindowTest(MemoryTestCase):
         interaction.response.edit_message.assert_awaited_once()
 
 
+class NotNotifiedWriteTest(MemoryTestCase):
+    """Un administrateur ne peut pas écrire la fiche d'un membre pas encore prévenu, seulement l'effacer."""
+
+    async def asyncSetUp(self):
+        await super().asyncSetUp()
+        await self.members.see(333, "carl", "Carl")  # inscrit par /memoire membre:@Carl, jamais prévenu
+        self.card = MemoryCard(self.store, Target("membres/333.md", 333, "Carl"), owner_id=999, bot_name="Rika")
+
+    async def test_write_refused(self):
+        error = await self.store.overwrite("membres/333.md", "Carl est nul", by="admin")
+        self.assertIn("pas encore reçu la présentation", error)
+        self.assertFalse((self.root / "memories" / "membres" / "333.md").exists())
+
+    async def test_erase_allowed(self):
+        self.write("membres/333.md", "habite à Lyon")  # fiche écrite avant la correction
+        self.assertIsNone(await self.store.overwrite("membres/333.md", "", by="admin"))
+        self.assertFalse((self.root / "memories" / "membres" / "333.md").exists())
+
+    async def test_edit_button_disabled(self):
+        self.write("membres/333.md", "habite à Lyon")
+        buttons = {item.label: item.disabled for item in self.card.view().children}
+        self.assertTrue(buttons["Modifier"])
+        self.assertFalse(buttons["Effacer la fiche"])
+        self.assertIn("pas encore reçu la présentation", self.card.embeds()[-1].footer.text)
+
+    async def test_write_allowed_once_notified(self):
+        await self.members.set_notified(333)
+        self.assertIsNone(await self.store.overwrite("membres/333.md", "habite à Lyon", by="admin"))
+        self.assertFalse({item.label: item.disabled for item in self.card.view().children}["Modifier"])
+
+
 class AdminAccessTest(MemoryTestCase):
     """Les membres savent que les administrateurs voient leur fiche, et chaque consultation est tracée."""
 
@@ -509,7 +540,9 @@ class AdminAccessTest(MemoryTestCase):
         self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
         self.assertEqual(self.footer(own), f"13/3000 caractères · {ADMINS_NOTE}")
 
-    def test_other_files_without_note(self):
+    async def test_other_files_without_note(self):
+        await self.members.see(222, "alice", "Alice")
+        await self.members.set_notified(222)
         self.write("membres/222.md", "habite à Rome")
         self.assertEqual(self.footer(Target("membres/222.md", 222, "Alice")), "13/3000 caractères")
         self.assertIsNone(self.footer(Target(SERVER_FILE, None)))