"""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 io import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass import discord from .members import MemberRegistry, display_name from .memory import NOT_SAVED, 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 # Durée pendant laquelle la fenêtre « Modifier » peut être validée : Discord la laisse ouverte sans # limite, mais discord.py l'oublie ensuite et ignore sa validation. Assez pour réécrire le fichier général. MODAL_TIMEOUT = 3600 # 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." ERROR_MESSAGE = "Une erreur est survenue. Si ça se reproduit, préviens l'administrateur du bot." Callback = Callable[[discord.Interaction], Awaitable[None]] async def reply_error(interaction: discord.Interaction) -> None: """Prévient la personne qu'une commande, un bouton ou une fenêtre a échoué (le détail est dans le log), au lieu du seul « Échec de l'interaction » de Discord.""" try: if interaction.response.is_done(): await interaction.followup.send(ERROR_MESSAGE, ephemeral=True) else: await interaction.response.send_message(ERROR_MESSAGE, ephemeral=True) except discord.HTTPException as exc: logger.warning("Impossible de signaler l'erreur à %s : %s", interaction.user, exc) 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 departed: bool = False # membre parti du serveur : un administrateur désactive sa mémoire à sa place 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 self._view: OwnerView | None = None # boutons affichés en ce moment # ---------------------------------------------------------------- é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: if self.target.is_self: text = (f"La mémoire n'est pas activée pour toi : {self.bot_name} ne retient rien sur toi. " f"Active-la pour que {self.bot_name} se souvienne de toi au fil des conversations.") else: text = (f"{self.target.name} n'a pas activé la mémoire : {self.bot_name} ne retient rien " f"sur ce membre.") if content: # fiche écrite avant que la mémoire soit à activer soi-même : ni lue ni complétée text += (f"\n\nUne ancienne fiche ({len(content)} caractères) est gardée : elle ne sert pas " f"tant que la mémoire n'est pas activée.") 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:@…). note = ADMINS_NOTE if self.target.member_id is not None and self.target.is_self else "" if not content: embed = discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.", color=discord.Color.blurple()) if note: embed.set_footer(text=note) 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 footer = f"{size}/{self.store.limit(self.target.file)} caractères" embeds[-1].set_footer(text=f"{footer} · {note}" if note else footer) return embeds def view(self) -> discord.ui.View: view = OwnerView(self.owner_id) content = self.store.read(self.target.file) # Mémoire pas activée : la fiche ne peut qu'être effacée (voir MemoryStore.overwrite). 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 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) elif self.target.is_self: add_button(view, "Activer la mémoire", "✅", discord.ButtonStyle.success, self.ask_enable) return view async def send(self, interaction: discord.Interaction) -> None: await self.show(interaction, self.view(), self.embeds(), new=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 self.show(interaction, self.view(), self.embeds(), note) async def show(self, interaction: discord.Interaction, view: OwnerView, embeds: list[discord.Embed], note: str | None = None, new: bool = False) -> None: """Affiche ces boutons dans un nouveau message éphémère (new), ou à la place des précédents.""" if self._view is not None: self._view.stop() # sans quoi son expiration griserait les nouveaux boutons self._view, view.interaction = view, interaction if new: await interaction.response.send_message(embeds=embeds, view=view, ephemeral=True) else: await interaction.response.edit_message(content=note, embeds=embeds, view=view) # -------------------------------------------------------------- actions async def edit(self, interaction: discord.Interaction) -> None: content = self.store.read(self.target.file) if len(content) > INPUT_MAX * MAX_INPUTS: # au-delà de ce qu'une fenêtre Discord peut contenir file = discord.File(io.BytesIO(content.encode("utf-8")), filename="contenu_complet.txt") limit = self.store.limit(self.target.file) await interaction.response.send_message( f"Ce fichier fait {len(content)} caractères, trop pour la fenêtre de Discord (sa limite est de " f"{limit}). Son contenu est joint : efface-le, puis recopie ce qui compte avec « Modifier », ou " f"demande à l'administrateur du bot de le raccourcir sur le disque.", file=file, ephemeral=True) return await interaction.response.send_modal(EditModal(self)) async def save(self, interaction: discord.Interaction, text: str, original: str | None = None) -> None: """Enregistre le texte de la fenêtre « Modifier » (original : le fichier à son ouverture).""" error = await self.store.overwrite(self.target.file, text, by=str(interaction.user), expected=original) if error: if not text.strip(): await interaction.response.send_message(error, ephemeral=True) return # Le texte saisi est renvoyé en fichier joint (trop long pour un message) : rien n'est perdu. file = discord.File(io.BytesIO(text.encode("utf-8")), filename="texte_non_enregistre.txt") await interaction.response.send_message(f"{error} Ton texte est joint, pour le recopier.", file=file, 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: # Seule la fiche est effacée (pas le fichier général, ni la conversation) : la question ne promet pas plus. if self.target.member_id is None: question = "Effacer tout le fichier général du serveur ? C'est définitif." else: whose = "ta fiche" if self.target.is_self else f"la fiche de {self.target.name}" said = "tu as dit" if self.target.is_self else f"{self.target.name} a dit" question = (f"Effacer {whose} ? C'est définitif. Seule la fiche est effacée : ce que {said} " f"récemment reste dans la conversation, et {self.bot_name} peut encore s'en souvenir.") 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: 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 " f"rien sur toi, mais ce que tu as dit récemment reste dans la conversation. " f"Tu pourras la réactiver plus tard avec /memoire.") await confirm(interaction, self, question, self.disable) async def disable(self, interaction: discord.Interaction) -> None: error = await self.store.forget(self.target.member_id, by=str(interaction.user)) 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. question = (f"Si tu actives la mémoire, {self.bot_name} retiendra des choses sur toi au fil des " f"conversations (tes goûts, tes projets…), y compris ce que d'autres membres lui racontent " f"sur toi, avec leur nom comme source (« d'après … »). De même, ce que tu lui racontes sur " f"un autre membre qui a activé la mémoire peut être noté dans sa fiche avec ton nom, et il " f"la voit. Ce qui est retenu sert dans la conversation, mais ta fiche n'est jamais récitée " f"aux autres ; à part toi, seuls les administrateurs du serveur peuvent la voir et la " f"corriger. Tu pourras la voir, la modifier ou désactiver la mémoire ici, avec /memoire.") await confirm(interaction, self, question, self.enable) async def enable(self, interaction: discord.Interaction) -> None: saved = await self.store.members.set_memory(self.target.member_id, True) await self.refresh(interaction, "Mémoire activée." if saved else NOT_SAVED.format(setting="l'activation")) 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 self.interaction: discord.Interaction | None = None # celle qui a affiché ces boutons (voir show) async def interaction_check(self, interaction: discord.Interaction) -> bool: return interaction.user.id == self.owner_id async def on_timeout(self) -> None: """Boutons grisés : ils ne répondent plus. Le message éphémère reste modifiable 15 minutes après l'interaction qui l'a affiché, plus que VIEW_TIMEOUT.""" if self.interaction is None: return for item in self.children: item.disabled = True try: await self.interaction.edit_original_response( content="Ces boutons ont expiré : relance la commande pour continuer.", view=self) except discord.HTTPException: pass # message fermé par la personne, ou trop ancien async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None: logger.exception("Erreur sur le bouton « %s »", getattr(item, "label", "?"), exc_info=error) await reply_error(interaction) 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 card.show(interaction, view, [embed]) 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=MODAL_TIMEOUT) self.card = card limit = card.store.limit(card.target.file) self.original = card.store.read(card.target.file) # comparé à l'enregistrement (voir overwrite) # Fichier plus long que sa limite (limite baissée, fichier modifié sur le disque) : les zones # s'agrandissent pour le contenir en entier, à raccourcir avant d'enregistrer. Tronqué, il serait # perdu en partie ; plus long que sa zone, Discord refuserait d'ouvrir la fenêtre. too_long = len(self.original) > limit sizes = input_sizes(max(limit, len(self.original))) count = len(sizes) parts = split_for_inputs(self.original, sizes) self.inputs = [] for i in range(count): label = "Contenu" if count == 1 else f"Contenu ({i + 1}/{count})" if too_long and i == 0: label += f" — {limit} caractères au plus" text_input = discord.ui.TextInput( label=label, style=discord.TextStyle.paragraph, required=False, max_length=sizes[i], 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, self.original) 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) await reply_error(interaction) def input_sizes(limit: int) -> list[int]: """Taille maximale de chaque zone de texte : INPUT_MAX, sauf la dernière, réduite pour que le total ne dépasse pas la limite du fichier (5000 : 4000 + 1000). Sinon le compteur de Discord laisserait écrire un texte refusé à l'enregistrement.""" count = min(MAX_INPUTS, -(-limit // INPUT_MAX)) return [min(INPUT_MAX, limit - INPUT_MAX * i) for i in range(count)] def split_for_inputs(text: str, sizes: list[int]) -> list[str]: """Répartit un texte dans des zones de texte de ces tailles, de préférence entre les lignes. Une zone ne reçoit jamais plus que sa taille : Discord refuserait d'ouvrir la fenêtre.""" parts, rest = [], text for size in sizes: cut = len(rest) if len(rest) <= size else rest.rfind("\n", 0, size + 1) if cut <= 0 < len(rest): # ligne plus longue que la zone : coupée au milieu cut = min(size, len(rest)) parts.append(rest[:cut]) rest = rest[cut:].lstrip("\n") if rest: # lignes trop mal réparties : découpage exact starts = [sum(sizes[:i]) for i in range(len(sizes))] parts = [text[start:start + size] for start, size in zip(starts, sizes)] return parts 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 # 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: return Target(SERVER_FILE, None)