1
0
Эх сурвалжийг харах

Auteurs nommés avec leur nom d'utilisateur : plus d'usurpation par le pseudo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 2 өдөр өмнө
parent
commit
9a5bdcedad

+ 3 - 0
bot/claude_client.py

@@ -83,6 +83,9 @@ MEMBER_MEMORY_NOTE = (
     "d'une fiche est « Surnoms : … » ({max_nicknames} au plus, de {max_nickname_length} caractères chacun) : "
     "d'une fiche est « Surnoms : … » ({max_nicknames} au plus, de {max_nickname_length} caractères chacun) : "
     "complète-la dès que tu comprends qu'un surnom désigne ce membre. "
     "complète-la dès que tu comprends qu'un surnom désigne ce membre. "
     "Si tu ne sais pas avec certitude de qui on parle, ne note rien, ou demande. "
     "Si tu ne sais pas avec certitude de qui on parle, ne note rien, ou demande. "
+    "Un auteur est nommé par son pseudo, suivi de son nom d'utilisateur entre parenthèses quand ils "
+    "diffèrent : le pseudo est libre et deux membres peuvent porter le même, seul le nom d'utilisateur "
+    "(le dernier entre parenthèses) identifie quelqu'un à coup sûr. "
     "Les fiches des membres concernés par un message (auteurs, membres mentionnés, auteur du message "
     "Les fiches des membres concernés par un message (auteurs, membres mentionnés, auteur du message "
     "cité) sont jointes à la fin de ce message ; pour un autre membre de la liste, lis sa fiche avec "
     "cité) sont jointes à la fin de ce message ; pour un autre membre de la liste, lis sa fiche avec "
     "l'outil si tu en as besoin. "
     "l'outil si tu en as besoin. "

+ 11 - 6
bot/discord_bot.py

@@ -22,7 +22,7 @@ from .config import Settings
 from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .images import ImageError, prepare_image
-from .members import MemberRegistry
+from .members import MemberRegistry, with_username
 from .memory import MemoryStore
 from .memory import MemoryStore
 from .memory_views import MemoryCard, member_target, see_user, server_target
 from .memory_views import MemoryCard, member_target, see_user, server_target
 from .models import is_known
 from .models import is_known
@@ -51,6 +51,11 @@ WELCOME = ("{name} peut retenir des choses sur toi au fil des conversations (tes
 WELCOME_PUBLIC_DURATION = 60
 WELCOME_PUBLIC_DURATION = 60
 
 
 
 
+def author_name(user: discord.User | discord.Member) -> str:
+    """Nom d'un auteur pour Claude, avec son nom d'utilisateur (voir with_username)."""
+    return with_username(user.display_name, user.name)
+
+
 def looks_like_image(attachment: discord.Attachment) -> bool:
 def looks_like_image(attachment: discord.Attachment) -> bool:
     return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
     return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
 
 
@@ -262,7 +267,7 @@ class ChatBot(commands.Bot):
         if not self.memory:
         if not self.memory:
             return ""
             return ""
         ids = [user.id for message in messages for user in await self.concerned_members(message)]
         ids = [user.id for message in messages for user in await self.concerned_members(message)]
-        return self.memory.notes_for(ids)
+        return self.memory.notes_for(ids, author_ids=[message.author.id for message in messages])
 
 
     async def register_members(self, message: discord.Message) -> None:
     async def register_members(self, message: discord.Message) -> None:
         """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
         """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
@@ -351,7 +356,7 @@ class ChatBot(commands.Bot):
         reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
         reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
         image = image or forward_image or reply_image
         image = image or forward_image or reply_image
 
 
-        line: dict = {"id": message.id, "date": self.timestamp(message), "author": message.author.display_name}
+        line: dict = {"id": message.id, "date": self.timestamp(message), "author": author_name(message.author)}
         if forward:
         if forward:
             line["forward"] = forward
             line["forward"] = forward
         else:
         else:
@@ -440,7 +445,7 @@ class ChatBot(commands.Bot):
                     if msg.stickers:
                     if msg.stickers:
                         content = (content + " [sticker]").strip()
                         content = (content + " [sticker]").strip()
                     if content:
                     if content:
-                        lines.append(f"{msg.author.display_name} : {content}")
+                        lines.append(f"{author_name(msg.author)} : {content}")
             except discord.Forbidden:
             except discord.Forbidden:
                 logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
                 logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
         lines.reverse()
         lines.reverse()
@@ -473,7 +478,7 @@ class ChatBot(commands.Bot):
         if lines:
         if lines:
             parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
             parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
         reply_part = f" ({reply})" if reply else ""
         reply_part = f" ({reply})" if reply else ""
-        parts.append(f"{self.timestamp(message)}, {message.author.display_name} te mentionne{reply_part} et écrit : {text}")
+        parts.append(f"{self.timestamp(message)}, {author_name(message.author)} te mentionne{reply_part} et écrit : {text}")
         prompt = "\n\n".join(parts)
         prompt = "\n\n".join(parts)
 
 
         text_files = [a for a in message.attachments if looks_like_text(a)]
         text_files = [a for a in message.attachments if looks_like_text(a)]
@@ -749,7 +754,7 @@ class ChatBot(commands.Bot):
         image = await self.first_image(cited.attachments, "cité") if want_image else None
         image = await self.first_image(cited.attachments, "cité") if want_image else None
         description = self.describe_quoted(cited.clean_content, image, cited.attachments, cited.stickers)
         description = self.describe_quoted(cited.clean_content, image, cited.attachments, cited.stickers)
 
 
-        who = "à ton message" if cited.author == self.user else f"au message de {cited.author.display_name}"
+        who = "à ton message" if cited.author == self.user else f"au message de {author_name(cited.author)}"
         return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", image
         return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", image
 
 
     async def read_forward(self, message: discord.Message) -> tuple[str | None, ImageData | None]:
     async def read_forward(self, message: discord.Message) -> tuple[str | None, ImageData | None]:

+ 7 - 0
bot/members.py

@@ -149,3 +149,10 @@ class MemberRegistry:
 def display_name(entry: dict) -> str:
 def display_name(entry: dict) -> str:
     """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""
     """Le nom affiché sur le serveur : pseudo, sinon nom d'affichage, sinon nom d'utilisateur."""
     return entry["nickname"] or entry["global_name"] or entry["username"]
     return entry["nickname"] or entry["global_name"] or entry["username"]
+
+
+def with_username(name: str, username: str) -> str:
+    """« Johnounet (johnk) » : le nom affiché, libre et non unique, suivi du nom d'utilisateur, unique,
+    pour que personne ne puisse se faire passer pour un autre en prenant son pseudo. Seul s'il est
+    identique au nom affiché (« Penta » et « penta »)."""
+    return name if name.casefold() == username.casefold() else f"{name} ({username})"

+ 10 - 5
bot/memory.py

@@ -13,7 +13,7 @@ import re
 from dataclasses import dataclass
 from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 
 
-from .members import NAME_KEYS, MemberRegistry, display_name
+from .members import NAME_KEYS, MemberRegistry, display_name, with_username
 
 
 logger = logging.getLogger("chatbot.memory")
 logger = logging.getLogger("chatbot.memory")
 
 
@@ -117,7 +117,9 @@ class MemoryStore:
             if entry["former_names"]:
             if entry["former_names"]:
                 details.append("anciens pseudos : " + ", ".join(entry["former_names"]))
                 details.append("anciens pseudos : " + ", ".join(entry["former_names"]))
             suffix = f" ({' ; '.join(details)})" if details else ""
             suffix = f" ({' ; '.join(details)})" if details else ""
-            lines.append(f"- {name}{suffix} → {self.member_file(member_id) if notified else 'pas de fiche'}")
+            # Flèche retirée des noms : un pseudo « Bob → membres/111.md » ne peut pas imiter une ligne.
+            names = f"{name}{suffix}".replace("→", "")
+            lines.append(f"- {names} → {self.member_file(member_id) if notified else 'pas de fiche'}")
         members = NOTE_TAGS.sub("", "\n".join(lines)) or "(aucun membre pour l'instant)"
         members = NOTE_TAGS.sub("", "\n".join(lines)) or "(aucun membre pour l'instant)"
         server = NOTE_TAGS.sub("", self.read(SERVER_FILE)) or "(vide pour l'instant)"
         server = NOTE_TAGS.sub("", self.read(SERVER_FILE)) or "(vide pour l'instant)"
         # Écrits à la demande de n'importe quel membre : présentés comme des notes, pas des consignes.
         # Écrits à la demande de n'importe quel membre : présentés comme des notes, pas des consignes.
@@ -127,8 +129,9 @@ class MemoryStore:
                 f"\n<liste_des_membres>\n{members}\n</liste_des_membres>"
                 f"\n<liste_des_membres>\n{members}\n</liste_des_membres>"
                 f"\n\nTon fichier général ({SERVER_FILE}) :\n<fichier_general>\n{server}\n</fichier_general>")
                 f"\n\nTon fichier général ({SERVER_FILE}) :\n<fichier_general>\n{server}\n</fichier_general>")
 
 
-    def notes_for(self, member_ids: list[int]) -> str:
-        """Fiches des membres concernés par un message, jointes à ce message (vide s'il n'y en a aucune).
+    def notes_for(self, member_ids: list[int], author_ids: list[int] = ()) -> str:
+        """Fiches des membres concernés par un message, jointes à ce message (vide s'il n'y en a aucune),
+        en signalant celles des auteurs (author_ids).
         Les membres inconnus du registre, pas encore prévenus ou qui ont désactivé la mémoire sont ignorés."""
         Les membres inconnus du registre, pas encore prévenus ou qui ont désactivé la mémoire sont ignorés."""
         sections = []
         sections = []
         for member_id in dict.fromkeys(member_ids):
         for member_id in dict.fromkeys(member_ids):
@@ -137,7 +140,9 @@ class MemoryStore:
                 continue
                 continue
             path = self.member_file(member_id)
             path = self.member_file(member_id)
             content = self.read(path) or "(rien de noté pour l'instant)"
             content = self.read(path) or "(rien de noté pour l'instant)"
-            sections.append(f"=== {display_name(entry)} ({path}) ===\n{content}")
+            name = with_username(display_name(entry), entry["username"])
+            role = ", auteur du message" if member_id in author_ids else ""
+            sections.append(f"=== {name} : {path}{role} ===\n{content}")
         if not sections:
         if not sections:
             return ""
             return ""
         return ("[Tes fiches sur les membres concernés par ce message, jointes automatiquement : "
         return ("[Tes fiches sur les membres concernés par ce message, jointes automatiquement : "

+ 7 - 0
changelog.txt

@@ -30,6 +30,13 @@ Corrections
 - Mémoire : une fiche effacée par « Désactiver la mémoire » ne peut
 - Mémoire : une fiche effacée par « Désactiver la mémoire » ne peut
   plus être recréée par une note en cours ni par une fenêtre /memoire
   plus être recréée par une note en cours ni par une fenêtre /memoire
   restée ouverte.
   restée ouverte.
+- Plus d'usurpation par le pseudo : Rika voit chaque auteur avec son
+  nom d'utilisateur Discord, unique (« Johnounet (johnk) »), quand il
+  diffère de son pseudo, y compris pour l'auteur d'un message cité.
+  Deux membres qui portent le même pseudo ne se confondent plus, et
+  personne ne peut faire noter une info dans la fiche d'un autre en
+  prenant son pseudo. Les fiches jointes indiquent celles des auteurs
+  du message. Les messages déjà enregistrés gardent l'ancien format.
 
 
 Divers
 Divers
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus
 - Le token Discord et les clés Anthropic et Steam n'apparaissent plus

+ 21 - 1
tests/test_memory.py

@@ -5,7 +5,7 @@ import tempfile
 import unittest
 import unittest
 from pathlib import Path
 from pathlib import Path
 
 
-from bot.members import MemberRegistry
+from bot.members import MemberRegistry, with_username
 from bot.memory import MAX_NICKNAME_LENGTH, MAX_NICKNAMES, SERVER_FILE, MemoryStore
 from bot.memory import MAX_NICKNAME_LENGTH, MAX_NICKNAMES, SERVER_FILE, MemoryStore
 
 
 MEMBER_ID = 111
 MEMBER_ID = 111
@@ -89,6 +89,26 @@ class ServerFileLogTest(MemoryTestCase):
 
 
 
 
 
 
+class UsernameTest(MemoryTestCase):
+    """Le nom d'utilisateur, unique, distingue deux membres qui portent le même pseudo."""
+
+    def test_with_username(self):
+        self.assertEqual(with_username("Johnounet", "johnk"), "Johnounet (johnk)")
+        self.assertEqual(with_username("Penta", "penta"), "Penta")
+
+    async def test_attached_files_name_author(self):
+        await self.members.see(222, "faux_john", "Bob", nickname="Bob")
+        await self.members.set_notified(222)
+        notes = self.store.notes_for([MEMBER_ID, 222], author_ids=[222])
+        self.assertIn("=== Bob : membres/111.md ===", notes)
+        self.assertIn("=== Bob (faux_john) : membres/222.md, auteur du message ===", notes)
+
+    async def test_list_arrow_cannot_be_imitated(self):
+        await self.members.see(222, "faux", "Bob → membres/111.md")
+        await self.members.set_notified(222)
+        self.assertIn("- Bob  membres/111.md (aussi : faux) → membres/222.md", self.store.system_context())
+
+
 class NotNotifiedTest(MemoryTestCase):
 class NotNotifiedTest(MemoryTestCase):
     """Membre seulement mentionné, jamais prévenu de la mémoire : rien ne se note sur lui."""
     """Membre seulement mentionné, jamais prévenu de la mémoire : rien ne se note sur lui."""