Parcourir la source

Mémoire des membres : commandes /memoire et /memoire-serveur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet il y a 3 jours
Parent
commit
0a2c021ad8
6 fichiers modifiés avec 327 ajouts et 8 suppressions
  1. 10 2
      README.md
  2. 8 2
      bot/config.py
  3. 48 4
      bot/discord_bot.py
  4. 23 0
      bot/memory.py
  5. 227 0
      bot/memory_views.py
  6. 11 0
      changelog.txt

+ 10 - 2
README.md

@@ -32,6 +32,13 @@ Seul le nom du fichier reste ensuite en mémoire : le bot se souvient qu'il l'a
 - **Emojis du serveur** : le bot connaît automatiquement tous les emojis du serveur, animés compris, et peut les utiliser dans ses réponses. Un emoji ajouté ou retiré est pris en compte tout de suite.
 - **Stickers et emojis seuls** : à un sticker seul, il répond par un sticker du serveur ; à un emoji seul, par un emoji du serveur.
 
+### Mémoire des membres
+
+Avec `MEMORY=on` :
+
+- **`/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, tout effacer (après confirmation) ou désactiver la mémoire : sa fiche est alors effacée et plus rien n'est retenu sur lui, jusqu'à ce qu'il la réactive. 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-serveur`** : le fichier général du serveur, à voir, modifier ou effacer. Réservé aux administrateurs.
+
 ### Modération
 
 - **`/reset`** : efface la mémoire du salon. Réservé aux membres qui ont la permission Administrateur.
@@ -54,7 +61,7 @@ Il te faut :
    - clique sur **Reset Token** et copie le token : c'est le `DISCORD_TOKEN` (à garder secret) ;
    - dans **Privileged Gateway Intents**, active **Message Content Intent**. Les intents « Presence » et « Server Members » ne servent pas.
 3. Onglet **OAuth2 > URL Generator** :
-   - coche les scopes **bot** et **applications.commands** (ce dernier pour `/reset` et `/skip`) ;
+   - coche les scopes **bot** et **applications.commands** (ce dernier pour les commandes comme `/reset` et `/memoire`) ;
    - dans les permissions, coche **View Channels**, **Send Messages** et **Read Message History**, plus **Add Reactions** avec `MEMORY=on` ;
    - ouvre l'adresse générée et invite le bot sur ton serveur.
 4. Dans Discord, active le mode développeur (**Paramètres utilisateur > Avancés > Mode développeur**), puis fais un clic droit sur le salon du bot > **Copier l'identifiant du salon** : c'est le `CHANNEL_ID`.
@@ -165,7 +172,8 @@ 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/memory.py            fiches des membres et fichier général : outil « memoire » de Claude
-bot/discord_bot.py       événements Discord, réponses, /reset et /skip
+bot/memory_views.py      /memoire et /memoire-serveur : affichage, fenêtre « Modifier », confirmations
+bot/discord_bot.py       événements Discord, réponses, commandes slash
 bot/emojis.py            emojis du serveur : liste pour Claude, noms remplacés à l'envoi
 bot/presence.py          statut Discord : tirage au sort et changements
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)

+ 8 - 2
bot/config.py

@@ -198,6 +198,12 @@ def load_settings() -> Settings:
     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")
+    memory_limits = {name: _get_int(name, default=default, minimum=200)
+                     for name, default in (("MEMORY_MEMBER_LIMIT", 3000), ("MEMORY_SERVER_LIMIT", 5000))}
+    for name, value in memory_limits.items():
+        # La fenêtre « Modifier » de /memoire contient au plus 5 zones de 4000 caractères.
+        if value > 20000:
+            raise ConfigError(f"{name} ne peut pas dépasser 20000 (valeur actuelle : {value}).")
 
     presence_raw = (_get_str("PRESENCE", default="on") or "on").lower()
     if presence_raw not in ("on", "off"):
