discord_bot.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. """Le bot Discord : événements, commandes et réactions."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import random
  7. import re
  8. from dataclasses import dataclass
  9. import discord
  10. from discord import app_commands
  11. from discord.ext import commands
  12. from . import VERSION
  13. from .claude_client import ClaudeClient, ClaudeError, ImageData
  14. from .config import Settings
  15. from .history import IMAGE_MARKER, WEB_MARKER, ConversationHistory
  16. from .images import ImageError, prepare_image
  17. from .presence import PresenceManager, Status
  18. from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
  19. from .text_utils import split_message
  20. logger = logging.getLogger("chatbot.discord")
  21. CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
  22. USER_MENTION = re.compile(r"<@!?(\d+)>")
  23. IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
  24. # Longueur maximale du texte d'un message cité ou transféré transmis à Claude.
  25. QUOTE_MAX_LENGTH = 500
  26. # Messages écrits par quelqu'un ; les autres types sont des messages système
  27. # (arrivée d'un membre, message épinglé, boost, fil créé…).
  28. USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
  29. def looks_like_image(attachment: discord.Attachment) -> bool:
  30. return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
  31. def looks_like_text(attachment: discord.Attachment) -> bool:
  32. return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
  33. def text_file_prompt(name: str, content: str) -> str:
  34. """Contenu d'un fichier .txt tel qu'il est transmis à Claude."""
  35. return f"\n\n[Contenu du fichier joint « {name} » :]\n{content}\n[Fin du fichier « {name} »]"
  36. def text_file_marker(name: str) -> str:
  37. """Remplace un fichier .txt dans la mémoire (son contenu n'est pas conservé)."""
  38. return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
  39. @dataclass
  40. class PendingMessage:
  41. """Message du salon dédié, prêt à être envoyé à Claude."""
  42. message: discord.Message
  43. user_text: str # texte envoyé à Claude
  44. stored_text: str # texte conservé en mémoire
  45. image: ImageData | None
  46. class ChatBot(commands.Bot):
  47. def __init__(self, settings: Settings):
  48. intents = discord.Intents.default()
  49. intents.message_content = True # nécessaire pour lire le texte des messages
  50. super().__init__(
  51. command_prefix=commands.when_mentioned, # aucune commande textuelle utilisée
  52. intents=intents,
  53. help_command=None,
  54. # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
  55. allowed_mentions=discord.AllowedMentions.none(),
  56. )
  57. self.settings = settings
  58. self.claude = ClaudeClient(settings)
  59. self.history = ConversationHistory(settings.history_file, settings.max_history_length)
  60. self._pending: dict[int, list[PendingMessage]] = {} # messages en attente de réponse, par salon
  61. self._announced = False
  62. self.presence = PresenceManager(self, self.claude, settings) if settings.presence else None
  63. # Absent ou en « Ne pas déranger » : réponses différées, par salon.
  64. self._waiters: dict[int, asyncio.Task] = {} # attente en cours
  65. self._mentions: dict[int, list[discord.Message]] = {} # mentions en attente (hors salon dédié)
  66. # Messages en attente, gardés sur le disque pour y répondre même après un redémarrage.
  67. self._waiting_file = settings.history_file.with_name("waiting_messages.json")
  68. self._waiting: set[tuple[int, int]] = set() # (salon, message)
  69. # ------------------------------------------------------------ cycle de vie
  70. async def setup_hook(self) -> None:
  71. # Exécuté une seule fois au démarrage (contrairement à on_ready).
  72. self.tree.add_command(reset_command)
  73. synced = await self.tree.sync()
  74. logger.info("%d commande(s) slash synchronisée(s).", len(synced))
  75. s = self.settings
  76. logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s",
  77. s.model, "on" if s.thinking else "off", s.effort or "par défaut",
  78. "on" if s.web_search else "off")
  79. if self.presence:
  80. self.presence.start()
  81. async def on_ready(self) -> None:
  82. logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
  83. if self.presence:
  84. await self.presence.apply() # une reconnexion complète efface le statut
  85. if self._announced:
  86. return # simple reconnexion : on ne reposte pas le message de démarrage
  87. self._announced = True
  88. self._replay_task = asyncio.create_task(self.replay_waiting()) # référence gardée : pas de ramasse-miettes
  89. channel = self.get_channel(self.settings.channel_id)
  90. if channel is None:
  91. logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot.",
  92. self.settings.channel_id)
  93. return
  94. name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
  95. embed = discord.Embed(
  96. title="Bot en ligne",
  97. description=f"{name} est désormais en ligne. Version {VERSION}.",
  98. color=discord.Color.green(),
  99. )
  100. try:
  101. await channel.send(embed=embed)
  102. except discord.HTTPException as exc:
  103. logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
  104. async def close(self) -> None:
  105. if self.presence:
  106. self.presence.stop()
  107. for task in self._waiters.values():
  108. task.cancel() # les messages en attente restent dans waiting_messages.json
  109. await self.claude.close()
  110. await super().close()
  111. # ------------------------------------------------------------- messages
  112. async def on_message(self, message: discord.Message) -> None:
  113. # Ignore ses propres messages et les webhooks : évite les boucles infinies.
  114. if message.author == self.user or message.webhook_id is not None:
  115. return
  116. if message.type not in USER_MESSAGE_TYPES:
  117. return # message système
  118. # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
  119. # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble.
  120. if message.author.bot and self.user not in message.mentions and not await self.replies_to_me(message):
  121. return
  122. in_main_channel = message.channel.id == self.settings.channel_id
  123. # Mention directe uniquement (@everyone et @here ne comptent pas).
  124. mentioned = self.user is not None and self.user in message.mentions
  125. if in_main_channel:
  126. await self.handle_main_channel(message)
  127. elif mentioned:
  128. await self.handle_mention(message)
  129. async def handle_main_channel(self, message: discord.Message) -> None:
  130. if not self.available():
  131. if message.stickers or CUSTOM_EMOJI_ONLY.match(message.content.strip()):
  132. return # absent : pas de réaction aux stickers ni aux emojis
  133. elif await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
  134. return
  135. forward, forward_image = await self.read_forward(message)
  136. if not (message.content.strip() or message.attachments or forward):
  137. return # sondage, transfert d'un message vide… : rien à lire
  138. attachments = await self.read_attachments(message)
  139. if attachments is False:
  140. return # pièce jointe refusée, l'utilisateur a été prévenu
  141. image, text_file = attachments
  142. # L'image du message cité n'est utilisée que si le message n'en contient pas lui-même.
  143. reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
  144. image = image or forward_image or reply_image
  145. if forward:
  146. user_text = f"{self.timestamp(message)}, {message.author.display_name} a transféré un message {forward}"
  147. else:
  148. text = message.clean_content.strip() or "(message sans texte)"
  149. reply_part = f" ({reply})" if reply else ""
  150. user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
  151. stored_text = user_text + (f" {IMAGE_MARKER}" if image else "")
  152. if text_file:
  153. user_text += text_file_prompt(*text_file)
  154. stored_text += f" {text_file_marker(text_file[0])}"
  155. # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
  156. # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
  157. self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, user_text, stored_text, image))
  158. if not self.available() or message.channel.id in self._waiters:
  159. self.answer_later(message, self.answer_main_channel)
  160. return
  161. await self.answer_main_channel(message.channel)
  162. async def answer_main_channel(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  163. """Répond en une fois aux messages en attente du salon dédié."""
  164. pending = self._pending.setdefault(channel.id, [])
  165. async with self.history.lock(channel.id):
  166. if not pending:
  167. return # déjà parti avec un message précédent
  168. batch = sorted(pending, key=lambda p: p.message.id)
  169. pending.clear()
  170. last = batch[-1].message
  171. if len(batch) > 1:
  172. logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
  173. # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
  174. parts = [(p.user_text, p.image) for p in batch]
  175. stored_lines = [p.stored_text for p in batch]
  176. notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
  177. if note := self.presence_note():
  178. notes.append(note)
  179. for note in reversed(notes):
  180. parts.insert(0, (note, None))
  181. stored_lines.insert(0, note)
  182. try:
  183. async with channel.typing():
  184. try:
  185. answer = await self.claude.ask(self.history.get(channel.id), parts)
  186. except ClaudeError as exc:
  187. await self.safe_reply(last, exc.user_message)
  188. return
  189. except Exception:
  190. logger.exception("Erreur inattendue pendant le traitement d'un message")
  191. await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
  192. return
  193. finally:
  194. self.forget_waiting(*(p.message for p in batch))
  195. stored_text = "\n".join(stored_lines)
  196. if answer.web_used:
  197. stored_text += f" {WEB_MARKER}"
  198. await self.history.add_exchange(channel.id, stored_text, answer.text)
  199. await self.send_answer(last, answer.text)
  200. async def handle_mention(self, message: discord.Message) -> None:
  201. if not self.available() or message.channel.id in self._waiters:
  202. self._mentions.setdefault(message.channel.id, []).append(message)
  203. self.answer_later(message, self.answer_mentions)
  204. return
  205. await self.answer_mention(message)
  206. async def answer_mentions(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  207. """Répond, une par une, aux mentions reçues pendant une absence (avec « Répondre »)."""
  208. for message in sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id):
  209. try:
  210. await self.answer_mention(message, late)
  211. finally:
  212. self.forget_waiting(message)
  213. async def answer_mention(self, message: discord.Message, late: Status | None = None) -> None:
  214. """Hors du salon dédié : répond à une mention en lisant les messages récents."""
  215. lines: list[str] = []
  216. if self.settings.context_message_limit > 0:
  217. try:
  218. async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
  219. content = msg.clean_content.strip()
  220. if msg.attachments:
  221. content = (content + " [pièce jointe]").strip()
  222. if msg.stickers:
  223. content = (content + " [sticker]").strip()
  224. if content:
  225. lines.append(f"{msg.author.display_name} : {content}")
  226. except discord.Forbidden:
  227. logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
  228. lines.reverse()
  229. # Seules la première image et le premier fichier .txt de la mention sont lus (les autres
  230. # pièces jointes sont ignorées). L'image du message cité ne sert que s'il n'y en a pas.
  231. images = [a for a in message.attachments if looks_like_image(a)]
  232. try:
  233. image = await self.download_image(images[0]) if images else None
  234. except ImageError as exc:
  235. await self.safe_reply(message, exc.user_message)
  236. return
  237. reply, reply_image = await self.read_reply(message, want_image=image is None)
  238. image = image or reply_image
  239. bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
  240. text = message.clean_content.replace(bot_name, "").strip() or "(simple mention, sans texte)"
  241. channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
  242. parts = []
  243. if late:
  244. parts.append(self.presence.late_note(late, several=False))
  245. if note := self.presence_note():
  246. parts.append(note)
  247. if lines:
  248. parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
  249. reply_part = f" ({reply})" if reply else ""
  250. parts.append(f"{self.timestamp(message)}, {message.author.display_name} te mentionne{reply_part} et écrit : {text}")
  251. prompt = "\n\n".join(parts)
  252. text_files = [a for a in message.attachments if looks_like_text(a)]
  253. if text_files:
  254. try:
  255. prompt += text_file_prompt(*await self.download_text_file(text_files[0]))
  256. except TextFileError as exc:
  257. await self.safe_reply(message, exc.user_message)
  258. return
  259. async with message.channel.typing():
  260. try:
  261. answer = await self.claude.ask([], [(prompt, image)], cache_history=False)
  262. except ClaudeError as exc:
  263. await self.safe_reply(message, exc.user_message)
  264. return
  265. except Exception:
  266. logger.exception("Erreur inattendue pendant le traitement d'une mention")
  267. await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
  268. return
  269. # En retard, la réponse cite la mention : on sait à quoi elle répond.
  270. await self.send_answer(message, answer.text, reply=late is not None)
  271. # ------------------------------------------------------ réponses différées
  272. def available(self) -> bool:
  273. return self.presence is None or self.presence.available
  274. def answer_later(self, message: discord.Message, answer) -> None:
  275. """Met le message de côté : answer(salon, late=statut) y répondra quand le bot le pourra."""
  276. self.remember_waiting(message)
  277. channel = message.channel
  278. if channel.id in self._waiters:
  279. return # une attente est déjà en cours dans ce salon : le message en profitera
  280. status = self.presence.status
  281. logger.info("Réponse différée dans #%s : %s.", channel, status.unavailability())
  282. self._waiters[channel.id] = asyncio.create_task(self._answer_when_possible(channel, answer, status))
  283. async def _answer_when_possible(self, channel: discord.abc.Messageable, answer, status: Status) -> None:
  284. try:
  285. await self.presence.wait_turn()
  286. finally:
  287. self._waiters.pop(channel.id, None)
  288. try:
  289. await answer(channel, late=status)
  290. except Exception:
  291. logger.exception("Erreur inattendue pendant une réponse différée")
  292. def remember_waiting(self, message: discord.Message) -> None:
  293. self._waiting.add((message.channel.id, message.id))
  294. self._save_waiting()
  295. def forget_waiting(self, *messages: discord.Message) -> None:
  296. keys = {(m.channel.id, m.id) for m in messages}
  297. if keys & self._waiting:
  298. self._waiting -= keys
  299. self._save_waiting()
  300. def _save_waiting(self) -> None:
  301. try:
  302. self._waiting_file.write_text(json.dumps(sorted(self._waiting)), encoding="utf-8")
  303. except OSError as exc:
  304. logger.warning("Impossible d'écrire %s : %s", self._waiting_file, exc)
  305. async def replay_waiting(self) -> None:
  306. """Au démarrage : reprend les messages restés sans réponse avant le redémarrage."""
  307. try:
  308. saved = json.loads(self._waiting_file.read_text(encoding="utf-8"))
  309. except FileNotFoundError:
  310. return
  311. except (OSError, ValueError) as exc:
  312. logger.warning("%s illisible, ignoré : %s", self._waiting_file, exc)
  313. return
  314. messages = []
  315. for channel_id, message_id in saved:
  316. try:
  317. channel = self.get_channel(channel_id) or await self.fetch_channel(channel_id)
  318. messages.append(await channel.fetch_message(message_id))
  319. except discord.HTTPException:
  320. pass # message supprimé, salon inaccessible…
  321. self._waiting.clear()
  322. self._save_waiting()
  323. if messages:
  324. logger.info("%d message(s) resté(s) sans réponse avant le redémarrage : reprise.", len(messages))
  325. # En parallèle, comme à leur arrivée : ceux du salon dédié sont regroupés.
  326. await asyncio.gather(*(self.on_message(m) for m in sorted(messages, key=lambda m: m.id)),
  327. return_exceptions=True)
  328. # ------------------------------------------------------------- réactions
  329. async def reply_to_sticker(self, message: discord.Message) -> bool:
  330. """Répond à un sticker par un sticker aléatoire du serveur."""
  331. if not message.stickers:
  332. return False
  333. if message.guild is None:
  334. return True
  335. stickers = [s for s in message.guild.stickers if s.available]
  336. random.shuffle(stickers)
  337. for sticker in stickers:
  338. try:
  339. await message.channel.send(stickers=[sticker])
  340. logger.info("Sticker envoyé : %s", sticker.name)
  341. return True
  342. except discord.HTTPException as exc:
  343. logger.warning("Sticker %s inutilisable : %s", sticker.name, exc)
  344. await message.channel.send("Aucun sticker utilisable sur ce serveur.")
  345. return True
  346. async def reply_to_emoji(self, message: discord.Message) -> bool:
  347. """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur."""
  348. if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
  349. return False
  350. emojis = [e for e in message.guild.emojis if e.available] if message.guild else []
  351. if not emojis:
  352. await message.channel.send("Aucun emoji personnalisé sur ce serveur.")
  353. return True
  354. try:
  355. await message.channel.send(str(random.choice(emojis)))
  356. except discord.HTTPException as exc:
  357. logger.warning("Impossible d'envoyer l'emoji : %s", exc)
  358. return True
  359. # ------------------------------------------------------- pièces jointes
  360. async def read_attachments(
  361. self, message: discord.Message
  362. ) -> tuple[ImageData | None, tuple[str, str] | None] | bool:
  363. """Vérifie les pièces jointes, puis télécharge l'image et le fichier .txt.
  364. Renvoie (image, (nom, contenu) du fichier texte), chacun pouvant valoir None,
  365. ou False si l'envoi est refusé (l'utilisateur est alors prévenu).
  366. """
  367. if not message.attachments:
  368. return None, None
  369. images = [a for a in message.attachments if looks_like_image(a)]
  370. texts = [a for a in message.attachments if looks_like_text(a)]
  371. others = [a.filename for a in message.attachments if a not in images and a not in texts]
  372. if others:
  373. await self.safe_reply(message, f"Je ne peux lire que des images et des fichiers .txt. Fichier(s) non pris en charge : {', '.join(others)}.")
  374. return False
  375. if len(images) > 1:
  376. await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
  377. return False
  378. if len(texts) > 1:
  379. await self.safe_reply(message, "Un seul fichier .txt par message, s'il te plaît.")
  380. return False
  381. try:
  382. image = await self.download_image(images[0]) if images else None
  383. text_file = await self.download_text_file(texts[0]) if texts else None
  384. except (ImageError, TextFileError) as exc:
  385. await self.safe_reply(message, exc.user_message)
  386. return False
  387. return image, text_file
  388. async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
  389. """Télécharge un fichier .txt et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
  390. if attachment.size > TEXT_FILE_MAX_BYTES:
  391. raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
  392. try:
  393. data = await attachment.read()
  394. except discord.HTTPException as exc:
  395. logger.error("Téléchargement du fichier texte impossible : %s", exc)
  396. raise TextFileError("Je n'ai pas réussi à télécharger ton fichier, réessaie.")
  397. return attachment.filename, decode_text_file(data)
  398. async def download_image(self, attachment: discord.Attachment) -> ImageData:
  399. """Télécharge et prépare une image (lève ImageError en cas de problème)."""
  400. if attachment.size > self.settings.max_image_size:
  401. max_mb = self.settings.max_image_size / 1_000_000
  402. raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
  403. try:
  404. data = await attachment.read()
  405. except discord.HTTPException as exc:
  406. logger.error("Téléchargement de l'image impossible : %s", exc)
  407. raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
  408. # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
  409. return await asyncio.to_thread(prepare_image, data)
  410. # ------------------------------------------- message cité ou transféré
  411. async def fetch_cited(self, message: discord.Message) -> discord.Message | None:
  412. """Renvoie le message auquel ce message répond (bouton « Répondre » de Discord),
  413. ou None s'il ne répond à rien ou si le message cité est inaccessible."""
  414. ref = message.reference
  415. if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
  416. return None
  417. cited = ref.resolved
  418. if cited is None: # Discord ne l'a pas fourni (vieux message) : on le télécharge
  419. try:
  420. cited = await message.channel.fetch_message(ref.message_id)
  421. except discord.HTTPException as exc:
  422. logger.info("Message cité inaccessible : %s", exc)
  423. return None
  424. ref.resolved = cited # évite de le télécharger une seconde fois
  425. return cited if isinstance(cited, discord.Message) else None # None : message cité supprimé
  426. async def replies_to_me(self, message: discord.Message) -> bool:
  427. cited = await self.fetch_cited(message)
  428. return cited is not None and cited.author == self.user
  429. async def read_reply(self, message: discord.Message, want_image: bool) -> tuple[str | None, ImageData | None]:
  430. """Lit le message auquel l'utilisateur répond.
  431. Renvoie sa description pour Claude, ainsi que son image si want_image est vrai,
  432. ou (None, None) si le message ne répond à rien ou si le message cité est inaccessible.
  433. """
  434. cited = await self.fetch_cited(message)
  435. if cited is None:
  436. return None, None
  437. image = await self.first_image(cited.attachments, "cité") if want_image else None
  438. description = self.describe_quoted(cited.clean_content, image, cited.attachments, cited.stickers)
  439. who = "à ton message" if cited.author == self.user else f"au message de {cited.author.display_name}"
  440. return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", image
  441. async def read_forward(self, message: discord.Message) -> tuple[str | None, ImageData | None]:
  442. """Lit le message transféré (bouton « Transférer » de Discord).
  443. Renvoie sa description pour Claude et son image, ou (None, None) si le message
  444. n'est pas un transfert ou si le message transféré ne contient rien de lisible.
  445. """
  446. if not message.message_snapshots:
  447. return None, None
  448. snapshot = message.message_snapshots[0]
  449. image = await self.first_image(snapshot.attachments, "transféré")
  450. # Contrairement à un message normal, le contenu transféré garde ses mentions brutes (<@123>).
  451. content = self.clean_mentions(snapshot.content)
  452. description = self.describe_quoted(content, image, snapshot.attachments, snapshot.stickers)
  453. if not description:
  454. return None, None
  455. return f"du {self.timestamp(snapshot)} : {description}", image
  456. async def first_image(self, attachments: list[discord.Attachment], kind: str) -> ImageData | None:
  457. """Télécharge la première image d'un message cité ou transféré (None si aucune ou illisible)."""
  458. images = [a for a in attachments if looks_like_image(a)]
  459. if not images:
  460. return None
  461. try:
  462. return await self.download_image(images[0])
  463. except ImageError as exc:
  464. logger.warning("Image du message %s ignorée : %s", kind, exc.user_message)
  465. return None
  466. @staticmethod
  467. def describe_quoted(content: str, image: ImageData | None, attachments: list, stickers: list) -> str:
  468. """Description d'un message cité ou transféré (vide s'il n'y a rien de lisible)."""
  469. content = content.strip()
  470. if len(content) > QUOTE_MAX_LENGTH:
  471. content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
  472. parts = [f"« {content} »"] if content else []
  473. if image is not None:
  474. parts.append("[son image est jointe à ce message]")
  475. elif attachments:
  476. parts.append("[pièce jointe]")
  477. if stickers:
  478. parts.append("[sticker]")
  479. return " ".join(parts)
  480. def clean_mentions(self, text: str) -> str:
  481. """Remplace les mentions brutes (<@123>) par des pseudos, comme le fait clean_content."""
  482. def name(match: re.Match) -> str:
  483. user = self.get_user(int(match.group(1)))
  484. return f"@{user.display_name}" if user else "@quelqu'un"
  485. return USER_MENTION.sub(name, text)
  486. # ------------------------------------------------------------- envoi
  487. def presence_note(self) -> str | None:
  488. return self.presence.prompt_note() if self.presence else None
  489. def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
  490. return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
  491. async def send_answer(self, message: discord.Message, answer: str, reply: bool = False) -> None:
  492. # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre »
  493. # (sauf reply=True : le premier morceau répond alors au message).
  494. for i, chunk in enumerate(split_message(answer)):
  495. # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
  496. reference = message.to_reference(fail_if_not_exists=False) if reply and i == 0 else None
  497. try:
  498. await message.channel.send(chunk, reference=reference, mention_author=False)
  499. except discord.HTTPException as exc:
  500. logger.error("Envoi de la réponse impossible : %s", exc)
  501. return
  502. async def safe_reply(self, message: discord.Message, text: str) -> None:
  503. try:
  504. await message.reply(text, mention_author=False)
  505. except discord.HTTPException:
  506. try:
  507. await message.channel.send(text)
  508. except discord.HTTPException as exc:
  509. logger.error("Impossible d'envoyer un message : %s", exc)
  510. # ------------------------------------------------------------- commandes slash
  511. @app_commands.command(name="reset", description="Réinitialise la mémoire du bot dans ce salon")
  512. @app_commands.guild_only()
  513. # Masque la commande aux membres sans « Gérer les messages » (modérateurs) ;
  514. # les administrateurs ont implicitement toutes les permissions.
  515. @app_commands.default_permissions(manage_messages=True)
  516. async def reset_command(interaction: discord.Interaction) -> None:
  517. # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
  518. perms = interaction.permissions
  519. if not (perms.manage_messages or perms.administrator):
  520. logger.info("Tentative de /reset refusée pour %s dans le salon %s", interaction.user, interaction.channel_id)
  521. await interaction.response.send_message(
  522. "Seuls les modérateurs et administrateurs du serveur peuvent utiliser cette commande.",
  523. ephemeral=True,
  524. )
  525. return
  526. bot: ChatBot = interaction.client # type: ignore[assignment]
  527. # Le reset attend la fin d'une éventuelle réponse en cours, ce qui peut dépasser
  528. # les 3 secondes accordées par Discord pour répondre à une commande.
  529. await interaction.response.defer()
  530. await bot.history.reset(interaction.channel_id)
  531. logger.info("Historique réinitialisé dans le salon %s par %s", interaction.channel_id, interaction.user)
  532. await interaction.followup.send("L'historique de conversation a été réinitialisé.")