discord_bot.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. """Le bot Discord : événements, commandes et réactions."""
  2. from __future__ import annotations
  3. import asyncio
  4. import contextlib
  5. import hashlib
  6. import json
  7. import logging
  8. import random
  9. import re
  10. from dataclasses import dataclass
  11. from datetime import datetime, timedelta
  12. import discord
  13. from discord import app_commands
  14. from discord.ext import commands
  15. from . import VERSION
  16. from .claude_client import ClaudeClient, ClaudeError, ImageData
  17. from .config import Settings
  18. from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
  19. from .history import ConversationHistory, images_marker, render_line
  20. from .images import ImageError, prepare_image
  21. from .members import MemberRegistry, with_username
  22. from .memory import DATE_FORMAT, MemoryStore
  23. from .memory_views import MemoryCard, member_target, reply_error, see_user, server_target
  24. from .models import display_name, is_known
  25. from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
  26. from .presence import PresenceManager, Status
  27. from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
  28. from .text_utils import split_message
  29. logger = logging.getLogger("chatbot.discord")
  30. CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
  31. USER_MENTION = re.compile(r"<@!?(\d+)>")
  32. IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
  33. # Couleur du message de démarrage : le vert du statut « en ligne » de Discord.
  34. ONLINE_COLOR = discord.Color(0x23A55A)
  35. # Longueur maximale du texte d'un message cité ou transféré transmis à Claude.
  36. QUOTE_MAX_LENGTH = 500
  37. # Images lues par message : chacune coûte jusqu'à ~1 600 tokens.
  38. MAX_IMAGES = 4
  39. # Messages écrits par quelqu'un ; les autres types sont des messages système
  40. # (arrivée d'un membre, message épinglé, boost, fil créé…).
  41. USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
  42. # En passant absent ou en « Ne pas déranger », le bot prévient son salon si on y a parlé depuis moins de :
  43. DEPARTURE_WINDOW = timedelta(minutes=3)
  44. def author_name(user: discord.User | discord.Member) -> str:
  45. """Nom d'un auteur pour Claude, avec son nom d'utilisateur (voir with_username)."""
  46. return with_username(user.display_name, user.name)
  47. def looks_like_image(attachment: discord.Attachment) -> bool:
  48. return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
  49. def looks_like_text(attachment: discord.Attachment) -> bool:
  50. return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
  51. def looks_like_pdf(attachment: discord.Attachment) -> bool:
  52. return attachment.content_type == "application/pdf" or attachment.filename.lower().endswith(PDF_EXTENSIONS)
  53. @contextlib.asynccontextmanager
  54. async def typing(channel: discord.abc.Messageable):
  55. """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
  56. indicator = channel.typing()
  57. try:
  58. await indicator.__aenter__() # seul ce premier envoi peut échouer : les suivants sont protégés
  59. except discord.HTTPException as exc:
  60. logger.warning("Indicateur « en train d'écrire » impossible dans #%s : %s", channel, exc)
  61. indicator = None
  62. try:
  63. yield
  64. finally:
  65. if indicator is not None:
  66. await indicator.__aexit__(None, None, None)
  67. def text_file_prompt(name: str, content: str) -> str:
  68. """Contenu d'un fichier .txt tel qu'il est transmis à Claude."""
  69. return f"\n\n[Contenu du fichier joint « {name} » :]\n{content}\n[Fin du fichier « {name} »]"
  70. def text_file_marker(name: str) -> str:
  71. """Remplace un fichier texte dans la mémoire (son contenu n'est pas conservé)."""
  72. return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
  73. def attachments_marker(attachments: list[ImageData]) -> str:
  74. """Remplace les images ou le PDF d'un message dans la mémoire (leur contenu n'est pas conservé)."""
  75. if attachments[0].is_pdf:
  76. return f"[PDF joint « {attachments[0].name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
  77. return images_marker(len(attachments))
  78. @dataclass
  79. class PendingMessage:
  80. """Message du salon dédié, prêt à être envoyé à Claude."""
  81. message: discord.Message
  82. line: dict # ligne conservée en mémoire (voir history.render_line)
  83. extra: str # ajouté pour Claude seulement : contenu du fichier texte joint
  84. images: list[ImageData] # images, ou un seul PDF
  85. @property
  86. def user_text(self) -> str:
  87. """Texte envoyé à Claude : les images ou le fichier eux-mêmes remplacent leur repère."""
  88. line = {key: value for key, value in self.line.items() if key != "attachments"}
  89. return render_line(line) + self.extra
  90. class HomeTree(app_commands.CommandTree):
  91. """Commandes slash acceptées seulement sur le serveur du bot (voir ChatBot.home_guild_id) : un
  92. administrateur d'un autre serveur où le bot a été invité ne peut ni lire ni modifier la mémoire."""
  93. async def interaction_check(self, interaction: discord.Interaction) -> bool:
  94. bot: ChatBot = self.client # type: ignore[assignment]
  95. if interaction.guild_id is not None and interaction.guild_id == bot.home_guild_id():
  96. return True
  97. logger.warning("/%s refusée pour %s : serveur %s, hors du serveur du bot",
  98. (interaction.data or {}).get("name", "?"), interaction.user, interaction.guild_id)
  99. if interaction.type is discord.InteractionType.application_command:
  100. await interaction.response.send_message("Ce bot ne fonctionne que sur son propre serveur.",
  101. ephemeral=True)
  102. return False
  103. async def on_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
  104. logger.error("Erreur pendant /%s", (interaction.data or {}).get("name", "?"), exc_info=error)
  105. await reply_error(interaction)
  106. class ChatBot(commands.Bot):
  107. def __init__(self, settings: Settings):
  108. intents = discord.Intents.default()
  109. intents.message_content = True # nécessaire pour lire le texte des messages
  110. super().__init__(
  111. command_prefix=commands.when_mentioned, # aucune commande textuelle utilisée
  112. intents=intents,
  113. help_command=None,
  114. tree_cls=HomeTree,
  115. # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
  116. allowed_mentions=discord.AllowedMentions.none(),
  117. )
  118. self.settings = settings
  119. self.members = MemberRegistry(settings.memory_dir / "registre.json") if settings.memory else None
  120. if self.members and self.members.broken:
  121. self.members = None # registre illisible : mémoire coupée comme avec MEMORY=off (voir MemberRegistry)
  122. self.memory = MemoryStore(settings.memory_dir, self.members, settings.memory_member_limit,
  123. settings.memory_server_limit, settings.timezone) if self.members else None
  124. self.claude = ClaudeClient(settings, self.memory)
  125. self.history = ConversationHistory(settings.history_file, settings.max_history_length)
  126. self._pending: dict[int, list[PendingMessage]] = {} # messages en attente de réponse, par salon
  127. self._announced = False
  128. self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
  129. # Absent ou en « Ne pas déranger » : réponses différées, par salon.
  130. self._waiters: dict[int, asyncio.Task] = {} # attente en cours
  131. self._mentions: dict[int, list[discord.Message]] = {} # mentions en attente (hors salon dédié)
  132. # Messages en attente, gardés sur le disque pour y répondre même après un redémarrage.
  133. self._waiting_file = settings.history_file.with_name("waiting_messages.json")
  134. self._waiting: set[tuple[int, int]] = set() # (salon, message)
  135. self._last_main_message: datetime | None = None # date du dernier message reçu dans le salon dédié
  136. # Empreinte des commandes slash envoyées à Discord (voir sync_commands).
  137. self._commands_file = settings.history_file.with_name("slash_commands.sha256")
  138. # ------------------------------------------------------------ cycle de vie
  139. async def setup_hook(self) -> None:
  140. # Exécuté une seule fois au démarrage (contrairement à on_ready).
  141. self.tree.add_command(reset_command)
  142. if self.presence:
  143. self.tree.add_command(skip_command)
  144. # Toujours présentes : avec MEMORY=off, elles indiquent que la mémoire est coupée.
  145. self.tree.add_command(memory_command)
  146. self.tree.add_command(server_memory_command)
  147. await self.sync_commands()
  148. if self.memory:
  149. # Lecture de toutes les fiches, avant la connexion : elle ne bloque aucun message.
  150. await asyncio.to_thread(self.memory.index)
  151. s = self.settings
  152. memory = "off" if not s.memory else f"on ({s.memory_dir})" if self.memory else "coupée (registre illisible)"
  153. logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
  154. s.model, "on" if s.thinking else "off", s.effort or "par défaut",
  155. "on" if s.web_search else "off", memory)
  156. if not is_known(s.model):
  157. # Un nom mal écrit ne serait sinon découvert qu'au premier message (erreur 404).
  158. prices = ""
  159. if s.price_input_per_mtok is None or s.price_output_per_mtok is None:
  160. prices = (" S'il existe bien, renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK "
  161. "pour estimer les coûts.")
  162. logger.warning("Modèle %s inconnu du bot : vérifie CLAUDE_MODEL dans le .env.%s", s.model, prices)
  163. if self.memory:
  164. # Un fichier plein se réécrit d'un seul appel d'outil, qui doit tenir dans MAX_TOKENS avec
  165. # la réflexion : environ 2,5 caractères par token en français (estimation prudente),
  166. # plus une marge pour la réflexion et la réponse.
  167. needed = max(self.memory.member_limit, self.memory.server_limit) * 2 // 5 + 2000
  168. if s.max_tokens < needed:
  169. logger.warning("MAX_TOKENS (%s) est trop petit pour la mémoire des membres : la réécriture "
  170. "d'un fichier plein serait coupée et la note perdue. Mets au moins %s dans "
  171. "le .env, ou baisse MEMORY_SERVER_LIMIT et MEMORY_MEMBER_LIMIT.",
  172. s.max_tokens, needed)
  173. if self.presence:
  174. self.presence.start()
  175. async def sync_commands(self) -> None:
  176. """Envoie les commandes slash à Discord, seulement si elles ont changé depuis le dernier envoi
  177. (Discord limite ces envois : inutile de les refaire à chaque démarrage)."""
  178. payload = [command.to_dict(self.tree) for command in self.tree.get_commands()]
  179. signature = json.dumps([self.application_id, payload], sort_keys=True, ensure_ascii=False)
  180. digest = hashlib.sha256(signature.encode("utf-8")).hexdigest()
  181. try:
  182. if self._commands_file.read_text(encoding="utf-8").strip() == digest:
  183. logger.info("Commandes slash inchangées : pas de synchronisation.")
  184. return
  185. except OSError:
  186. pass # premier démarrage, ou fichier illisible : on synchronise
  187. try:
  188. synced = await self.tree.sync()
  189. except discord.HTTPException as exc: # le bot fonctionne quand même ; nouvel essai au prochain démarrage
  190. logger.error("Synchronisation des commandes slash impossible : %s", exc)
  191. return
  192. logger.info("%d commande(s) slash synchronisée(s).", len(synced))
  193. try:
  194. self._commands_file.write_text(digest, encoding="utf-8")
  195. except OSError as exc:
  196. logger.warning("Impossible d'écrire %s : %s", self._commands_file, exc)
  197. async def on_ready(self) -> None:
  198. logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
  199. if self.presence:
  200. await self.presence.apply() # une reconnexion complète efface le statut
  201. if self._announced:
  202. return # simple reconnexion : on ne reposte pas le message de démarrage
  203. self._announced = True
  204. self._replay_task = asyncio.create_task(self.replay_waiting()) # référence gardée : pas de ramasse-miettes
  205. channel = self.get_channel(self.settings.channel_id)
  206. if channel is None:
  207. logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot. "
  208. "En attendant, le bot ne répond nulle part.", self.settings.channel_id)
  209. return
  210. if others := [guild for guild in self.guilds if guild.id != self.home_guild_id()]:
  211. logger.warning("Le bot est aussi sur %d autre(s) serveur(s), où il ne répond pas : %s. Retire-le de ces "
  212. "serveurs, et décoche Public Bot dans le portail Discord (voir le README).", len(others),
  213. ", ".join(f"{guild.name} ({guild.id})" for guild in others))
  214. me = channel.guild.me if getattr(channel, "guild", None) else self.user
  215. logger.info("%d emoji(s) du serveur utilisable(s) dans les réponses.",
  216. len(usable_emojis(getattr(channel, "guild", None))))
  217. started = discord.utils.utcnow()
  218. embed = discord.Embed(
  219. title="En ligne",
  220. description=f"**{me.display_name}** vient de démarrer et répond de nouveau aux messages.",
  221. color=ONLINE_COLOR,
  222. timestamp=started,
  223. )
  224. embed.set_thumbnail(url=me.display_avatar.url)
  225. embed.add_field(name="Version", value=f"`{VERSION}`")
  226. embed.add_field(name="Modèle", value=display_name(self.settings.model))
  227. embed.add_field(name="Démarrage", value=discord.utils.format_dt(started, "R"))
  228. try:
  229. await channel.send(embed=embed)
  230. except discord.HTTPException as exc:
  231. logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
  232. async def close(self) -> None:
  233. if self.presence:
  234. self.presence.stop()
  235. for task in self._waiters.values():
  236. task.cancel() # les messages en attente restent dans waiting_messages.json
  237. await self.claude.close()
  238. await super().close()
  239. def home_guild_id(self) -> int | None:
  240. """Le serveur du salon dédié (CHANNEL_ID), le seul où le bot répond : la mémoire n'existe qu'en
  241. un exemplaire. None tant que le salon est introuvable (avant la connexion, CHANNEL_ID faux)."""
  242. guild = getattr(self.get_channel(self.settings.channel_id), "guild", None)
  243. return guild.id if guild else None
  244. async def on_guild_join(self, guild: discord.Guild) -> None:
  245. logger.warning("Bot ajouté au serveur %s (%s), où il ne répond pas : retire-le de ce serveur, et "
  246. "décoche Public Bot dans le portail Discord (voir le README).", guild.name, guild.id)
  247. # ------------------------------------------------------------- messages
  248. async def on_message(self, message: discord.Message) -> None:
  249. # Ignore ses propres messages et les webhooks : évite les boucles infinies.
  250. if message.author == self.user or message.webhook_id is not None:
  251. return
  252. # Ni autre serveur ni message privé : réponses payées par le propriétaire, mémoire du serveur.
  253. if message.guild is None or message.guild.id != self.home_guild_id():
  254. return
  255. if message.type not in USER_MESSAGE_TYPES:
  256. return # message système
  257. # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
  258. # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble.
  259. if message.author.bot and self.user not in message.mentions and not await self.replies_to_me(message):
  260. return
  261. in_main_channel = message.channel.id == self.settings.channel_id
  262. # Mention directe uniquement (@everyone et @here ne comptent pas).
  263. mentioned = self.user is not None and self.user in message.mentions
  264. if (in_main_channel or mentioned) and self.members:
  265. await self.register_members(message)
  266. if in_main_channel:
  267. await self.handle_main_channel(message)
  268. elif mentioned:
  269. await self.handle_mention(message)
  270. async def concerned_members(self, message: discord.Message) -> list[discord.User | discord.Member]:
  271. """Membres concernés par un message, sans les bots : auteur, auteur du message cité, membres
  272. mentionnés (ordre des fiches jointes quand elles sont trop nombreuses)."""
  273. users = [message.author]
  274. if (cited := await self.fetch_cited(message)) is not None:
  275. users.append(cited.author)
  276. users += message.mentions
  277. unique = {user.id: user for user in users if not user.bot}
  278. return list(unique.values())
  279. async def member_notes(self, messages: list[discord.Message]) -> str:
  280. """Fiches des membres concernés par ces messages, à joindre pour Claude (vide sans mémoire)."""
  281. if not self.memory:
  282. return ""
  283. ids = [user.id for message in messages for user in await self.concerned_members(message)]
  284. return self.memory.notes_for(ids, author_ids=[message.author.id for message in messages])
  285. async def register_members(self, message: discord.Message) -> None:
  286. """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
  287. for user in await self.concerned_members(message):
  288. await see_user(self.members, user)
  289. async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
  290. """Message modifié : la mémoire, ou le message s'il attend encore sa réponse, prend le nouveau texte."""
  291. message = payload.message
  292. # Aperçu d'un lien qui s'affiche, ou transfert (qui ne se modifie pas) : rien n'a changé.
  293. if message.edited_at is None or message.message_snapshots:
  294. return
  295. content = self.message_body(message)
  296. for pending in self._pending.get(payload.channel_id, []):
  297. if pending.message.id == message.id:
  298. pending.line["content"] = content
  299. if await self.history.edit(payload.channel_id, message.id, content):
  300. logger.info("Message modifié dans le salon %s : mémoire mise à jour.", payload.channel_id)
  301. async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
  302. await self.forget_deleted(payload.channel_id, {payload.message_id})
  303. async def on_raw_bulk_message_delete(self, payload: discord.RawBulkMessageDeleteEvent) -> None:
  304. await self.forget_deleted(payload.channel_id, payload.message_ids)
  305. async def forget_deleted(self, channel_id: int, message_ids: set[int]) -> None:
  306. """Messages supprimés : ils sortent de la mémoire et n'attendent plus de réponse."""
  307. # Listes modifiées sur place : une réponse sur le point de partir les lit encore.
  308. if pending := self._pending.get(channel_id):
  309. pending[:] = [p for p in pending if p.message.id not in message_ids]
  310. if mentions := self._mentions.get(channel_id):
  311. mentions[:] = [m for m in mentions if m.id not in message_ids]
  312. keys = {(channel_id, message_id) for message_id in message_ids}
  313. if keys & self._waiting:
  314. self._waiting -= keys
  315. self._save_waiting()
  316. if await self.history.delete(channel_id, message_ids):
  317. logger.info("%s message(s) supprimé(s) dans le salon %s : mémoire mise à jour.", len(message_ids), channel_id)
  318. async def handle_main_channel(self, message: discord.Message) -> None:
  319. if self._last_main_message is None or message.created_at > self._last_main_message:
  320. self._last_main_message = message.created_at
  321. content = message.content.strip()
  322. # Un sticker ou un emoji seul reçoit un sticker ou un emoji du serveur. S'il n'y en a aucun
  323. # d'utilisable, Claude répond avec des mots ; accompagné de texte, le sticker lui est décrit.
  324. if (message.stickers and not content and not message.attachments) or CUSTOM_EMOJI_ONLY.match(content):
  325. if not self.available():
  326. return # absent : pas de réaction aux stickers ni aux emojis
  327. if await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
  328. return
  329. forward, forward_images = await self.read_forward(message)
  330. if not (content or message.attachments or message.stickers or forward):
  331. return # sondage, transfert d'un message vide… : rien à lire
  332. attachments = await self.read_attachments(message)
  333. if attachments is False:
  334. return # pièce jointe refusée, l'utilisateur a été prévenu
  335. images, text_file = attachments
  336. # Les images du message cité ne sont utilisées que si le message n'en contient pas lui-même.
  337. reply, reply_images = await self.read_reply(message, want_images=not (images or forward_images))
  338. images = images or forward_images or reply_images
  339. line: dict = {"id": message.id, "date": self.timestamp(message), "author": author_name(message.author)}
  340. if forward:
  341. line["forward"] = forward
  342. else:
  343. if reply:
  344. line["reply_to"] = message.reference.message_id # citation retirée si ce message est supprimé
  345. line["reply"] = reply
  346. line["content"] = self.message_body(message)
  347. markers = [attachments_marker(images)] if images else []
  348. extra = ""
  349. if text_file:
  350. extra = text_file_prompt(*text_file)
  351. markers.append(text_file_marker(text_file[0]))
  352. if markers:
  353. line["attachments"] = markers
  354. # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
  355. # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
  356. self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, line, extra, images))
  357. if not self.available() or message.channel.id in self._waiters:
  358. self.answer_later(message, self.answer_main_channel)
  359. return
  360. await self.answer_main_channel(message.channel)
  361. async def answer_main_channel(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  362. """Répond en une fois aux messages en attente du salon dédié."""
  363. pending = self._pending.setdefault(channel.id, [])
  364. async with self.history.lock(channel.id):
  365. if not pending:
  366. return # déjà parti avec un message précédent
  367. batch = sorted(pending, key=lambda p: p.message.id)
  368. pending.clear()
  369. last = batch[-1].message
  370. if len(batch) > 1:
  371. logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
  372. # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
  373. parts = [(p.user_text, p.images) for p in batch]
  374. lines = [p.line for p in batch]
  375. notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
  376. if note := self.presence_note():
  377. notes.append(note)
  378. for note in reversed(notes):
  379. parts.insert(0, (note, []))
  380. lines.insert(0, {"content": note})
  381. try:
  382. async with typing(channel):
  383. try:
  384. notes = await self.member_notes([p.message for p in batch])
  385. answer = await self.claude.ask(self.history.get(channel.id), parts,
  386. emojis=emoji_names(last.guild), member_notes=notes)
  387. except ClaudeError as exc:
  388. await self.safe_reply(last, exc.user_message)
  389. return
  390. except Exception:
  391. logger.exception("Erreur inattendue pendant le traitement d'un message")
  392. await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
  393. return
  394. finally:
  395. self.forget_waiting(*(p.message for p in batch))
  396. await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
  397. await self.send_answer(last, answer.text)
  398. async def handle_mention(self, message: discord.Message) -> None:
  399. if not self.available() or message.channel.id in self._waiters:
  400. self._mentions.setdefault(message.channel.id, []).append(message)
  401. self.answer_later(message, self.answer_mentions)
  402. return
  403. await self.answer_mention(message)
  404. async def answer_mentions(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  405. """Répond, une par une, aux mentions reçues pendant une absence (avec « Répondre »)."""
  406. for message in sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id):
  407. try:
  408. await self.answer_mention(message, late)
  409. finally:
  410. self.forget_waiting(message)
  411. async def answer_mention(self, message: discord.Message, late: Status | None = None) -> None:
  412. """Hors du salon dédié : répond à une mention en lisant les messages récents."""
  413. lines: list[str] = []
  414. if self.settings.context_message_limit > 0:
  415. try:
  416. async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
  417. content = short_emojis(msg.clean_content.strip())
  418. if msg.attachments:
  419. content = (content + " [pièce jointe]").strip()
  420. if msg.stickers:
  421. content = (content + " [sticker]").strip()
  422. if content:
  423. lines.append(f"{author_name(msg.author)} : {content}")
  424. except discord.Forbidden:
  425. logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
  426. lines.reverse()
  427. # Seuls les MAX_IMAGES premières images (à défaut, le premier PDF) et le premier fichier texte
  428. # de la mention sont lus (les autres pièces jointes sont ignorées). Les images du message cité
  429. # ne servent que s'il n'y a ni image ni PDF.
  430. image_files = [a for a in message.attachments if looks_like_image(a)][:MAX_IMAGES]
  431. pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
  432. try:
  433. if image_files:
  434. images = await self.download_images(image_files)
  435. else:
  436. images = [await self.download_pdf(pdfs[0])] if pdfs else []
  437. except (ImageError, PdfError) as exc:
  438. await self.safe_reply(message, exc.user_message)
  439. return
  440. reply, reply_images = await self.read_reply(message, want_images=not images)
  441. images = images or reply_images
  442. bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
  443. text = short_emojis(message.clean_content.replace(bot_name, "").strip()) or "(simple mention, sans texte)"
  444. channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
  445. parts = []
  446. if late:
  447. parts.append(self.presence.late_note(late, several=False))
  448. if note := self.presence_note():
  449. parts.append(note)
  450. if lines:
  451. parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
  452. reply_part = f" ({reply})" if reply else ""
  453. parts.append(f"{self.timestamp(message)}, {author_name(message.author)} te mentionne{reply_part} et écrit : {text}")
  454. prompt = "\n\n".join(parts)
  455. text_files = [a for a in message.attachments if looks_like_text(a)]
  456. if text_files:
  457. try:
  458. prompt += text_file_prompt(*await self.download_text_file(text_files[0]))
  459. except TextFileError as exc:
  460. await self.safe_reply(message, exc.user_message)
  461. return
  462. async with typing(message.channel):
  463. try:
  464. answer = await self.claude.ask([], [(prompt, images)], emojis=emoji_names(message.guild),
  465. cache_history=False, member_notes=await self.member_notes([message]))
  466. except ClaudeError as exc:
  467. await self.safe_reply(message, exc.user_message)
  468. return
  469. except Exception:
  470. logger.exception("Erreur inattendue pendant le traitement d'une mention")
  471. await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
  472. return
  473. # En retard, la réponse cite la mention : on sait à quoi elle répond.
  474. await self.send_answer(message, answer.text, reply=late is not None)
  475. # ------------------------------------------------------ réponses différées
  476. def available(self) -> bool:
  477. return self.presence is None or self.presence.available
  478. def answer_later(self, message: discord.Message, answer) -> None:
  479. """Met le message de côté : answer(salon, late=statut) y répondra quand le bot le pourra."""
  480. self.remember_waiting(message)
  481. channel = message.channel
  482. if channel.id in self._waiters:
  483. return # une attente est déjà en cours dans ce salon : le message en profitera
  484. status = self.presence.status
  485. logger.info("Réponse différée dans #%s : %s.", channel, status.unavailability())
  486. self._waiters[channel.id] = asyncio.create_task(self._answer_when_possible(channel, answer, status))
  487. async def _answer_when_possible(self, channel: discord.abc.Messageable, answer, status: Status) -> None:
  488. try:
  489. await self.presence.wait_turn()
  490. finally:
  491. self._waiters.pop(channel.id, None)
  492. try:
  493. await answer(channel, late=status)
  494. except Exception:
  495. logger.exception("Erreur inattendue pendant une réponse différée")
  496. async def on_status_change(self, before: Status, after: Status) -> None:
  497. """Passe absent ou en « Ne pas déranger » : prévient le salon dédié si une conversation y est en cours."""
  498. if not before.available or after.available or self._last_main_message is None:
  499. return
  500. if discord.utils.utcnow() - self._last_main_message > DEPARTURE_WINDOW:
  501. return
  502. channel = self.get_channel(self.settings.channel_id)
  503. if channel is None:
  504. return
  505. async with self.history.lock(channel.id):
  506. # Attend la fin d'une réponse en cours ; le statut a pu changer encore entre-temps.
  507. if self.presence.status is not after:
  508. return
  509. parts = [(note, []) for note in (self.presence_note(), self.presence.departure_note()) if note]
  510. try:
  511. async with typing(channel):
  512. answer = await self.claude.ask(self.history.get(channel.id), parts,
  513. emojis=emoji_names(channel.guild))
  514. except ClaudeError as exc:
  515. logger.warning("Message d'absence non envoyé : %s", exc.user_message)
  516. return
  517. lines = [{"content": text} for text, _ in parts]
  518. await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
  519. logger.info("Conversation en cours dans #%s : le salon est prévenu de l'absence.", channel)
  520. for chunk in split_message(discord_emojis(short_emojis(answer.text), channel.guild)):
  521. try:
  522. await channel.send(chunk)
  523. except discord.HTTPException as exc:
  524. logger.error("Envoi du message d'absence impossible : %s", exc)
  525. return
  526. def remember_waiting(self, message: discord.Message) -> None:
  527. self._waiting.add((message.channel.id, message.id))
  528. self._save_waiting()
  529. def forget_waiting(self, *messages: discord.Message) -> None:
  530. keys = {(m.channel.id, m.id) for m in messages}
  531. if keys & self._waiting:
  532. self._waiting -= keys
  533. self._save_waiting()
  534. def _save_waiting(self) -> None:
  535. try:
  536. self._waiting_file.write_text(json.dumps(sorted(self._waiting)), encoding="utf-8")
  537. except OSError as exc:
  538. logger.warning("Impossible d'écrire %s : %s", self._waiting_file, exc)
  539. async def replay_waiting(self) -> None:
  540. """Au démarrage : reprend les messages restés sans réponse avant le redémarrage."""
  541. try:
  542. saved = json.loads(self._waiting_file.read_text(encoding="utf-8"))
  543. except FileNotFoundError:
  544. return
  545. except (OSError, ValueError) as exc:
  546. logger.warning("%s illisible, ignoré : %s", self._waiting_file, exc)
  547. return
  548. messages = []
  549. for channel_id, message_id in saved:
  550. try:
  551. channel = self.get_channel(channel_id) or await self.fetch_channel(channel_id)
  552. messages.append(await channel.fetch_message(message_id))
  553. except discord.HTTPException:
  554. pass # message supprimé, salon inaccessible…
  555. self._waiting.clear()
  556. self._save_waiting()
  557. if messages:
  558. logger.info("%d message(s) resté(s) sans réponse avant le redémarrage : reprise.", len(messages))
  559. # En parallèle, comme à leur arrivée : ceux du salon dédié sont regroupés.
  560. await asyncio.gather(*(self.on_message(m) for m in sorted(messages, key=lambda m: m.id)),
  561. return_exceptions=True)
  562. # ------------------------------------------------------------- réactions
  563. async def reply_to_sticker(self, message: discord.Message) -> bool:
  564. """Répond à un sticker par un sticker aléatoire du serveur (False si aucun n'est utilisable)."""
  565. if not message.stickers or message.guild is None:
  566. return False
  567. stickers = [s for s in message.guild.stickers if s.available]
  568. random.shuffle(stickers)
  569. for sticker in stickers:
  570. try:
  571. await message.channel.send(stickers=[sticker])
  572. logger.info("Sticker envoyé : %s", sticker.name)
  573. return True
  574. except discord.Forbidden as exc: # pas le droit d'envoyer de sticker : inutile d'insister
  575. logger.warning("Envoi de sticker interdit : %s", exc)
  576. break
  577. except discord.HTTPException as exc:
  578. logger.warning("Sticker %s inutilisable : %s", sticker.name, exc)
  579. return False
  580. async def reply_to_emoji(self, message: discord.Message) -> bool:
  581. """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur
  582. (False si le serveur n'en a aucun)."""
  583. if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
  584. return False
  585. emojis = usable_emojis(message.guild)
  586. if not emojis:
  587. return False
  588. try:
  589. await message.channel.send(str(random.choice(emojis)))
  590. except discord.HTTPException as exc:
  591. logger.warning("Impossible d'envoyer l'emoji : %s", exc)
  592. return True
  593. # ------------------------------------------------------- pièces jointes
  594. async def read_attachments(
  595. self, message: discord.Message
  596. ) -> tuple[list[ImageData], tuple[str, str] | None] | bool:
  597. """Vérifie les pièces jointes, puis télécharge les images (ou le PDF) et le fichier texte.
  598. Renvoie (images ou PDF, éventuellement aucun ; (nom, contenu) du fichier texte ou None),
  599. ou False si l'envoi est refusé (l'utilisateur est alors prévenu).
  600. """
  601. if not message.attachments:
  602. return [], None
  603. images = [a for a in message.attachments if looks_like_image(a)]
  604. pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
  605. texts = [a for a in message.attachments if looks_like_text(a)]
  606. others = [a.filename for a in message.attachments if a not in images + pdfs + texts]
  607. if others:
  608. kinds = "des images, des PDF" if self.settings.pdf_max_pages else "des images"
  609. await self.safe_reply(message, f"Je ne peux lire que {kinds} et des fichiers texte (.txt, .md, .csv, code…). "
  610. f"Fichier(s) non pris en charge : {', '.join(others)}.")
  611. return False
  612. if len(images) > MAX_IMAGES:
  613. await self.safe_reply(message, f"{MAX_IMAGES} images par message au maximum, s'il te plaît.")
  614. return False
  615. if pdfs and (images or len(pdfs) > 1):
  616. await self.safe_reply(message, "Un seul PDF par message, sans image, s'il te plaît.")
  617. return False
  618. if len(texts) > 1:
  619. await self.safe_reply(message, "Un seul fichier texte par message, s'il te plaît.")
  620. return False
  621. try:
  622. if images:
  623. files = await self.download_images(images)
  624. else:
  625. files = [await self.download_pdf(pdfs[0])] if pdfs else []
  626. text_file = await self.download_text_file(texts[0]) if texts else None
  627. except (ImageError, PdfError, TextFileError) as exc:
  628. await self.safe_reply(message, exc.user_message)
  629. return False
  630. return files, text_file
  631. async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
  632. """Télécharge un fichier texte et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
  633. if attachment.size > TEXT_FILE_MAX_BYTES:
  634. raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
  635. try:
  636. data = await attachment.read()
  637. except discord.HTTPException as exc:
  638. logger.error("Téléchargement du fichier texte impossible : %s", exc)
  639. raise TextFileError("Je n'ai pas réussi à télécharger ton fichier, réessaie.")
  640. return attachment.filename, decode_text_file(data)
  641. async def download_pdf(self, attachment: discord.Attachment) -> ImageData:
  642. """Télécharge et vérifie un PDF (lève PdfError en cas de problème)."""
  643. if attachment.size > PDF_MAX_BYTES:
  644. raise PdfError(f"Ce PDF est trop lourd (maximum {PDF_MAX_BYTES // 1_000_000} Mo).")
  645. try:
  646. data = await attachment.read()
  647. except discord.HTTPException as exc:
  648. logger.error("Téléchargement du PDF impossible : %s", exc)
  649. raise PdfError("Je n'ai pas réussi à télécharger ton PDF, réessaie.")
  650. # Lecture du PDF dans un thread : ne bloque pas le bot.
  651. return await asyncio.to_thread(prepare_pdf, attachment.filename, data, self.settings.pdf_max_pages)
  652. async def download_image(self, attachment: discord.Attachment) -> ImageData:
  653. """Télécharge et prépare une image (lève ImageError en cas de problème)."""
  654. if attachment.size > self.settings.max_image_size:
  655. max_mb = self.settings.max_image_size / 1_000_000
  656. raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
  657. try:
  658. data = await attachment.read()
  659. except discord.HTTPException as exc:
  660. logger.error("Téléchargement de l'image impossible : %s", exc)
  661. raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
  662. # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
  663. return await asyncio.to_thread(prepare_image, data)
  664. async def download_images(self, attachments: list[discord.Attachment]) -> list[ImageData]:
  665. """Télécharge et prépare plusieurs images en parallèle (lève ImageError si l'une pose problème).
  666. Si plusieurs échouent, l'erreur nomme la première dans l'ordre du message, pas la plus rapide.
  667. """
  668. results = await asyncio.gather(*(self.download_image(a) for a in attachments), return_exceptions=True)
  669. for attachment, result in zip(attachments, results):
  670. if isinstance(result, ImageError) and len(attachments) > 1:
  671. # précise laquelle, sinon « cette image » est ambigu
  672. raise ImageError(f"« {attachment.filename} » : {result.user_message}")
  673. if isinstance(result, BaseException):
  674. raise result
  675. return list(results)
  676. # ------------------------------------------- message cité ou transféré
  677. async def fetch_cited(self, message: discord.Message) -> discord.Message | None:
  678. """Renvoie le message auquel ce message répond (bouton « Répondre » de Discord),
  679. ou None s'il ne répond à rien ou si le message cité est inaccessible."""
  680. ref = message.reference
  681. if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
  682. return None
  683. cited = ref.resolved
  684. if cited is None: # Discord ne l'a pas fourni (vieux message) : on le télécharge
  685. try:
  686. cited = await message.channel.fetch_message(ref.message_id)
  687. except discord.HTTPException as exc:
  688. logger.info("Message cité inaccessible : %s", exc)
  689. return None
  690. ref.resolved = cited # évite de le télécharger une seconde fois
  691. return cited if isinstance(cited, discord.Message) else None # None : message cité supprimé
  692. async def replies_to_me(self, message: discord.Message) -> bool:
  693. cited = await self.fetch_cited(message)
  694. return cited is not None and cited.author == self.user
  695. async def read_reply(self, message: discord.Message, want_images: bool) -> tuple[str | None, list[ImageData]]:
  696. """Lit le message auquel l'utilisateur répond.
  697. Renvoie sa description pour Claude, ainsi que ses images si want_images est vrai,
  698. ou (None, []) si le message ne répond à rien ou si le message cité est inaccessible.
  699. """
  700. cited = await self.fetch_cited(message)
  701. if cited is None:
  702. return None, []
  703. images = await self.quoted_images(cited.attachments, "cité") if want_images else []
  704. description = self.describe_quoted(cited.clean_content, images, cited.attachments, cited.stickers)
  705. who = "à ton message" if cited.author == self.user else f"au message de {author_name(cited.author)}"
  706. return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", images
  707. async def read_forward(self, message: discord.Message) -> tuple[str | None, list[ImageData]]:
  708. """Lit le message transféré (bouton « Transférer » de Discord).
  709. Renvoie sa description pour Claude et ses images, ou (None, []) si le message
  710. n'est pas un transfert ou si le message transféré ne contient rien de lisible.
  711. """
  712. if not message.message_snapshots:
  713. return None, []
  714. snapshot = message.message_snapshots[0]
  715. images = await self.quoted_images(snapshot.attachments, "transféré")
  716. # Contrairement à un message normal, le contenu transféré garde ses mentions brutes (<@123>).
  717. content = self.clean_mentions(snapshot.content)
  718. description = self.describe_quoted(content, images, snapshot.attachments, snapshot.stickers)
  719. if not description:
  720. return None, []
  721. return f"du {self.timestamp(snapshot)} : {description}", images
  722. async def quoted_images(self, attachments: list[discord.Attachment], kind: str) -> list[ImageData]:
  723. """Télécharge les MAX_IMAGES premières images d'un message cité ou transféré (les illisibles sont ignorées)."""
  724. images = [a for a in attachments if looks_like_image(a)][:MAX_IMAGES]
  725. results = await asyncio.gather(*(self.download_image(a) for a in images), return_exceptions=True)
  726. for result in results:
  727. if isinstance(result, ImageError):
  728. logger.warning("Image du message %s ignorée : %s", kind, result.user_message)
  729. elif isinstance(result, BaseException):
  730. raise result
  731. return [r for r in results if isinstance(r, ImageData)]
  732. @staticmethod
  733. def describe_quoted(content: str, images: list[ImageData], attachments: list, stickers: list) -> str:
  734. """Description d'un message cité ou transféré (vide s'il n'y a rien de lisible)."""
  735. content = short_emojis(content.strip())
  736. if len(content) > QUOTE_MAX_LENGTH:
  737. content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
  738. parts = [f"« {content} »"] if content else []
  739. if len(images) > 1:
  740. parts.append(f"[ses {len(images)} images sont jointes à ce message]")
  741. elif images:
  742. parts.append("[son image est jointe à ce message]")
  743. elif attachments:
  744. parts.append("[pièce jointe]")
  745. if stickers:
  746. parts.append("[sticker]")
  747. return " ".join(parts)
  748. def clean_mentions(self, text: str) -> str:
  749. """Remplace les mentions brutes (<@123>) par des pseudos, comme le fait clean_content."""
  750. def name(match: re.Match) -> str:
  751. user = self.get_user(int(match.group(1)))
  752. return f"@{user.display_name}" if user else "@quelqu'un"
  753. return USER_MENTION.sub(name, text)
  754. # ------------------------------------------------------------- envoi
  755. def presence_note(self) -> str | None:
  756. return self.presence.prompt_note() if self.presence else None
  757. @staticmethod
  758. def message_body(message: discord.Message) -> str:
  759. """Texte d'un message du salon dédié, tel qu'il est conservé en mémoire (sans ce qui le précède)."""
  760. text = short_emojis(message.clean_content.strip())
  761. if message.stickers:
  762. text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
  763. return text or "(message sans texte)"
  764. def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
  765. return message.created_at.astimezone(self.settings.timezone).strftime(DATE_FORMAT)
  766. async def send_answer(self, message: discord.Message, answer: str, reply: bool = False) -> None:
  767. # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre »
  768. # (sauf reply=True : le premier morceau répond alors au message).
  769. # Les emojis passent par leur nom : un code écrit avec un mauvais identifiant est ainsi corrigé.
  770. answer = discord_emojis(short_emojis(answer), message.guild)
  771. for i, chunk in enumerate(split_message(answer)):
  772. # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
  773. reference = message.to_reference(fail_if_not_exists=False) if reply and i == 0 else None
  774. try:
  775. await message.channel.send(chunk, reference=reference, mention_author=False)
  776. except discord.HTTPException as exc:
  777. logger.error("Envoi de la réponse impossible : %s", exc)
  778. return
  779. async def safe_reply(self, message: discord.Message, text: str) -> None:
  780. try:
  781. await message.reply(text, mention_author=False)
  782. except discord.HTTPException:
  783. try:
  784. await message.channel.send(text)
  785. except discord.HTTPException as exc:
  786. logger.error("Impossible d'envoyer un message : %s", exc)
  787. # ------------------------------------------------------------- commandes slash
  788. async def allowed(interaction: discord.Interaction) -> bool:
  789. """Commande réservée aux administrateurs : prévient les autres et renvoie False."""
  790. # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
  791. if interaction.permissions.administrator:
  792. return True
  793. logger.info("Tentative de /%s refusée pour %s dans le salon %s",
  794. interaction.command.name if interaction.command else "?", interaction.user, interaction.channel_id)
  795. await interaction.response.send_message(
  796. "Seuls les administrateurs du serveur peuvent utiliser cette commande.",
  797. ephemeral=True,
  798. )
  799. return False
  800. @app_commands.command(name="reset", description="Réinitialise la mémoire du bot dans ce salon")
  801. @app_commands.guild_only()
  802. # Masque la commande aux membres qui n'ont pas la permission Administrateur.
  803. @app_commands.default_permissions(administrator=True)
  804. async def reset_command(interaction: discord.Interaction) -> None:
  805. if not await allowed(interaction):
  806. return
  807. bot: ChatBot = interaction.client # type: ignore[assignment]
  808. # Le reset attend la fin d'une éventuelle réponse en cours, ce qui peut dépasser
  809. # les 3 secondes accordées par Discord pour répondre à une commande.
  810. await interaction.response.defer()
  811. await bot.history.reset(interaction.channel_id)
  812. logger.info("Historique réinitialisé dans le salon %s par %s", interaction.channel_id, interaction.user)
  813. await interaction.followup.send("L'historique de conversation a été réinitialisé.")
  814. @app_commands.command(name="skip", description="Passe tout de suite au statut Discord suivant du bot")
  815. @app_commands.guild_only()
  816. @app_commands.default_permissions(administrator=True) # comme /reset
  817. async def skip_command(interaction: discord.Interaction) -> None:
  818. if not await allowed(interaction):
  819. return
  820. bot: ChatBot = interaction.client # type: ignore[assignment]
  821. # Une pensée ou une raison d'absence est écrite par Claude : plus de 3 secondes possibles.
  822. await interaction.response.defer(ephemeral=True)
  823. try:
  824. status = await bot.presence.skip()
  825. except Exception:
  826. logger.exception("Erreur inattendue pendant /skip")
  827. await interaction.followup.send("Le statut n'a pas pu être changé, regarde les logs.", ephemeral=True)
  828. return
  829. if status is None:
  830. await interaction.followup.send("C'est la nuit : le bot dort, il n'y a pas de statut à passer.", ephemeral=True)
  831. return
  832. logger.info("Statut passé par %s", interaction.user)
  833. remaining = bot.presence.remaining()
  834. duration = f", pendant environ {remaining}" if remaining else ""
  835. await interaction.followup.send(f"Nouveau statut : {status.label()}{duration}.", ephemeral=True)
  836. MEMORY_OFF = "La mémoire des membres est désactivée sur ce bot (MEMORY=off)."
  837. MEMORY_BROKEN = ("La mémoire des membres est coupée pour l'instant, à cause d'une erreur du bot. "
  838. "Préviens l'administrateur du bot.")
  839. def memory_off(bot: ChatBot) -> str:
  840. """Pourquoi /memoire est indisponible : MEMORY=off, ou registre des membres illisible."""
  841. return MEMORY_BROKEN if bot.settings.memory else MEMORY_OFF
  842. def bot_name(interaction: discord.Interaction) -> str:
  843. return interaction.guild.me.display_name if interaction.guild else interaction.client.user.name
  844. @app_commands.command(name="memoire", description="Voir, modifier ou effacer ce que le bot a retenu sur toi")
  845. @app_commands.describe(membre="Réservé aux administrateurs : la fiche d'un autre membre, ou son identifiant s'il est parti")
  846. @app_commands.guild_only()
  847. # discord.User plutôt que discord.Member : un membre parti du serveur reste accepté (Discord le résout par
  848. # son identifiant), pour que sa fiche puisse encore être effacée.
  849. async def memory_command(interaction: discord.Interaction, membre: discord.User | None = None) -> None:
  850. bot: ChatBot = interaction.client # type: ignore[assignment]
  851. if bot.memory is None:
  852. await interaction.response.send_message(memory_off(bot), ephemeral=True)
  853. return
  854. target = membre or interaction.user
  855. if target.bot:
  856. await interaction.response.send_message("Les bots n'ont pas de fiche.", ephemeral=True)
  857. return
  858. if target.id != interaction.user.id and not interaction.permissions.administrator:
  859. logger.info("/memoire sur %s refusé pour %s", target, interaction.user)
  860. await interaction.response.send_message(
  861. "Seuls les administrateurs peuvent voir la fiche d'un autre membre.", ephemeral=True)
  862. return
  863. await see_user(bot.members, target) # un membre qui n'a encore jamais écrit peut déjà activer la mémoire
  864. if target.id != interaction.user.id:
  865. # Lecture tracée, sans le contenu : un administrateur ne peut pas lire les fiches sans laisser de trace.
  866. logger.info("/memoire : fiche de %s ouverte par %s", target, interaction.user)
  867. card = MemoryCard(bot.memory, member_target(bot.memory, target, interaction.user.id),
  868. interaction.user.id, bot_name(interaction))
  869. await card.send(interaction)
  870. @app_commands.command(name="memoire-serveur", description="Voir ou modifier le fichier général du bot sur le serveur")
  871. @app_commands.guild_only()
  872. @app_commands.default_permissions(administrator=True) # comme /reset
  873. async def server_memory_command(interaction: discord.Interaction) -> None:
  874. if not await allowed(interaction):
  875. return
  876. bot: ChatBot = interaction.client # type: ignore[assignment]
  877. if bot.memory is None:
  878. await interaction.response.send_message(memory_off(bot), ephemeral=True)
  879. return
  880. await MemoryCard(bot.memory, server_target(), interaction.user.id, bot_name(interaction)).send(interaction)