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

Mémoire des membres : registre des membres

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 өдөр өмнө
parent
commit
0447ca449a
8 өөрчлөгдсөн 200 нэмэгдсэн , 3 устгасан
  1. 1 0
      .dockerignore
  2. 6 0
      .env.exemple
  3. 1 0
      .gitignore
  4. 2 0
      README.md
  5. 14 1
      bot/config.py
  6. 16 2
      bot/discord_bot.py
  7. 151 0
      bot/members.py
  8. 9 0
      changelog.txt

+ 1 - 0
.dockerignore

@@ -14,6 +14,7 @@
 **/presence_state.json
 **/waiting_messages.json
 **/slash_commands.sha256
+**/memories/
 **/bot.log*
 
 .venv/

+ 6 - 0
.env.exemple

@@ -40,6 +40,12 @@ HISTORY_FILE=conversation_history.json
 # Pour une personnalité perso non publiée sur GitHub : personality_prompt.local.txt
 PERSONALITY_FILE=personality_prompt.txt
 
+# --- Mémoire des membres -------------------------------------------------
+# Fiches que Rika tient sur chaque membre : on ou off
+MEMORY=off
+# Dossier des fiches et du registre des membres. Vide = dossier « memories » à côté de HISTORY_FILE.
+MEMORY_DIR=
+
 # --- Images et PDF -------------------------------------------------------
 # Taille max d'une image en octets
 MAX_IMAGE_SIZE=25000000

+ 1 - 0
.gitignore

@@ -9,6 +9,7 @@ presence_sources.json
 presence_state.json
 waiting_messages.json
 slash_commands.sha256
+memories/
 .claude/
 .vscode/
 # Personnalité perso (peut contenir des infos privées)

+ 2 - 0
README.md

@@ -149,6 +149,7 @@ Le fichier `bot.log` (`LOG_FILE`) garde l'activité du bot, avec pour chaque app
 | `presence_sources.json` | Listes d'animes, de musiques et de jeux, mises à jour une fois par jour |
 | `presence_state.json` | Statut Discord en cours, repris après un redémarrage |
 | `slash_commands.sha256` | Empreinte des commandes slash, pour ne les envoyer à Discord que quand elles changent |
+| `memories/registre.json` | Avec `MEMORY=on` : membres vus par le bot, leurs noms et anciens pseudos, et leurs réglages de la mémoire. Si le fichier est illisible, il est mis de côté sous un nom daté |
 | `bot.log` | Logs (5 Mo par fichier, 2 anciens fichiers gardés) |
 
 Le détail de chaque version est dans `changelog.txt`.
@@ -161,6 +162,7 @@ bot/config.py            lecture et validation du .env
 bot/models.py            particularités de chaque modèle Claude (réflexion, tarifs)
 bot/claude_client.py     appels à l'API de Claude, personnalité
 bot/history.py           mémoire des conversations
+bot/members.py           registre des membres (noms, anciens pseudos, réglages de la mémoire)
 bot/discord_bot.py       événements Discord, réponses, /reset et /skip
 bot/emojis.py            emojis du serveur : liste pour Claude, noms remplacés à l'envoi
 bot/presence.py          statut Discord : tirage au sort et changements

+ 14 - 1
bot/config.py

@@ -132,6 +132,10 @@ class Settings:
     history_file: Path
     personality_file: Path
 
+    # Mémoire des membres : fiches tenues par Claude (voir members.py)
+    memory: bool
+    memory_dir: Path
+
     # Images et PDF
     max_image_size: int
     pdf_max_pages: int  # 0 : PDF refusés
@@ -185,6 +189,13 @@ def load_settings() -> Settings:
     if web_search_raw not in ("on", "off"):
         raise ConfigError(f"WEB_SEARCH doit valoir « on » ou « off » (valeur actuelle : {web_search_raw!r}).")
 
+    memory_raw = (_get_str("MEMORY", default="off") or "off").lower()
+    if memory_raw not in ("on", "off"):
+        raise ConfigError(f"MEMORY doit valoir « on » ou « off » (valeur actuelle : {memory_raw!r}).")
+    history_file = Path(_get_str("HISTORY_FILE", default="conversation_history.json"))
+    # Vide : à côté de l'historique, comme les autres fichiers du bot (donc sur le même volume avec Docker).
+    memory_dir = _get_str("MEMORY_DIR")
+
     presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
     if presence_raw not in ("on", "off"):
         raise ConfigError(f"PRESENCE doit valoir « on » ou « off » (valeur actuelle : {presence_raw!r}).")
@@ -205,8 +216,10 @@ def load_settings() -> Settings:
         web_max_uses=_get_int("WEB_MAX_USES", default=3, minimum=1),
         max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
         context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
-        history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
+        history_file=history_file,
         personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
