memory_views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. """Affichage et modification des fichiers de mémoire sur Discord : /memoire et /memoire-serveur.
  2. Tout s'affiche dans des messages éphémères : seule la personne qui a lancé la commande les voit."""
  3. from __future__ import annotations
  4. import io
  5. import logging
  6. from collections.abc import Awaitable, Callable
  7. from dataclasses import dataclass
  8. import discord
  9. from .members import MemberRegistry, display_name
  10. from .memory import NOT_SAVED, SERVER_FILE, MemoryStore
  11. from .text_utils import split_message
  12. logger = logging.getLogger("chatbot.memory")
  13. # Longueur maximale d'une zone de texte dans une fenêtre Discord, et nombre maximal de zones.
  14. INPUT_MAX = 4000
  15. MAX_INPUTS = 5
  16. # Texte maximal d'un embed (4096 pour Discord), et de tous les embeds d'un message (6000).
  17. EMBED_MAX = 4000
  18. EMBEDS_TOTAL_MAX = 5500
  19. # Durée pendant laquelle les boutons répondent (ensuite, il suffit de relancer la commande).
  20. VIEW_TIMEOUT = 600
  21. # Durée pendant laquelle la fenêtre « Modifier » peut être validée : Discord la laisse ouverte sans
  22. # limite, mais discord.py l'oublie ensuite et ignore sa validation. Assez pour réécrire le fichier général.
  23. MODAL_TIMEOUT = 3600
  24. # Sous la fiche d'un membre, quand il la consulte lui-même.
  25. ADMINS_NOTE = "Les administrateurs du serveur peuvent aussi voir et corriger ta fiche."
  26. ERROR_MESSAGE = "Une erreur est survenue. Si ça se reproduit, préviens l'administrateur du bot."
  27. Callback = Callable[[discord.Interaction], Awaitable[None]]
  28. async def reply_error(interaction: discord.Interaction) -> None:
  29. """Prévient la personne qu'une commande, un bouton ou une fenêtre a échoué (le détail est dans le log),
  30. au lieu du seul « Échec de l'interaction » de Discord."""
  31. try:
  32. if interaction.response.is_done():
  33. await interaction.followup.send(ERROR_MESSAGE, ephemeral=True)
  34. else:
  35. await interaction.response.send_message(ERROR_MESSAGE, ephemeral=True)
  36. except discord.HTTPException as exc:
  37. logger.warning("Impossible de signaler l'erreur à %s : %s", interaction.user, exc)
  38. async def see_user(registry: MemberRegistry, user: discord.User | discord.Member) -> None:
  39. """Ajoute un utilisateur Discord au registre, ou met ses noms à jour."""
  40. # En message privé, l'auteur n'est pas un membre du serveur : son pseudo n'est pas connu.
  41. in_guild = isinstance(user, discord.Member)
  42. await registry.see(user.id, user.name, user.global_name, user.nick if in_guild else None, in_guild=in_guild)
  43. @dataclass
  44. class Target:
  45. """Fichier affiché : la fiche d'un membre, ou le fichier général (member_id=None)."""
  46. file: str
  47. member_id: int | None
  48. name: str = "" # nom du membre
  49. is_self: bool = False # fiche de la personne qui a lancé la commande
  50. departed: bool = False # membre parti du serveur : un administrateur désactive sa mémoire à sa place
  51. class MemoryCard:
  52. """Ce que voit la personne qui a lancé la commande : le fichier et les boutons pour le modifier."""
  53. def __init__(self, store: MemoryStore, target: Target, owner_id: int, bot_name: str):
  54. self.store = store
  55. self.target = target
  56. self.owner_id = owner_id
  57. self.bot_name = bot_name
  58. self._view: OwnerView | None = None # boutons affichés en ce moment
  59. # ---------------------------------------------------------------- état
  60. @property
  61. def memory_enabled(self) -> bool:
  62. return self.target.member_id is None or self.store.members.memory_enabled(self.target.member_id)
  63. def about(self) -> str:
  64. """« sur toi », « sur Penta » ou « sur le serveur »."""
  65. if self.target.member_id is None:
  66. return "sur le serveur"
  67. return "sur toi" if self.target.is_self else f"sur {self.target.name}"
  68. # ----------------------------------------------------------- affichage
  69. def embeds(self) -> list[discord.Embed]:
  70. if self.target.member_id is None:
  71. title = "Fichier général du serveur"
  72. else:
  73. title = "Ta fiche mémoire" if self.target.is_self else f"Fiche mémoire de {self.target.name}"
  74. content = self.store.read(self.target.file)
  75. if not self.memory_enabled:
  76. if self.target.is_self:
  77. text = (f"La mémoire n'est pas activée pour toi : {self.bot_name} ne retient rien sur toi. "
  78. f"Active-la pour que {self.bot_name} se souvienne de toi au fil des conversations.")
  79. else:
  80. text = (f"{self.target.name} n'a pas activé la mémoire : {self.bot_name} ne retient rien "
  81. f"sur ce membre.")
  82. if content: # fiche écrite avant que la mémoire soit à activer soi-même : ni lue ni complétée
  83. text += (f"\n\nUne ancienne fiche ({len(content)} caractères) est gardée : elle ne sert pas "
  84. f"tant que la mémoire n'est pas activée.")
  85. return [discord.Embed(title=title, description=text, color=discord.Color.light_grey())]
  86. # Le membre doit savoir que sa fiche n'est pas vue par lui seul (voir /memoire membre:@…).
  87. note = ADMINS_NOTE if self.target.member_id is not None and self.target.is_self else ""
  88. if not content:
  89. embed = discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.",
  90. color=discord.Color.blurple())
  91. if note:
  92. embed.set_footer(text=note)
  93. return [embed]
  94. size = len(content)
  95. if size > EMBEDS_TOTAL_MAX: # seulement si MEMORY_*_LIMIT dépasse ce que Discord affiche
  96. content = content[:EMBEDS_TOTAL_MAX] + "\n[…] (la suite est visible dans « Modifier »)"
  97. embeds = [discord.Embed(description=chunk, color=discord.Color.blurple())
  98. for chunk in split_message(content, EMBED_MAX)]
  99. embeds[0].title = title
  100. footer = f"{size}/{self.store.limit(self.target.file)} caractères"
  101. embeds[-1].set_footer(text=f"{footer} · {note}" if note else footer)
  102. return embeds
  103. def view(self) -> discord.ui.View:
  104. view = OwnerView(self.owner_id)
  105. content = self.store.read(self.target.file)
  106. # Mémoire pas activée : la fiche ne peut qu'être effacée (voir MemoryStore.overwrite).
  107. add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit, disabled=not self.memory_enabled)
  108. add_button(view, "Tout effacer" if self.target.member_id is None else "Effacer la fiche", "🗑️",
  109. discord.ButtonStyle.secondary, self.ask_erase, disabled=not content)
  110. # Seul le membre lui-même active ou désactive la mémoire. Parti du serveur, il ne peut plus lancer
  111. # /memoire : un administrateur peut alors la désactiver à sa place, pour tout effacer.
  112. if self.target.member_id is not None and (self.target.is_self or self.target.departed):
  113. if self.memory_enabled:
  114. add_button(view, "Désactiver la mémoire", "🚫", discord.ButtonStyle.danger, self.ask_disable)
  115. elif self.target.is_self:
  116. add_button(view, "Activer la mémoire", "✅", discord.ButtonStyle.success, self.ask_enable)
  117. return view
  118. async def send(self, interaction: discord.Interaction) -> None:
  119. await self.show(interaction, self.view(), self.embeds(), new=True)
  120. async def refresh(self, interaction: discord.Interaction, note: str | None = None) -> None:
  121. """Réaffiche le fichier à jour dans le même message, avec une courte phrase au-dessus."""
  122. await self.show(interaction, self.view(), self.embeds(), note)
  123. async def show(self, interaction: discord.Interaction, view: OwnerView, embeds: list[discord.Embed],
  124. note: str | None = None, new: bool = False) -> None:
  125. """Affiche ces boutons dans un nouveau message éphémère (new), ou à la place des précédents."""
  126. if self._view is not None:
  127. self._view.stop() # sans quoi son expiration griserait les nouveaux boutons
  128. self._view, view.interaction = view, interaction
  129. if new:
  130. await interaction.response.send_message(embeds=embeds, view=view, ephemeral=True)
  131. elif interaction.response.is_done(): # réponse différée (voir EditModal.on_submit)
  132. await interaction.edit_original_response(content=note, embeds=embeds, view=view)
  133. else:
  134. await interaction.response.edit_message(content=note, embeds=embeds, view=view)
  135. # -------------------------------------------------------------- actions
  136. async def edit(self, interaction: discord.Interaction) -> None:
  137. content = self.store.read(self.target.file)
  138. if len(content) > INPUT_MAX * MAX_INPUTS: # au-delà de ce qu'une fenêtre Discord peut contenir
  139. file = discord.File(io.BytesIO(content.encode("utf-8")), filename="contenu_complet.txt")
  140. limit = self.store.limit(self.target.file)
  141. await interaction.response.send_message(
  142. f"Ce fichier fait {len(content)} caractères, trop pour la fenêtre de Discord (sa limite est de "
  143. f"{limit}). Son contenu est joint : efface-le, puis recopie ce qui compte avec « Modifier », ou "
  144. f"demande à l'administrateur du bot de le raccourcir sur le disque.", file=file, ephemeral=True)
  145. return
  146. await interaction.response.send_modal(EditModal(self))
  147. async def save(self, interaction: discord.Interaction, text: str, original: str | None = None) -> None:
  148. """Enregistre le texte de la fenêtre « Modifier » (original : le fichier à son ouverture)."""
  149. error = await self.store.overwrite(self.target.file, text, by=str(interaction.user), expected=original)
  150. if error:
  151. # Réponse déjà différée par la fenêtre : le message part en suivi.
  152. if not text.strip():
  153. await interaction.followup.send(error, ephemeral=True)
  154. return
  155. # Le texte saisi est renvoyé en fichier joint (trop long pour un message) : rien n'est perdu.
  156. file = discord.File(io.BytesIO(text.encode("utf-8")), filename="texte_non_enregistre.txt")
  157. await interaction.followup.send(f"{error} Ton texte est joint, pour le recopier.",
  158. file=file, ephemeral=True)
  159. return
  160. await self.refresh(interaction, "C'est enregistré." if text.strip() else "C'est effacé.")
  161. async def ask_erase(self, interaction: discord.Interaction) -> None:
  162. # Seule la fiche est effacée (pas le fichier général, ni la conversation) : la question ne promet pas plus.
  163. if self.target.member_id is None:
  164. question = "Effacer tout le fichier général du serveur ? C'est définitif."
  165. else:
  166. whose = "ta fiche" if self.target.is_self else f"la fiche de {self.target.name}"
  167. said = "tu as dit" if self.target.is_self else f"{self.target.name} a dit"
  168. question = (f"Effacer {whose} ? C'est définitif. Seule la fiche est effacée : ce que {said} "
  169. f"récemment reste dans la conversation, et {self.bot_name} peut encore s'en souvenir.")
  170. await confirm(interaction, self, question, self.erase)
  171. async def erase(self, interaction: discord.Interaction) -> None:
  172. error = await self.store.overwrite(self.target.file, "", by=str(interaction.user))
  173. await self.refresh(interaction, error or "C'est effacé.")
  174. async def ask_disable(self, interaction: discord.Interaction) -> None:
  175. if self.target.departed:
  176. question = (f"{self.target.name} a quitté le serveur. Désactiver sa mémoire efface définitivement sa "
  177. f"fiche et les lignes du fichier général de {self.bot_name} qui citent ce membre, et "
  178. f"retire son nom des sources (« d'après … ») dans les fiches des autres.")
  179. await confirm(interaction, self, question, self.disable)
  180. return
  181. question = (f"Si tu désactives la mémoire, ta fiche et les lignes du fichier général de "
  182. f"{self.bot_name} qui te citent seront définitivement effacées, et ton nom sera retiré "
  183. f"des sources (« d'après … ») dans les fiches des autres. {self.bot_name} ne notera plus "
  184. f"rien sur toi, mais ce que tu as dit récemment reste dans la conversation. "
  185. f"Tu pourras la réactiver plus tard avec /memoire.")
  186. await confirm(interaction, self, question, self.disable)
  187. async def disable(self, interaction: discord.Interaction) -> None:
  188. error = await self.store.forget(self.target.member_id, by=str(interaction.user))
  189. if self.target.departed:
  190. done = (f"Mémoire désactivée pour {self.target.name} : sa fiche et ce qui citait ce membre dans le "
  191. f"fichier général ont été effacés, et son nom retiré des sources.")
  192. else:
  193. done = ("Mémoire désactivée : ta fiche et ce qui te citait dans le fichier général ont été effacés, "
  194. "et ton nom retiré des sources.")
  195. await self.refresh(interaction, error or done)
  196. async def ask_enable(self, interaction: discord.Interaction) -> None:
  197. # Seule présentation de la mémoire au membre : tout ce qu'il doit savoir avant d'accepter.
  198. question = (f"Si tu actives la mémoire, {self.bot_name} retiendra des choses sur toi au fil des "
  199. f"conversations (tes goûts, tes projets…), y compris ce que d'autres membres lui racontent "
  200. f"sur toi, avec leur nom comme source (« d'après … »). De même, ce que tu lui racontes sur "
  201. f"un autre membre qui a activé la mémoire peut être noté dans sa fiche avec ton nom, et il "
  202. f"la voit. Ce qui est retenu sert dans la conversation, mais ta fiche n'est jamais récitée "
  203. f"aux autres ; à part toi, seuls les administrateurs du serveur peuvent la voir et la "
  204. f"corriger. Tu pourras la voir, la modifier ou désactiver la mémoire ici, avec /memoire.")
  205. await confirm(interaction, self, question, self.enable)
  206. async def enable(self, interaction: discord.Interaction) -> None:
  207. saved = await self.store.members.set_memory(self.target.member_id, True)
  208. await self.refresh(interaction, "Mémoire activée." if saved else NOT_SAVED.format(setting="l'activation"))
  209. class OwnerView(discord.ui.View):
  210. """Boutons réservés à la personne qui a lancé la commande."""
  211. def __init__(self, owner_id: int):
  212. super().__init__(timeout=VIEW_TIMEOUT)
  213. self.owner_id = owner_id
  214. self.interaction: discord.Interaction | None = None # celle qui a affiché ces boutons (voir show)
  215. async def interaction_check(self, interaction: discord.Interaction) -> bool:
  216. return interaction.user.id == self.owner_id
  217. async def on_timeout(self) -> None:
  218. """Boutons grisés : ils ne répondent plus. Le message éphémère reste modifiable 15 minutes après
  219. l'interaction qui l'a affiché, plus que VIEW_TIMEOUT."""
  220. if self.interaction is None:
  221. return
  222. for item in self.children:
  223. item.disabled = True
  224. try:
  225. await self.interaction.edit_original_response(
  226. content="Ces boutons ont expiré : relance la commande pour continuer.", view=self)
  227. except discord.HTTPException:
  228. pass # message fermé par la personne, ou trop ancien
  229. async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None:
  230. logger.exception("Erreur sur le bouton « %s »", getattr(item, "label", "?"), exc_info=error)
  231. await reply_error(interaction)
  232. def add_button(view: discord.ui.View, label: str, emoji: str, style: discord.ButtonStyle,
  233. callback: Callback, disabled: bool = False) -> None:
  234. button = discord.ui.Button(label=label, emoji=emoji, style=style, disabled=disabled)
  235. button.callback = callback
  236. view.add_item(button)
  237. async def confirm(interaction: discord.Interaction, card: MemoryCard, question: str, action: Callback) -> None:
  238. """Remplace le fichier affiché par une question, avec « Confirmer » et « Annuler »."""
  239. view = OwnerView(card.owner_id)
  240. add_button(view, "Confirmer", "⚠️", discord.ButtonStyle.danger, action)
  241. add_button(view, "Annuler", "↩️", discord.ButtonStyle.secondary, card.refresh)
  242. embed = discord.Embed(description=question, color=discord.Color.orange())
  243. await card.show(interaction, view, [embed])
  244. class EditModal(discord.ui.Modal):
  245. """Fenêtre « Modifier » : le fichier, déjà rempli, dans une ou plusieurs zones de texte
  246. (une zone fait 4000 caractères au plus ; le fichier général peut en demander deux)."""
  247. def __init__(self, card: MemoryCard):
  248. title = "Modifier ta fiche" if card.target.is_self else (
  249. "Modifier le fichier général" if card.target.member_id is None else f"Fiche de {card.target.name}")
  250. super().__init__(title=title[:45], timeout=MODAL_TIMEOUT)
  251. self.card = card
  252. limit = card.store.limit(card.target.file)
  253. self.original = card.store.read(card.target.file) # comparé à l'enregistrement (voir overwrite)
  254. # Fichier plus long que sa limite (limite baissée, fichier modifié sur le disque) : les zones
  255. # s'agrandissent pour le contenir en entier, à raccourcir avant d'enregistrer. Tronqué, il serait
  256. # perdu en partie ; plus long que sa zone, Discord refuserait d'ouvrir la fenêtre.
  257. too_long = len(self.original) > limit
  258. sizes = input_sizes(max(limit, len(self.original)))
  259. count = len(sizes)
  260. parts = split_for_inputs(self.original, sizes)
  261. self.inputs = []
  262. for i in range(count):
  263. label = "Contenu" if count == 1 else f"Contenu ({i + 1}/{count})"
  264. if too_long and i == 0:
  265. label += f" — {limit} caractères au plus"
  266. text_input = discord.ui.TextInput(
  267. label=label, style=discord.TextStyle.paragraph, required=False,
  268. max_length=sizes[i], default=parts[i] or None,
  269. placeholder="Une information par ligne. Tout vider efface le fichier." if i == 0 else None,
  270. )
  271. self.inputs.append(text_input)
  272. self.add_item(text_input)
  273. async def on_submit(self, interaction: discord.Interaction) -> None:
  274. text = "\n".join(text_input.value.strip() for text_input in self.inputs if text_input.value.strip())
  275. # Discord laisse 3 secondes pour répondre, et l'écriture sur le disque peut prendre plus longtemps
  276. # (fsync sur un volume réseau) : sans réponse, la fenêtre reste ouverte et un second clic renverrait
  277. # le même texte, déjà enregistré.
  278. await interaction.response.defer()
  279. await self.card.save(interaction, text, self.original)
  280. async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
  281. logger.exception("Erreur pendant la modification de %s", self.card.target.file, exc_info=error)
  282. await reply_error(interaction)
  283. def input_sizes(limit: int) -> list[int]:
  284. """Taille maximale de chaque zone de texte : INPUT_MAX, sauf la dernière, réduite pour que le total ne
  285. dépasse pas la limite du fichier (5000 : 4000 + 1000). Sinon le compteur de Discord laisserait écrire
  286. un texte refusé à l'enregistrement."""
  287. count = min(MAX_INPUTS, -(-limit // INPUT_MAX))
  288. return [min(INPUT_MAX, limit - INPUT_MAX * i) for i in range(count)]
  289. def split_for_inputs(text: str, sizes: list[int]) -> list[str]:
  290. """Répartit un texte dans des zones de texte de ces tailles, de préférence entre les lignes. Une zone
  291. ne reçoit jamais plus que sa taille : Discord refuserait d'ouvrir la fenêtre."""
  292. parts, rest = [], text
  293. for size in sizes:
  294. cut = len(rest) if len(rest) <= size else rest.rfind("\n", 0, size + 1)
  295. if cut <= 0 < len(rest): # ligne plus longue que la zone : coupée au milieu
  296. cut = min(size, len(rest))
  297. parts.append(rest[:cut])
  298. rest = rest[cut:].lstrip("\n")
  299. if rest: # lignes trop mal réparties : découpage exact
  300. starts = [sum(sizes[:i]) for i in range(len(sizes))]
  301. parts = [text[start:start + size] for start, size in zip(starts, sizes)]
  302. return parts
  303. def member_target(store: MemoryStore, member: discord.User | discord.Member, owner_id: int) -> Target:
  304. entry = store.members.get(member.id)
  305. name = display_name(entry) if entry else member.display_name
  306. # Hors du serveur, Discord donne un discord.User au lieu d'un discord.Member.
  307. is_self = member.id == owner_id
  308. departed = not is_self and not isinstance(member, discord.Member)
  309. return Target(store.member_file(member.id), member.id, name, is_self=is_self, departed=departed)
  310. def server_target() -> Target:
  311. return Target(SERVER_FILE, None)