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

Mémoire : membres prévenus de l'accès des administrateurs, consultations journalisées

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 днів тому
батько
коміт
5bcc12d197
6 змінених файлів з 72 додано та 7 видалено
  1. 1 1
      README.md
  2. 5 1
      bot/discord_bot.py
  3. 11 3
      bot/memory_views.py
  4. 4 0
      changelog.txt
  5. 48 1
      tests/test_memory.py
  6. 3 1
      tests/test_prompts.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, 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.
+- **`/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-serveur`** : le fichier général du serveur, à voir, modifier ou effacer. Réservé aux administrateurs.
 
 ### Modération

+ 5 - 1
bot/discord_bot.py

@@ -46,7 +46,8 @@ DEPARTURE_WINDOW = timedelta(minutes=3)
 # Envoyé une seule fois à chaque membre, à son premier message, avec MEMORY=on.
 WELCOME = ("{name} peut retenir des choses sur toi au fil des conversations (tes goûts, tes projets…), "
            "y compris ce que d'autres membres lui racontent, pour mieux te connaître. Ce qui est retenu "
-           "sert dans la conversation, mais ta fiche n'est jamais récitée aux autres. Tape /memoire sur le serveur pour voir ce qui a été retenu, le modifier "
+           "sert dans la conversation, mais ta fiche n'est jamais récitée aux autres ; à part toi, seuls "
+           "les administrateurs du serveur peuvent la voir et la corriger. Tape /memoire sur le serveur pour voir ce qui a été retenu, le modifier "
            "ou désactiver la fonction.")
 # Durée d'affichage, en secondes, de ce message quand il est posté dans le salon.
 WELCOME_PUBLIC_DURATION = 60
@@ -981,6 +982,9 @@ async def memory_command(interaction: discord.Interaction, membre: discord.Membe
     await see_user(bot.members, target)  # un membre qui n'a encore jamais écrit peut déjà désactiver la mémoire
     if target.id == interaction.user.id:
         await bot.members.set_notified(target.id)  # il connaît /memoire : inutile de le lui présenter
+    else:
+        # Lecture tracée, sans le contenu : un administrateur ne peut pas lire les fiches sans laisser de trace.
+        logger.info("/memoire : fiche de %s ouverte par %s", target, interaction.user)
     card = MemoryCard(bot.memory, member_target(bot.memory, target, interaction.user.id),
                       interaction.user.id, bot_name(interaction))
     await card.send(interaction)

+ 11 - 3
bot/memory_views.py

@@ -24,6 +24,8 @@ EMBED_MAX = 4000
 EMBEDS_TOTAL_MAX = 5500
 # Durée pendant laquelle les boutons répondent (ensuite, il suffit de relancer la commande).
 VIEW_TIMEOUT = 600
+# Sous la fiche d'un membre, quand il la consulte lui-même.
+ADMINS_NOTE = "Les administrateurs du serveur peuvent aussi voir et corriger ta fiche."
 
 Callback = Callable[[discord.Interaction], Awaitable[None]]
 
@@ -78,16 +80,22 @@ 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 not content:
-            return [discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.",
-                                  color=discord.Color.blurple())]
+            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)
+            return [embed]
         size = len(content)
         if size > EMBEDS_TOTAL_MAX:  # seulement si MEMORY_*_LIMIT dépasse ce que Discord affiche
             content = content[:EMBEDS_TOTAL_MAX] + "\n[…] (la suite est visible dans « Modifier »)"
         embeds = [discord.Embed(description=chunk, color=discord.Color.blurple())
                   for chunk in split_message(content, EMBED_MAX)]
         embeds[0].title = title
-        embeds[-1].set_footer(text=f"{size}/{self.store.limit(self.target.file)} caractères")
+        footer = f"{size}/{self.store.limit(self.target.file)} caractères"
+        embeds[-1].set_footer(text=f"{footer} · {admins}" if admins else footer)
         return embeds
 
     def view(self) -> discord.ui.View:

+ 4 - 0
changelog.txt

@@ -106,6 +106,10 @@ Corrections
   ne sera retenu », et remplace maintenant le nom du membre par « un
   membre » là où il est cité comme source dans les fiches des autres
   (« d'après Bob », « selon Bob »).
+- Mémoire : les membres savent que les administrateurs du serveur
+  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.
 
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 48 - 1
tests/test_memory.py

@@ -1,13 +1,15 @@
 """Tests de la mémoire des membres. Lancer depuis la racine du projet : python -m unittest"""
 
 import asyncio
+import logging
 import tempfile
 import unittest
 from pathlib import Path
 from unittest import mock
 
 from bot.members import MemberRegistry, with_username
-from bot.memory_views import MemoryCard, Target
+from bot.discord_bot import memory_command
+from bot.memory_views import ADMINS_NOTE, MemoryCard, Target
 from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
                         SERVER_FILE, MemoryStore)
 
@@ -430,5 +432,50 @@ class ConfirmationTextsTest(MemoryTestCase):
         self.assertNotIn("plus rien ne sera retenu", text)
 
 
+class AdminAccessTest(MemoryTestCase):
+    """Les membres savent que les administrateurs voient leur fiche, et chaque consultation est tracée."""
+
+    def footer(self, target: Target) -> str | None:
+        return MemoryCard(self.store, target, owner_id=MEMBER_ID, bot_name="Rika").embeds()[-1].footer.text
+
+    def test_own_file_mentions_admins(self):
+        own = Target(f"membres/{MEMBER_ID}.md", MEMBER_ID, "Bob", is_self=True)
+        self.assertIn("administrateurs", self.footer(own))  # fiche vide
+        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):
+        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)))
+
+    async def open_file(self, user_id: int, target_id: int) -> list[str]:
+        bot = mock.Mock(memory=self.store, members=self.members)
+        interaction = mock.Mock(client=bot)
+        interaction.user = mock.Mock(id=user_id, bot=False, display_name="Admin")
+        interaction.user.name = "admin"
+        interaction.permissions.administrator = True
+        interaction.guild.me.display_name = "Rika"
+        interaction.response.send_message = mock.AsyncMock()
+        target = mock.Mock(id=target_id, bot=False, display_name="Bob", global_name="Bob")
+        target.name = "bob"
+        target.__str__ = lambda self: "bob"
+        with self.assertLogs("chatbot", "INFO") as logs:
+            logging.getLogger("chatbot").info("début")
+            await memory_command.callback(interaction, target)
+        interaction.response.send_message.assert_awaited_once()
+        return logs.output
+
+    async def test_other_file_opening_logged(self):
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        logs = await self.open_file(user_id=999, target_id=MEMBER_ID)
+        self.assertTrue(any("fiche de bob ouverte par" in line for line in logs))
+        self.assertFalse(any("Lyon" in line for line in logs))
+
+    async def test_own_file_not_logged(self):
+        logs = await self.open_file(user_id=MEMBER_ID, target_id=MEMBER_ID)
+        self.assertFalse(any("ouverte par" in line for line in logs))
+
+
 if __name__ == "__main__":
     unittest.main()

+ 3 - 1
tests/test_prompts.py

@@ -14,7 +14,9 @@ class PromptsTest(unittest.TestCase):
         self.assertIn("ne récite jamais la fiche", note)
 
     def test_welcome_formats(self):
-        self.assertIn("jamais récitée aux autres", WELCOME.format(name="Rika"))
+        text = WELCOME.format(name="Rika")
+        self.assertIn("jamais récitée aux autres", text)
+        self.assertIn("administrateurs du serveur peuvent la voir", text)
 
 
 if __name__ == "__main__":