1
0

memory_views.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  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. # Sous la fiche d'un membre, quand il la consulte lui-même.
  22. ADMINS_NOTE = "Les administrateurs du serveur peuvent aussi voir et corriger ta fiche."
  23. Callback = Callable[[discord.Interaction], Awaitable[None]]
  24. async def see_user(registry: MemberRegistry, user: discord.User | discord.Member) -> None:
  25. """Ajoute un utilisateur Discord au registre, ou met ses noms à jour."""
  26. # En message privé, l'auteur n'est pas un membre du serveur : son pseudo n'est pas connu.
  27. in_guild = isinstance(user, discord.Member)
  28. await registry.see(user.id, user.name, user.global_name, user.nick if in_guild else None, in_guild=in_guild)
  29. @dataclass
  30. class Target:
  31. """Fichier affiché : la fiche d'un membre, ou le fichier général (member_id=None)."""
  32. file: str
  33. member_id: int | None
  34. name: str = "" # nom du membre
  35. is_self: bool = False # fiche de la personne qui a lancé la commande
  36. class MemoryCard:
  37. """Ce que voit la personne qui a lancé la commande : le fichier et les boutons pour le modifier."""
  38. def __init__(self, store: MemoryStore, target: Target, owner_id: int, bot_name: str):
  39. self.store = store
  40. self.target = target
  41. self.owner_id = owner_id
  42. self.bot_name = bot_name
  43. # ---------------------------------------------------------------- état
  44. @property
  45. def memory_enabled(self) -> bool:
  46. return self.target.member_id is None or self.store.members.memory_enabled(self.target.member_id)
  47. @property
  48. def notified(self) -> bool:
  49. return self.target.member_id is None or self.store.members.notified(self.target.member_id)
  50. def about(self) -> str:
  51. """« sur toi », « sur Penta » ou « sur le serveur »."""
  52. if self.target.member_id is None:
  53. return "sur le serveur"
  54. return "sur toi" if self.target.is_self else f"sur {self.target.name}"
  55. # ----------------------------------------------------------- affichage
  56. def embeds(self) -> list[discord.Embed]:
  57. if self.target.member_id is None:
  58. title = "Fichier général du serveur"
  59. else:
  60. title = "Ta fiche mémoire" if self.target.is_self else f"Fiche mémoire de {self.target.name}"
  61. content = self.store.read(self.target.file)
  62. if not self.memory_enabled:
  63. who = "Tu as" if self.target.is_self else f"{self.target.name} a"
  64. text = (f"{who} désactivé la mémoire : {self.bot_name} ne retient rien "
  65. f"{self.about()} et ne voit aucune fiche dans ses messages.")
  66. return [discord.Embed(title=title, description=text, color=discord.Color.light_grey())]
  67. if self.target.member_id is None:
  68. note = ""
  69. elif self.target.is_self:
  70. note = ADMINS_NOTE # le membre doit savoir que sa fiche n'est pas vue par lui seul
  71. elif not self.notified:
  72. note = (f"{self.target.name} n'a pas encore reçu la présentation de la mémoire : rien ne peut "
  73. f"être noté dans sa fiche avant son premier message.")
  74. else:
  75. note = ""
  76. if not content:
  77. embed = discord.Embed(title=title, description=f"{self.bot_name} n'a encore rien retenu {self.about()}.",
  78. color=discord.Color.blurple())
  79. if note:
  80. embed.set_footer(text=note)
  81. return [embed]
  82. size = len(content)
  83. if size > EMBEDS_TOTAL_MAX: # seulement si MEMORY_*_LIMIT dépasse ce que Discord affiche
  84. content = content[:EMBEDS_TOTAL_MAX] + "\n[…] (la suite est visible dans « Modifier »)"
  85. embeds = [discord.Embed(description=chunk, color=discord.Color.blurple())
  86. for chunk in split_message(content, EMBED_MAX)]
  87. embeds[0].title = title
  88. footer = f"{size}/{self.store.limit(self.target.file)} caractères"
  89. embeds[-1].set_footer(text=f"{footer} · {note}" if note else footer)
  90. return embeds
  91. def view(self) -> discord.ui.View:
  92. view = OwnerView(self.owner_id)
  93. content = self.store.read(self.target.file)
  94. # Pas encore prévenu : la fiche ne peut qu'être effacée (voir MemoryStore.overwrite).
  95. add_button(view, "Modifier", "✏️", discord.ButtonStyle.primary, self.edit,
  96. disabled=not self.memory_enabled or not self.notified)
  97. add_button(view, "Tout effacer" if self.target.member_id is None else "Effacer la fiche", "🗑️",
  98. discord.ButtonStyle.secondary, self.ask_erase, disabled=not content or not self.memory_enabled)
  99. # Seul le membre lui-même choisit d'activer ou non la mémoire.
  100. if self.target.member_id is not None and self.target.is_self:
  101. if self.memory_enabled:
  102. add_button(view, "Désactiver la mémoire", "🚫", discord.ButtonStyle.danger, self.ask_disable)
  103. else:
  104. add_button(view, "Réactiver la mémoire", "✅", discord.ButtonStyle.success, self.enable)
  105. return view
  106. async def send(self, interaction: discord.Interaction) -> None:
  107. await interaction.response.send_message(embeds=self.embeds(), view=self.view(), ephemeral=True)
  108. async def refresh(self, interaction: discord.Interaction, note: str | None = None) -> None:
  109. """Réaffiche le fichier à jour dans le même message, avec une courte phrase au-dessus."""
  110. await interaction.response.edit_message(content=note, embeds=self.embeds(), view=self.view())
  111. # -------------------------------------------------------------- actions
  112. async def edit(self, interaction: discord.Interaction) -> None:
  113. await interaction.response.send_modal(EditModal(self))
  114. async def save(self, interaction: discord.Interaction, text: str, original: str | None = None) -> None:
  115. """Enregistre le texte de la fenêtre « Modifier » (original : le fichier à son ouverture)."""
  116. error = await self.store.overwrite(self.target.file, text, by=str(interaction.user), expected=original)
  117. if error:
  118. if not text.strip():
  119. await interaction.response.send_message(error, ephemeral=True)
  120. return
  121. # Le texte saisi est renvoyé en fichier joint (trop long pour un message) : rien n'est perdu.
  122. file = discord.File(io.BytesIO(text.encode("utf-8")), filename="texte_non_enregistre.txt")
  123. await interaction.response.send_message(f"{error} Ton texte est joint, pour le recopier.",
  124. file=file, ephemeral=True)
  125. return
  126. await self.refresh(interaction, "C'est enregistré." if text.strip() else "C'est effacé.")
  127. async def ask_erase(self, interaction: discord.Interaction) -> None:
  128. # Seule la fiche est effacée (pas le fichier général, ni la conversation) : la question ne promet pas plus.
  129. if self.target.member_id is None:
  130. question = "Effacer tout le fichier général du serveur ? C'est définitif."
  131. else:
  132. whose = "ta fiche" if self.target.is_self else f"la fiche de {self.target.name}"
  133. said = "tu as dit" if self.target.is_self else f"{self.target.name} a dit"
  134. question = (f"Effacer {whose} ? C'est définitif. Seule la fiche est effacée : ce que {said} "
  135. f"récemment reste dans la conversation, et {self.bot_name} peut encore s'en souvenir.")
  136. await confirm(interaction, self, question, self.erase)
  137. async def erase(self, interaction: discord.Interaction) -> None:
  138. error = await self.store.overwrite(self.target.file, "", by=str(interaction.user))
  139. await self.refresh(interaction, error or "C'est effacé.")
  140. async def ask_disable(self, interaction: discord.Interaction) -> None:
  141. question = (f"Si tu désactives la mémoire, ta fiche et les lignes du fichier général de "
  142. f"{self.bot_name} qui te citent seront définitivement effacées, et ton nom sera retiré "
  143. f"des sources (« d'après … ») dans les fiches des autres. {self.bot_name} ne notera plus "
  144. f"rien sur toi, mais ce que tu as dit récemment reste dans la conversation. "
  145. f"Tu pourras la réactiver plus tard avec /memoire.")
  146. await confirm(interaction, self, question, self.disable)
  147. async def disable(self, interaction: discord.Interaction) -> None:
  148. error = await self.store.forget(self.target.member_id, by=str(interaction.user))
  149. await self.refresh(interaction, error or "Mémoire désactivée : ta fiche et ce qui te citait dans le "
  150. "fichier général ont été effacés, et ton nom retiré des sources.")
  151. async def enable(self, interaction: discord.Interaction) -> None:
  152. saved = await self.store.members.set_memory(self.target.member_id, True)
  153. await self.refresh(interaction, "Mémoire réactivée." if saved else NOT_SAVED.format(setting="la réactivation"))
  154. class OwnerView(discord.ui.View):
  155. """Boutons réservés à la personne qui a lancé la commande."""
  156. def __init__(self, owner_id: int):
  157. super().__init__(timeout=VIEW_TIMEOUT)
  158. self.owner_id = owner_id
  159. async def interaction_check(self, interaction: discord.Interaction) -> bool:
  160. return interaction.user.id == self.owner_id
  161. def add_button(view: discord.ui.View, label: str, emoji: str, style: discord.ButtonStyle,
  162. callback: Callback, disabled: bool = False) -> None:
  163. button = discord.ui.Button(label=label, emoji=emoji, style=style, disabled=disabled)
  164. button.callback = callback
  165. view.add_item(button)
  166. async def confirm(interaction: discord.Interaction, card: MemoryCard, question: str, action: Callback) -> None:
  167. """Remplace le fichier affiché par une question, avec « Confirmer » et « Annuler »."""
  168. view = OwnerView(card.owner_id)
  169. add_button(view, "Confirmer", "⚠️", discord.ButtonStyle.danger, action)
  170. add_button(view, "Annuler", "↩️", discord.ButtonStyle.secondary, card.refresh)
  171. embed = discord.Embed(description=question, color=discord.Color.orange())
  172. await interaction.response.edit_message(content=None, embeds=[embed], view=view)
  173. class EditModal(discord.ui.Modal):
  174. """Fenêtre « Modifier » : le fichier, déjà rempli, dans une ou plusieurs zones de texte
  175. (une zone fait 4000 caractères au plus ; le fichier général peut en demander deux)."""
  176. def __init__(self, card: MemoryCard):
  177. title = "Modifier ta fiche" if card.target.is_self else (
  178. "Modifier le fichier général" if card.target.member_id is None else f"Fiche de {card.target.name}")
  179. super().__init__(title=title[:45], timeout=VIEW_TIMEOUT)
  180. self.card = card
  181. limit = card.store.limit(card.target.file)
  182. count = min(MAX_INPUTS, -(-limit // INPUT_MAX))
  183. self.original = card.store.read(card.target.file) # comparé à l'enregistrement (voir overwrite)
  184. parts = split_for_inputs(self.original, count)
  185. self.inputs = []
  186. for i in range(count):
  187. label = "Contenu" if count == 1 else f"Contenu ({i + 1}/{count})"
  188. text_input = discord.ui.TextInput(
  189. label=label, style=discord.TextStyle.paragraph, required=False,
  190. max_length=min(INPUT_MAX, limit), default=parts[i] or None,
  191. placeholder="Une information par ligne. Tout vider efface le fichier." if i == 0 else None,
  192. )
  193. self.inputs.append(text_input)
  194. self.add_item(text_input)
  195. async def on_submit(self, interaction: discord.Interaction) -> None:
  196. text = "\n".join(text_input.value.strip() for text_input in self.inputs if text_input.value.strip())
  197. await self.card.save(interaction, text, self.original)
  198. async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
  199. logger.exception("Erreur pendant la modification de %s", self.card.target.file, exc_info=error)
  200. if not interaction.response.is_done():
  201. await interaction.response.send_message("La modification a échoué, regarde les logs.", ephemeral=True)
  202. def split_for_inputs(text: str, count: int) -> list[str]:
  203. """Répartit un texte dans count zones de texte, de préférence entre les lignes."""
  204. parts = split_message(text, INPUT_MAX)
  205. if len(parts) > count: # lignes trop mal réparties : découpage exact
  206. parts = [text[i:i + INPUT_MAX] for i in range(0, len(text), INPUT_MAX)]
  207. return (parts + [""] * count)[:count]
  208. def member_target(store: MemoryStore, member: discord.User | discord.Member, owner_id: int) -> Target:
  209. entry = store.members.get(member.id)
  210. name = display_name(entry) if entry else member.display_name
  211. return Target(store.member_file(member.id), member.id, name, is_self=member.id == owner_id)
  212. def server_target() -> Target:
  213. return Target(SERVER_FILE, None)