+        memory=memory_raw == "on",
+        memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
         # Chaque page coûte son texte et son image (souvent plusieurs milliers de tokens).

+ 16 - 2
bot/discord_bot.py

@@ -22,6 +22,7 @@ from .config import Settings
 from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
+from .members import MemberRegistry
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .presence import PresenceManager, Status
 from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
@@ -115,6 +116,7 @@ class ChatBot(commands.Bot):
         self.settings = settings
         self.claude = ClaudeClient(settings)
         self.history = ConversationHistory(settings.history_file, settings.max_history_length)
+        self.members = MemberRegistry(settings.memory_dir / "registre.json") if settings.memory else None
         self._pending: dict[int, list[PendingMessage]] = {}  # messages en attente de réponse, par salon
         self._announced = False
         self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
@@ -137,9 +139,9 @@ class ChatBot(commands.Bot):
             self.tree.add_command(skip_command)
         await self.sync_commands()
         s = self.settings
-        logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s",
+        logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
                     s.model, "on" if s.thinking else "off", s.effort or "par défaut",
-                    "on" if s.web_search else "off")
+                    "on" if s.web_search else "off", f"on ({s.memory_dir})" if s.memory else "off")
         if self.presence:
             self.presence.start()
 
@@ -217,12 +219,24 @@ class ChatBot(commands.Bot):
         in_main_channel = message.channel.id == self.settings.channel_id
         # Mention directe uniquement (@everyone et @here ne comptent pas).
         mentioned = self.user is not None and self.user in message.mentions
+        if (in_main_channel or mentioned) and self.members:
+            await self.register_members(message)
 
         if in_main_channel:
             await self.handle_main_channel(message)
         elif mentioned:
             await self.handle_mention(message)
 
+    async def register_members(self, message: discord.Message) -> None:
+        """Ajoute au registre l'auteur et les membres mentionnés (sauf les bots), ou met leurs noms à jour."""
+        for user in (message.author, *message.mentions):
+            if user.bot:
+                continue
+            # En message privé, l'auteur n'est pas un membre du serveur : son pseudo n'est pas connu.
+            in_guild = isinstance(user, discord.Member)
+            await self.members.see(user.id, user.name, user.global_name,
+                                   user.nick if in_guild else None, in_guild=in_guild)
+
     async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
         """Message modifié : la mémoire, ou le message s'il attend encore sa réponse, prend le nouveau texte."""
         message = payload.message

+ 151 - 0
bot/members.py