@@ -224,8 +230,8 @@ def load_settings() -> Settings:
         memory=memory_raw == "on",
         memory_dir=Path(memory_dir) if memory_dir else history_file.with_name("memories"),
         # Chaque fiche jointe est relue à chaque message : plus elle est longue, plus il coûte.
-        memory_member_limit=_get_int("MEMORY_MEMBER_LIMIT", default=3000, minimum=200),
-        memory_server_limit=_get_int("MEMORY_SERVER_LIMIT", default=5000, minimum=200),
+        memory_member_limit=memory_limits["MEMORY_MEMBER_LIMIT"],
+        memory_server_limit=memory_limits["MEMORY_SERVER_LIMIT"],
         memory_max_operations=_get_int("MEMORY_MAX_OPERATIONS", default=5, minimum=1),
         # 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),

+ 48 - 4
bot/discord_bot.py

@@ -24,6 +24,7 @@ from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .members import MemberRegistry
 from .memory import MemoryStore
+from .memory_views import MemoryCard, member_target, see_user, server_target
 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
@@ -142,6 +143,9 @@ class ChatBot(commands.Bot):
         self.tree.add_command(reset_command)
         if self.presence:
             self.tree.add_command(skip_command)
+        # Toujours présentes : avec MEMORY=off, elles indiquent que la mémoire est coupée.
+        self.tree.add_command(memory_command)
+        self.tree.add_command(server_memory_command)
         await self.sync_commands()
         s = self.settings
         logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
@@ -250,10 +254,7 @@ class ChatBot(commands.Bot):
     async def register_members(self, message: discord.Message) -> None:
         """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
         for user in await self.concerned_members(message):
-            # 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)
+            await see_user(self.members, user)
 
     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."""
@@ -870,3 +871,46 @@ async def skip_command(interaction: discord.Interaction) -> None:
     remaining = bot.presence.remaining()
     duration = f", pendant environ {remaining}" if remaining else ""
     await interaction.followup.send(f"Nouveau statut : {status.label()}{duration}.", ephemeral=True)
+
+
+MEMORY_OFF = "La mémoire des membres est désactivée sur ce bot (MEMORY=off)."
+
+
+def bot_name(interaction: discord.Interaction) -> str:
+    return interaction.guild.me.display_name if interaction.guild else interaction.client.user.name
+
+
+@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.guild_only()
+async def memory_command(interaction: discord.Interaction, membre: discord.Member | None = None) -> None:
+    bot: ChatBot = interaction.client  # type: ignore[assignment]
+    if bot.memory is None:
+        await interaction.response.send_message(MEMORY_OFF, ephemeral=True)
+        return
+    target = membre or interaction.user
+    if target.bot:
+        await interaction.response.send_message("Les bots n'ont pas de fiche.", ephemeral=True)
+        return
+    if target.id != interaction.user.id and not interaction.permissions.administrator:
+        logger.info("/memoire sur %s refusé pour %s", target, interaction.user)
+        await interaction.response.send_message(
+            "Seuls les administrateurs peuvent voir la fiche d'un autre membre.", ephemeral=True)
+        return
+    await see_user(bot.members, target)  # un membre qui n'a encore jamais écrit peut déjà désactiver la mémoire
+    card = MemoryCard(bot.memory, member_target(bot.memory, target, interaction.user.id),
+                      interaction.user.id, bot_name(interaction))
+    await card.send(interaction)
+
+
+@app_commands.command(name="memoire-serveur", description="Voir ou modifier le fichier général du bot sur le serveur")
+@app_commands.guild_only()
+@app_commands.default_permissions(administrator=True)  # comme /reset
+async def server_memory_command(interaction: discord.Interaction) -> None:
+    if not await allowed(interaction):
+        return
+    bot: ChatBot = interaction.client  # type: ignore[assignment]
+    if bot.memory is None:
+        await interaction.response.send_message(MEMORY_OFF, ephemeral=True)
+        return
+    await MemoryCard(bot.memory, server_target(), interaction.user.id, bot_name(interaction)).send(interaction)

