瀏覽代碼

Mémoire : fiche d'un membre parti accessible, désactivable par un administrateur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 天之前
父節點
當前提交
6cf2d14cd5
共有 5 個文件被更改,包括 79 次插入 和 10 次删除
  1. 1 1
      README.md
  2. 4 2
      bot/discord_bot.py
  3. 22 6
      bot/memory_views.py
  4. 5 0
      changelog.txt
  5. 47 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.
 - **Activation** : la mémoire est désactivée pour chacun par défaut. Un membre qui le souhaite l'active lui-même avec `/memoire`, après avoir lu ce qu'elle retient et qui peut voir sa fiche. Rien n'est noté sur un membre qui ne l'a pas activée, même quand les autres parlent de lui. Le bot n'envoie aucun message pour la présenter : c'est à vous d'en parler sur le serveur.
-- **`/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 si le membre a activé la mémoire) ; seul le membre lui-même peut l'activer ou la désactiver. Les membres en sont prévenus (avant d'activer la mémoire, 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 si le membre a activé la mémoire) ; seul le membre lui-même peut l'activer ou la désactiver. Exception : pour un membre parti du serveur (désigné par son identifiant), qui ne peut plus lancer `/memoire`, un administrateur peut désactiver la mémoire à sa place, ce qui efface tout comme s'il l'avait fait lui-même. Les membres en sont prévenus (avant d'activer la mémoire, 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

+ 4 - 2
bot/discord_bot.py

@@ -934,9 +934,11 @@ def bot_name(interaction: discord.Interaction) -> str:
 
 
 @app_commands.command(name="memoire", description="Voir, modifier ou effacer ce que le bot a retenu sur toi")
-@app_commands.describe(membre="Réservé aux administrateurs : la fiche d'un autre membre")
+@app_commands.describe(membre="Réservé aux administrateurs : la fiche d'un autre membre, ou son identifiant s'il est parti")
 @app_commands.guild_only()
-async def memory_command(interaction: discord.Interaction, membre: discord.Member | None = None) -> None:
+# discord.User plutôt que discord.Member : un membre parti du serveur reste accepté (Discord le résout par
+# son identifiant), pour que sa fiche puisse encore être effacée.
+async def memory_command(interaction: discord.Interaction, membre: discord.User | None = None) -> None:
     bot: ChatBot = interaction.client  # type: ignore[assignment]
     if bot.memory is None:
         await interaction.response.send_message(memory_off(bot), ephemeral=True)

+ 22 - 6
bot/memory_views.py

@@ -61,6 +61,7 @@ class Target:
     member_id: int | None
     name: str = ""        # nom du membre
     is_self: bool = False  # fiche de la personne qui a lancé la commande
+    departed: bool = False  # membre parti du serveur : un administrateur désactive sa mémoire à sa place
 
 
 class MemoryCard:
@@ -129,11 +130,12 @@ class MemoryCard:
         add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit, disabled=not self.memory_enabled)
         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)
-        # Seul le membre lui-même choisit d'activer ou non la mémoire.
-        if self.target.member_id is not None and self.target.is_self:
+        # Seul le membre lui-même active ou désactive la mémoire. Parti du serveur, il ne peut plus lancer
+        # /memoire : un administrateur peut alors la désactiver à sa place, pour tout effacer.
+        if self.target.member_id is not None and (self.target.is_self or self.target.departed):
             if self.memory_enabled:
                 add_button(view, "Désactiver la mémoire", "🚫", discord.ButtonStyle.danger, self.ask_disable)
-            else:
+            elif self.target.is_self:
                 add_button(view, "Activer la mémoire", "✅", discord.ButtonStyle.success, self.ask_enable)
         return view
 
@@ -199,6 +201,12 @@ class MemoryCard:
         await self.refresh(interaction, error or "C'est effacé.")
 
     async def ask_disable(self, interaction: discord.Interaction) -> None:
+        if self.target.departed:
+            question = (f"{self.target.name} a quitté le serveur. Désactiver sa mémoire efface définitivement sa "
+                        f"fiche et les lignes du fichier général de {self.bot_name} qui citent ce membre, et "
+                        f"retire son nom des sources (« d'après … ») dans les fiches des autres.")
+            await confirm(interaction, self, question, self.disable)
+            return
         question = (f"Si tu désactives la mémoire, ta fiche et les lignes du fichier général de "
                     f"{self.bot_name} qui te citent seront définitivement effacées, et ton nom sera retiré "
                     f"des sources (« d'après … ») dans les fiches des autres. {self.bot_name} ne notera plus "
@@ -208,8 +216,13 @@ class MemoryCard:
 
     async def disable(self, interaction: discord.Interaction) -> None:
         error = await self.store.forget(self.target.member_id, by=str(interaction.user))
-        await self.refresh(interaction, error or "Mémoire désactivée : ta fiche et ce qui te citait dans le "
-                                                 "fichier général ont été effacés, et ton nom retiré des sources.")
+        if self.target.departed:
+            done = (f"Mémoire désactivée pour {self.target.name} : sa fiche et ce qui citait ce membre dans le "
+                    f"fichier général ont été effacés, et son nom retiré des sources.")
+        else:
+            done = ("Mémoire désactivée : ta fiche et ce qui te citait dans le fichier général ont été effacés, "
+                    "et ton nom retiré des sources.")
+        await self.refresh(interaction, error or done)
 
     async def ask_enable(self, interaction: discord.Interaction) -> None:
         # Seule présentation de la mémoire au membre : tout ce qu'il doit savoir avant d'accepter.
@@ -337,7 +350,10 @@ def split_for_inputs(text: str, sizes: list[int]) -> list[str]:
 def member_target(store: MemoryStore, member: discord.User | discord.Member, owner_id: int) -> Target:
     entry = store.members.get(member.id)
     name = display_name(entry) if entry else member.display_name
-    return Target(store.member_file(member.id), member.id, name, is_self=member.id == owner_id)
+    # Hors du serveur, Discord donne un discord.User au lieu d'un discord.Member.
+    is_self = member.id == owner_id
+    departed = not is_self and not isinstance(member, discord.Member)
+    return Target(store.member_file(member.id), member.id, name, is_self=is_self, departed=departed)
 
 
 def server_target() -> Target:

+ 5 - 0
changelog.txt

@@ -138,6 +138,11 @@ Corrections
   à relancer la commande, au lieu de rester affichés sans répondre. La
   fenêtre « Modifier » peut être validée pendant une heure, au lieu de
   10 minutes.
+- Mémoire : la fiche d'un membre parti du serveur peut de nouveau être
+  ouverte avec /memoire membre: (par son identifiant). Avant, la
+  commande échouait, et lui-même ne pouvait plus la lancer : sa fiche
+  restait pour toujours. Un administrateur peut aussi désactiver sa
+  mémoire à sa place, ce qui efface tout comme s'il l'avait fait.
 - Mémoire : un membre ne peut plus prendre le nom Discord d'un autre
   (pseudo, nom d'utilisateur ou ancien pseudo) comme surnom dans sa
   fiche. Ce surnom est ignoré dans la liste des membres : Rika ne note

+ 47 - 1
tests/test_memory.py

@@ -12,7 +12,7 @@ import discord
 from bot.members import MemberRegistry, with_username
 from bot.discord_bot import memory_command
 from bot.memory_views import (ADMINS_NOTE, ERROR_MESSAGE, MODAL_TIMEOUT, EditModal, MemoryCard, Target, input_sizes,
-                              reply_error, split_for_inputs)
+                              member_target, reply_error, split_for_inputs)
 from bot.memory import (MAX_ATTACHED_NOTES, MAX_FORGOTTEN_LINES, MAX_NICKNAME_LENGTH, MAX_NICKNAMES,
                         SERVER_FILE, MemoryStore)
 
@@ -686,6 +686,52 @@ class ExpiryAndErrorsTest(MemoryTestCase):
         self.assertEqual(EditModal(self.card()).timeout, MODAL_TIMEOUT)
 
 
+class DepartedMemberTest(MemoryTestCase):
+    """Membre parti du serveur : il ne peut plus lancer /memoire, un administrateur efface tout à sa place."""
+
+    @staticmethod
+    def user(member_id: int, in_guild: bool) -> mock.Mock:
+        return mock.Mock(spec=discord.Member if in_guild else discord.User, id=member_id, display_name="Bob")
+
+    def card(self, in_guild: bool) -> MemoryCard:
+        target = member_target(self.store, self.user(MEMBER_ID, in_guild), owner_id=999)
+        return MemoryCard(self.store, target, owner_id=999, bot_name="Rika")
+
+    @staticmethod
+    def labels(card: MemoryCard) -> list[str]:
+        return [item.label for item in card.view().children]
+
+    def test_departed_detected(self):
+        self.assertTrue(member_target(self.store, self.user(MEMBER_ID, False), owner_id=999).departed)
+        self.assertFalse(member_target(self.store, self.user(MEMBER_ID, True), owner_id=999).departed)
+        self.assertFalse(member_target(self.store, self.user(999, True), owner_id=999).departed)
+
+    def test_admin_can_disable_only_for_departed(self):
+        self.assertIn("Désactiver la mémoire", self.labels(self.card(in_guild=False)))
+        self.assertNotIn("Désactiver la mémoire", self.labels(self.card(in_guild=True)))
+
+    async def test_admin_cannot_enable(self):
+        await self.members.set_memory(MEMBER_ID, False)
+        self.assertNotIn("Activer la mémoire", self.labels(self.card(in_guild=False)))
+
+    async def test_disable_erases_everything(self):
+        self.write(f"membres/{MEMBER_ID}.md", "habite à Lyon")
+        self.write("membres/222.md", "part à Rome (d'après Bob)")
+        self.write(SERVER_FILE, "soirée jeux le vendredi\nBob adore le chocolat")
+        card = self.card(in_guild=False)
+        interaction = mock.Mock()
+        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)
+        interaction.user.__str__ = lambda self: "admin"
+        await card.disable(interaction)
+        self.assertFalse(self.members.memory_enabled(MEMBER_ID))
+        self.assertFalse((self.root / "memories" / "membres" / f"{MEMBER_ID}.md").exists())
+        self.assertEqual(self.server_file(), "soirée jeux le vendredi")
+        self.assertEqual(self.store.read("membres/222.md"), "part à Rome (d'après un membre)")
+        self.assertIn("Mémoire désactivée pour Bob", interaction.response.edit_message.call_args.kwargs["content"])
+
+
 class AdminAccessTest(MemoryTestCase):
     """Les membres savent que les administrateurs voient leur fiche, et chaque consultation est tracée."""