@@ -0,0 +1,151 @@
+"""Registre des membres vus par le bot (noms, anciens pseudos, réglages de la mémoire), dans un fichier JSON."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger("chatbot.members")
+
+FILE_VERSION = 1
+# Noms Discord d'un membre : nom d'utilisateur (unique), nom d'affichage, pseudo sur le serveur.
+NAME_KEYS = ("username", "global_name", "nickname")
+# Anciens pseudos gardés par membre, du plus ancien au plus récent.
+MAX_FORMER_NAMES = 5
+
+
+class MemberRegistry:
+    """Garde, pour chaque membre vu (auteur d'un message lu par le bot, ou membre mentionné), ses noms
+    actuels et ses anciens pseudos, s'il a été prévenu de la mémoire et s'il l'a désactivée.
+
+    Le fichier n'est réécrit que si quelque chose change : pas d'écriture à chaque message."""
+
+    def __init__(self, file_path: Path):
+        self.file_path = file_path
+        self._data: dict[str, dict] = self._load()
+        self._save_lock = asyncio.Lock()
+
+    # ------------------------------------------------------------------ accès
+
+    def get(self, member_id: int | str) -> dict | None:
+        return self._data.get(str(member_id))
+
+    def members(self) -> dict[str, dict]:
+        """Tous les membres connus, par identifiant."""
+        return self._data
+
+    def memory_enabled(self, member_id: int | str) -> bool:
+        """La mémoire est activée par défaut, y compris pour un membre encore inconnu."""
+        entry = self.get(member_id)
+        return entry is None or entry["memory"]
+
+    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.
+
+        in_guild=False (message privé) : le pseudo sur le serveur n'est pas connu, il reste inchangé."""
+        names = {"username": username, "global_name": global_name or None}
+        if in_guild:
+            names["nickname"] = nickname or None
+        key = str(member_id)
+        entry = self._data.get(key)
+        if entry is None:
+            self._data[key] = {
+                **{name_key: None for name_key in NAME_KEYS}, **names,
+                "former_names": [], "notified": False, "memory": True,
+                "first_seen": datetime.now().strftime("%Y-%m-%d"),
+            }
+            logger.info("Nouveau membre dans le registre : %s (%s)", display_name(self._data[key]), key)
+            await self._save()
+            return
+        old_names = [entry[name_key] for name_key in NAME_KEYS if entry[name_key]]
+        changed = False
+        for name_key, value in names.items():
+            if entry[name_key] != value:
+                entry[name_key] = value
+                changed = True
+        if not changed:
+            return
+        current = {entry[name_key] for name_key in NAME_KEYS if entry[name_key]}
+        former = [name for name in entry["former_names"] if name not in current]
+        for name in old_names:
+            if name not in current and name not in former:
+                former.append(name)
+        entry["former_names"] = former[-MAX_FORMER_NAMES:]
+        logger.info("Noms mis à jour dans le registre : %s (%s)", display_name(entry), key)
+        await self._save()
+
+    async def set_notified(self, member_id: int | str) -> None:
+        """Le membre a été prévenu de la mémoire : il ne le sera plus."""
+        if (entry := self.get(member_id)) is not None and not entry["notified"]:
+            entry["notified"] = True
+            await self._save()
+
+    async def set_memory(self, member_id: int | str, enabled: bool) -> None:
+        """Active ou désactive la mémoire pour ce membre."""
+        if (entry := self.get(member_id)) is not None and entry["memory"] != enabled:
+            entry["memory"] = enabled
+            logger.info("Mémoire %s pour %s (%s)", "activée" if enabled else "désactivée", display_name(entry), member_id)
+            await self._save()
+
+    # ------------------------------------------------------------ internes
+
+    def _load(self) -> dict[str, dict]:
+        if not self.file_path.exists():
+            return {}
+        try:
+            raw = json.loads(self.file_path.read_text(encoding="utf-8"))
+            if not isinstance(raw, dict) or not isinstance(raw.get("members"), dict):
+                raise ValueError("« members » absent ou n'est pas un dictionnaire")
+        except (ValueError, OSError) as exc:  # JSON invalide, mauvais encodage ou structure inattendue
+            self._set_aside(exc)
+            return {}
+        return {key: entry for key, value in raw["members"].items() if (entry := self._sanitize(value))}
+
+    @staticmethod
+    def _sanitize(value) -> dict | None:
+        """Une entrée du fichier, complétée si besoin, ou None si elle est illisible."""
+        if not isinstance(value, dict) or not isinstance(value.get("username"), str):
+            return None
+        entry = {name_key: value.get(name_key) if isinstance(value.get(name_key), str) else None for name_key in NAME_KEYS}
+        former = value.get("former_names")
+        entry["former_names"] = [n for n in former if isinstance(n, str)] if isinstance(former, list) else []
+        entry["notified"] = value.get("notified") is True
+        entry["memory"] = value.get("memory") is not False
+        entry["first_seen"] = value.get("first_seen") if isinstance(value.get("first_seen"), str) else None
+        return entry
+
+    def _set_aside(self, exc: Exception) -> None:
+        """Met de côté un fichier illisible, sous un nom daté, comme pour l'historique."""
+        stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        backup = self.file_path.with_name(f"{self.file_path.name}.corrompu-{stamp}")
+        logger.error("Registre des membres illisible (%s). Copie de secours : %s", exc, backup)
+        try:
+            os.replace(self.file_path, backup)
+        except OSError as err:
+            logger.error("Copie de secours impossible : %s", err)
+
+    async def _save(self) -> None:
+        payload = {"version": FILE_VERSION, "members": self._data}
+        text = json.dumps(payload, ensure_ascii=False, indent=2)
+        async with self._save_lock:
+            try:
+                await asyncio.to_thread(self._write_atomic, text)
+            except OSError as exc:
+                logger.error("Impossible de sauvegarder le registre des membres : %s", exc)
+
+    def _write_atomic(self, text: str) -> None:
+        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
+        self.file_path.parent.mkdir(parents=True, exist_ok=True)
+        tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
+        tmp.write_text(text, encoding="utf-8")
+        os.replace(tmp, self.file_path)
+
+
+def display_name(entry: dict) -> str:
+    """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"]

+ 9 - 0
changelog.txt

@@ -5,6 +5,15 @@ CHANGELOG — Chatbot Discord
 Version 5.10
 ------------
 
+Nouveautés
+- Mémoire des membres (MEMORY=on, en construction) : le bot tient un
+  registre des membres qu'il voit, auteurs des messages qu'il lit et
+  membres mentionnés, sans les autres bots. Pour chacun, il garde le
+  nom d'utilisateur, le nom d'affichage, le pseudo sur le serveur et les
+  5 derniers anciens pseudos, mis à jour à chaque message. Le registre
+  est dans memories/registre.json, à côté de l'historique (MEMORY_DIR
+  pour un autre dossier), et n'est réécrit que si quelque chose change.
+
 Divers
 - /reset et /skip sont réservés aux membres qui ont la permission
   Administrateur (avant : aussi aux modérateurs, avec « Gérer les