+ 23 - 0
bot/memory.py

@@ -186,6 +186,29 @@ class MemoryStore:
         logger.info("Mémoire : %s sur %s (%s), %d → %d caractères", action, name, label, len(before), len(after))
         return ToolResult(f"C'est fait. {name} : {len(after)}/{limit} caractères.", written=True)
 
+    def limit(self, name: str) -> int:
+        return self.server_limit if name == SERVER_FILE else self.member_limit
+
+    async def overwrite(self, name: str, text: str, by: str) -> str | None:
+        """Remplace tout un fichier, ou le supprime si text est vide : modification faite par un membre
+        avec /memoire (by : son nom, pour les logs). Renvoie un message d'erreur, ou None."""
+        text = "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
+        limit = self.limit(name)
+        if len(text) > limit:
+            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:
+            try:
+                if text:
+                    await asyncio.to_thread(self._write_atomic, path, text)
+                else:
+                    path.unlink(missing_ok=True)
+            except OSError as exc:
+                logger.error("Mémoire : impossible de modifier %s : %s", name, exc)
+                return "Modification impossible pour l'instant, à cause d'une erreur du bot."
+        logger.info("Mémoire : %s %s par %s (%d caractères)", name, "modifié" if text else "effacé", by, len(text))
+        return None
+
     # ------------------------------------------------------------ internes
 
     @staticmethod

+ 227 - 0
bot/memory_views.py

@@ -0,0 +1,227 @@
+"""Affichage et modification des fichiers de mémoire sur Discord : /memoire et /memoire-serveur.
+
+Tout s'affiche dans des messages éphémères : seule la personne qui a lancé la commande les voit."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+import discord
+
+from .members import MemberRegistry, display_name
+from .memory import SERVER_FILE, MemoryStore
+from .text_utils import split_message
+
+logger = logging.getLogger("chatbot.memory")
+
+# Longueur maximale d'une zone de texte dans une fenêtre Discord, et nombre maximal de zones.
+INPUT_MAX = 4000
+MAX_INPUTS = 5
+# Texte maximal d'un embed (4096 pour Discord), et de tous les embeds d'un message (6000).
+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
+
+Callback = Callable[[discord.Interaction], Awaitable[None]]
+
+
+async def see_user(registry: MemberRegistry, user: discord.User | discord.Member) -> None:
+    """Ajoute un utilisateur Discord au registre, ou met ses noms à jour."""
+    # 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 registry.see(user.id, user.name, user.global_name, user.nick if in_guild else None, in_guild=in_guild)
+
+
+@dataclass
+class Target:
+    """Fichier affiché : la fiche d'un membre, ou le fichier général (member_id=None)."""
+    file: str
+    member_id: int | None
+    name: str = ""        # nom du membre
+    is_self: bool = False  # fiche de la personne qui a lancé la commande
+
+
+class MemoryCard:
+    """Ce que voit la personne qui a lancé la commande : le fichier et les boutons pour le modifier."""
+
+    def __init__(self, store: MemoryStore, target: Target, owner_id: int, bot_name: str):
+        self.store = store
+        self.target = target
+        self.owner_id = owner_id
+        self.bot_name = bot_name
+
+    # ---------------------------------------------------------------- état
+
+    @property
+    def memory_enabled(self) -> bool:
+        return self.target.member_id is None or self.store.members.memory_enabled(self.target.member_id)
+
+    def about(self) -> str:
+        """« sur toi », « sur Penta » ou « sur le serveur »."""
+        if self.target.member_id is None:
+            return "sur le serveur"
+        return "sur toi" if self.target.is_self else f"sur {self.target.name}"
+
+    # ----------------------------------------------------------- affichage
+
+    def embeds(self) -> list[discord.Embed]:
+        if self.target.member_id is None:
+            title = "Fichier général du serveur"
+        else:
+            title = "Ta fiche mémoire" if self.target.is_self else f"Fiche mémoire de {self.target.name}"
+        content = self.store.read(self.target.file)
+        if not self.memory_enabled:
+            who = "Tu as" if self.target.is_self else f"{self.target.name} a"
+            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())]
+        if not content:
+            return [discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.",
+                                  color=discord.Color.blurple())]
+        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")
+        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)
+        add_button(view, "Tout effacer", "🗑️", 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.
+        if self.target.member_id is not None and self.target.is_self:
+            if self.memory_enabled:
+                add_button(view, "Désactiver la mémoire", "🚫", discord.ButtonStyle.danger, self.ask_disable)
+            else:
+                add_button(view, "Réactiver la mémoire", "✅", discord.ButtonStyle.success, self.enable)
+        return view
+
+    async def send(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_message(embeds=self.embeds(), view=self.view(), ephemeral=True)
+
+    async def refresh(self, interaction: discord.Interaction, note: str | None = None) -> None:
+        """Réaffiche le fichier à jour dans le même message, avec une courte phrase au-dessus."""
+        await interaction.response.edit_message(content=note, embeds=self.embeds(), view=self.view())
+
+    # -------------------------------------------------------------- actions
+
+    async def edit(self, interaction: discord.Interaction) -> None:
+        await interaction.response.send_modal(EditModal(self))
+
+    async def save(self, interaction: discord.Interaction, text: str) -> None:
+        """Enregistre le texte de la fenêtre « Modifier »."""
+        error = await self.store.overwrite(self.target.file, text, by=str(interaction.user))
+        if error:
+            await interaction.response.send_message(error, ephemeral=True)
+            return
+        await self.refresh(interaction, "C'est enregistré." if text.strip() else "C'est effacé.")
+
+    async def ask_erase(self, interaction: discord.Interaction) -> None:
+        question = f"Effacer tout ce que {self.bot_name} a retenu {self.about()} ? C'est définitif."
+        await confirm(interaction, self, question, self.erase)
+
+    async def erase(self, interaction: discord.Interaction) -> None:
+        error = await self.store.overwrite(self.target.file, "", by=str(interaction.user))
+        await self.refresh(interaction, error or "C'est effacé.")
+
+    async def ask_disable(self, interaction: discord.Interaction) -> None:
+        question = (f"Si tu désactives la mémoire, tout ce que {self.bot_name} a retenu sur toi sera "
+                    f"définitivement effacé, et plus rien ne sera retenu sur toi. Tu pourras la réactiver "
+                    f"plus tard avec /memoire.")
+        await confirm(interaction, self, question, self.disable)
+
+    async def disable(self, interaction: discord.Interaction) -> None:
+        error = await self.store.overwrite(self.target.file, "", by=str(interaction.user))
+        if error is None:
+            await self.store.members.set_memory(self.target.member_id, False)
+        await self.refresh(interaction, error or "Mémoire désactivée : ta fiche a été effacée.")
+
+    async def enable(self, interaction: discord.Interaction) -> None:
+        await self.store.members.set_memory(self.target.member_id, True)
+        await self.refresh(interaction, "Mémoire réactivée.")
+
+
+class OwnerView(discord.ui.View):
+    """Boutons réservés à la personne qui a lancé la commande."""
+
+    def __init__(self, owner_id: int):
+        super().__init__(timeout=VIEW_TIMEOUT)
+        self.owner_id = owner_id
+
+    async def interaction_check(self, interaction: discord.Interaction) -> bool:
+        return interaction.user.id == self.owner_id
+
+
+def add_button(view: discord.ui.View, label: str, emoji: str, style: discord.ButtonStyle,
+               callback: Callback, disabled: bool = False) -> None:
+    button = discord.ui.Button(label=label, emoji=emoji, style=style, disabled=disabled)
+    button.callback = callback
+    view.add_item(button)
+
+
+async def confirm(interaction: discord.Interaction, card: MemoryCard, question: str, action: Callback) -> None:
+    """Remplace le fichier affiché par une question, avec « Confirmer » et « Annuler »."""
+    view = OwnerView(card.owner_id)
+    add_button(view, "Confirmer", "⚠️", discord.ButtonStyle.danger, action)
+    add_button(view, "Annuler", "↩️", discord.ButtonStyle.secondary, card.refresh)
+    embed = discord.Embed(description=question, color=discord.Color.orange())
+    await interaction.response.edit_message(content=None, embeds=[embed], view=view)
+
+
+class EditModal(discord.ui.Modal):
+    """Fenêtre « Modifier » : le fichier, déjà rempli, dans une ou plusieurs zones de texte
+    (une zone fait 4000 caractères au plus ; le fichier général peut en demander deux)."""
+
+    def __init__(self, card: MemoryCard):
+        title = "Modifier ta fiche" if card.target.is_self else (
+            "Modifier le fichier général" if card.target.member_id is None else f"Fiche de {card.target.name}")
+        super().__init__(title=title[:45], timeout=VIEW_TIMEOUT)
+        self.card = card
+        limit = card.store.limit(card.target.file)
+        count = min(MAX_INPUTS, -(-limit // INPUT_MAX))
+        parts = split_for_inputs(card.store.read(card.target.file), count)
+        self.inputs = []
+        for i in range(count):
+            label = "Contenu" if count == 1 else f"Contenu ({i + 1}/{count})"
+            text_input = discord.ui.TextInput(
+                label=label, style=discord.TextStyle.paragraph, required=False,
+                max_length=min(INPUT_MAX, limit), default=parts[i] or None,
+                placeholder="Une information par ligne. Tout vider efface le fichier." if i == 0 else None,
+            )
+            self.inputs.append(text_input)
+            self.add_item(text_input)
+
+    async def on_submit(self, interaction: discord.Interaction) -> None:
+        text = "\n".join(text_input.value.strip() for text_input in self.inputs if text_input.value.strip())
+        await self.card.save(interaction, text)
+
+    async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
+        logger.exception("Erreur pendant la modification de %s", self.card.target.file, exc_info=error)
+        if not interaction.response.is_done():
+            await interaction.response.send_message("La modification a échoué, regarde les logs.", ephemeral=True)
+
+
+def split_for_inputs(text: str, count: int) -> list[str]:
+    """Répartit un texte dans count zones de texte, de préférence entre les lignes."""
+    parts = split_message(text, INPUT_MAX)
+    if len(parts) > count:  # lignes trop mal réparties : découpage exact
+        parts = [text[i:i + INPUT_MAX] for i in range(0, len(text), INPUT_MAX)]
+    return (parts + [""] * count)[:count]
+
+
+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)
+
+
+def server_target() -> Target:
+    return Target(SERVER_FILE, None)

+ 11 - 0
changelog.txt

@@ -36,6 +36,17 @@ Nouveautés
   de leur fiche, ce qui permet à Rika de noter une info sur quelqu'un
   cité par son seul nom ou surnom ; ainsi que le fichier général. Les
   membres qui ont désactivé la mémoire n'y figurent pas.
+- /memoire : chaque membre voit sa fiche, visible par lui seul, et peut
+  la modifier dans une fenêtre déjà remplie, tout effacer ou désactiver
+  la mémoire (avec confirmation : sa fiche est alors effacée, plus rien
+  n'est retenu sur lui ni joint à ses messages). Il peut la réactiver au
+  même endroit. Les administrateurs (permission Administrateur) voient,
+  modifient et effacent la fiche de n'importe qui avec
+  /memoire membre:@… ; seul le membre lui-même peut désactiver sa
+  mémoire. /memoire-serveur, réservé aux administrateurs, fait de même
+  pour le fichier général. Avec MEMORY=off, les deux commandes le
+  signalent. MEMORY_MEMBER_LIMIT et MEMORY_SERVER_LIMIT ne peuvent pas
+  dépasser 20000 (taille maximale de la fenêtre de modification).
 
 Divers
 - /reset et /skip sont réservés aux membres qui ont la permission