discord_bot.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """Le bot Discord : événements, commandes et réactions."""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. import random
  6. import re
  7. import discord
  8. from discord import app_commands
  9. from discord.ext import commands
  10. from . import VERSION
  11. from .claude_client import ClaudeClient, ClaudeError, ImageData
  12. from .config import Settings
  13. from .history import IMAGE_MARKER, ConversationHistory
  14. from .images import ImageError, prepare_image
  15. from .text_utils import split_message
  16. logger = logging.getLogger("chatbot.discord")
  17. CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
  18. IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
  19. # Longueur maximale du texte d'un message cité transmis à Claude.
  20. QUOTE_MAX_LENGTH = 500
  21. def looks_like_image(attachment: discord.Attachment) -> bool:
  22. return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
  23. class ChatBot(commands.Bot):
  24. def __init__(self, settings: Settings):
  25. intents = discord.Intents.default()
  26. intents.message_content = True # nécessaire pour lire le texte des messages
  27. super().__init__(
  28. command_prefix=commands.when_mentioned, # aucune commande textuelle utilisée
  29. intents=intents,
  30. help_command=None,
  31. # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
  32. allowed_mentions=discord.AllowedMentions.none(),
  33. )
  34. self.settings = settings
  35. self.claude = ClaudeClient(settings)
  36. self.history = ConversationHistory(settings.history_file, settings.max_history_length)
  37. self._announced = False
  38. # ------------------------------------------------------------ cycle de vie
  39. async def setup_hook(self) -> None:
  40. # Exécuté une seule fois au démarrage (contrairement à on_ready).
  41. self.tree.add_command(reset_command)
  42. synced = await self.tree.sync()
  43. logger.info("%d commande(s) slash synchronisée(s).", len(synced))
  44. async def on_ready(self) -> None:
  45. logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
  46. if self._announced:
  47. return # simple reconnexion : on ne reposte pas le message de démarrage
  48. self._announced = True
  49. channel = self.get_channel(self.settings.channel_id)
  50. if channel is None:
  51. logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot.",
  52. self.settings.channel_id)
  53. return
  54. name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
  55. embed = discord.Embed(
  56. title="Bot en ligne",
  57. description=f"{name} est désormais en ligne. Version {VERSION}.",
  58. color=discord.Color.green(),
  59. )
  60. try:
  61. await channel.send(embed=embed)
  62. except discord.HTTPException as exc:
  63. logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
  64. async def close(self) -> None:
  65. await self.claude.close()
  66. await super().close()
  67. # ------------------------------------------------------------- messages
  68. async def on_message(self, message: discord.Message) -> None:
  69. # Ignore les bots (dont lui-même) et les webhooks : évite les boucles infinies.
  70. if message.author.bot or message.webhook_id is not None:
  71. return
  72. in_main_channel = message.channel.id == self.settings.channel_id
  73. # Mention directe uniquement (@everyone et @here ne comptent pas).
  74. mentioned = self.user is not None and self.user in message.mentions
  75. if in_main_channel:
  76. await self.handle_main_channel(message)
  77. elif mentioned:
  78. await self.handle_mention(message)
  79. async def handle_main_channel(self, message: discord.Message) -> None:
  80. if await self.reply_to_sticker(message):
  81. return
  82. if await self.reply_to_emoji(message):
  83. return
  84. image = await self.read_image(message)
  85. if image is False:
  86. return # pièce jointe refusée, l'utilisateur a été prévenu
  87. # L'image du message cité n'est utilisée que si le message n'en contient pas lui-même.
  88. reply, reply_image = await self.read_reply(message, want_image=image is None)
  89. image = image or reply_image
  90. text = message.clean_content.strip() or "(message sans texte)"
  91. reply_part = f" ({reply})" if reply else ""
  92. user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
  93. stored_text = user_text + (f" {IMAGE_MARKER}" if image else "")
  94. # Un seul appel à la fois par salon : l'historique reste cohérent.
  95. async with self.history.lock(message.channel.id):
  96. async with message.channel.typing():
  97. try:
  98. answer = await self.claude.ask(self.history.get(message.channel.id), user_text, image or None)
  99. except ClaudeError as exc:
  100. await self.safe_reply(message, exc.user_message)
  101. return
  102. except Exception:
  103. logger.exception("Erreur inattendue pendant le traitement d'un message")
  104. await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
  105. return
  106. await self.history.add_exchange(message.channel.id, stored_text, answer)
  107. await self.send_answer(message, answer)
  108. async def handle_mention(self, message: discord.Message) -> None:
  109. """Hors du salon dédié : répond à une mention en lisant les messages récents."""
  110. lines: list[str] = []
  111. if self.settings.context_message_limit > 0:
  112. try:
  113. async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
  114. content = msg.clean_content.strip()
  115. if msg.attachments:
  116. content = (content + " [pièce jointe]").strip()
  117. if msg.stickers:
  118. content = (content + " [sticker]").strip()
  119. if content:
  120. lines.append(f"{msg.author.display_name} : {content}")
  121. except discord.Forbidden:
  122. logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
  123. lines.reverse()
  124. reply, image = await self.read_reply(message, want_image=True)
  125. bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
  126. text = message.clean_content.replace(bot_name, "").strip() or "(simple mention, sans texte)"
  127. channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
  128. parts = []
  129. if lines:
  130. parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
  131. reply_part = f" ({reply})" if reply else ""
  132. parts.append(f"{self.timestamp(message)}, {message.author.display_name} te mentionne{reply_part} et écrit : {text}")
  133. prompt = "\n\n".join(parts)
  134. async with message.channel.typing():
  135. try:
  136. answer = await self.claude.ask([], prompt, image)
  137. except ClaudeError as exc:
  138. await self.safe_reply(message, exc.user_message)
  139. return
  140. except Exception:
  141. logger.exception("Erreur inattendue pendant le traitement d'une mention")
  142. await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
  143. return
  144. await self.send_answer(message, answer)
  145. # ------------------------------------------------------------- réactions
  146. async def reply_to_sticker(self, message: discord.Message) -> bool:
  147. """Répond à un sticker par un sticker aléatoire du serveur."""
  148. if not message.stickers:
  149. return False
  150. if message.guild is None:
  151. return True
  152. stickers = [s for s in message.guild.stickers if s.available]
  153. random.shuffle(stickers)
  154. for sticker in stickers:
  155. try:
  156. await message.channel.send(stickers=[sticker])
  157. logger.info("Sticker envoyé : %s", sticker.name)
  158. return True
  159. except discord.HTTPException as exc:
  160. logger.warning("Sticker %s inutilisable : %s", sticker.name, exc)
  161. await message.channel.send("Aucun sticker utilisable sur ce serveur.")
  162. return True
  163. async def reply_to_emoji(self, message: discord.Message) -> bool:
  164. """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur."""
  165. if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
  166. return False
  167. emojis = [e for e in message.guild.emojis if e.available] if message.guild else []
  168. if not emojis:
  169. await message.channel.send("Aucun emoji personnalisé sur ce serveur.")
  170. return True
  171. try:
  172. await message.channel.send(str(random.choice(emojis)))
  173. except discord.HTTPException as exc:
  174. logger.warning("Impossible d'envoyer l'emoji : %s", exc)
  175. return True
  176. # ---------------------------------------------------------------- images
  177. async def read_image(self, message: discord.Message) -> ImageData | None | bool:
  178. """Vérifie les pièces jointes et télécharge l'image.
  179. Renvoie l'image, None s'il n'y en a pas, ou False si l'envoi est refusé
  180. (l'utilisateur est alors prévenu).
  181. """
  182. if not message.attachments:
  183. return None
  184. others = [a.filename for a in message.attachments if not looks_like_image(a)]
  185. if others:
  186. await self.safe_reply(message, f"Je ne peux lire que des images. Fichier(s) non pris en charge : {', '.join(others)}.")
  187. return False
  188. if len(message.attachments) > 1:
  189. await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
  190. return False
  191. try:
  192. return await self.download_image(message.attachments[0])
  193. except ImageError as exc:
  194. await self.safe_reply(message, exc.user_message)
  195. return False
  196. async def download_image(self, attachment: discord.Attachment) -> ImageData:
  197. """Télécharge et prépare une image (lève ImageError en cas de problème)."""
  198. if attachment.size > self.settings.max_image_size:
  199. max_mb = self.settings.max_image_size / 1_000_000
  200. raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
  201. try:
  202. data = await attachment.read()
  203. except discord.HTTPException as exc:
  204. logger.error("Téléchargement de l'image impossible : %s", exc)
  205. raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
  206. # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
  207. return await asyncio.to_thread(prepare_image, data)
  208. # ------------------------------------------------------- message cité
  209. async def read_reply(self, message: discord.Message, want_image: bool) -> tuple[str | None, ImageData | None]:
  210. """Lit le message auquel l'utilisateur répond (bouton « Répondre » de Discord).
  211. Renvoie sa description pour Claude, ainsi que son image si want_image est vrai,
  212. ou (None, None) si le message ne répond à rien ou si le message cité est inaccessible.
  213. """
  214. ref = message.reference
  215. if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
  216. return None, None
  217. cited = ref.resolved
  218. if cited is None: # Discord ne l'a pas fourni (vieux message) : on le télécharge
  219. try:
  220. cited = await message.channel.fetch_message(ref.message_id)
  221. except discord.HTTPException as exc:
  222. logger.info("Message cité inaccessible : %s", exc)
  223. return None, None
  224. if not isinstance(cited, discord.Message):
  225. return None, None # message cité supprimé
  226. content = cited.clean_content.strip()
  227. if len(content) > QUOTE_MAX_LENGTH:
  228. content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
  229. image = None
  230. images = [a for a in cited.attachments if looks_like_image(a)]
  231. if want_image and images:
  232. try:
  233. image = await self.download_image(images[0])
  234. except ImageError as exc:
  235. logger.warning("Image du message cité ignorée : %s", exc.user_message)
  236. parts = [f"« {content} »"] if content else []
  237. if image is not None:
  238. parts.append("[son image est jointe à ce message]")
  239. elif cited.attachments:
  240. parts.append("[pièce jointe]")
  241. if cited.stickers:
  242. parts.append("[sticker]")
  243. description = " ".join(parts) or "(message vide)"
  244. who = "à ton message" if cited.author == self.user else f"au message de {cited.author.display_name}"
  245. return f"en réponse {who} du {self.timestamp(cited)} : {description}", image
  246. # ------------------------------------------------------------- envoi
  247. def timestamp(self, message: discord.Message) -> str:
  248. return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
  249. async def send_answer(self, message: discord.Message, answer: str) -> None:
  250. chunks = split_message(answer)
  251. for i, chunk in enumerate(chunks):
  252. try:
  253. if i == 0:
  254. await message.reply(chunk, mention_author=False)
  255. else:
  256. await message.channel.send(chunk)
  257. except discord.HTTPException as exc:
  258. # Le message d'origine a pu être supprimé entre-temps : on envoie sans répondre.
  259. if i == 0:
  260. try:
  261. await message.channel.send(chunk)
  262. continue
  263. except discord.HTTPException:
  264. pass
  265. logger.error("Envoi de la réponse impossible : %s", exc)
  266. return
  267. async def safe_reply(self, message: discord.Message, text: str) -> None:
  268. try:
  269. await message.reply(text, mention_author=False)
  270. except discord.HTTPException:
  271. try:
  272. await message.channel.send(text)
  273. except discord.HTTPException as exc:
  274. logger.error("Impossible d'envoyer un message : %s", exc)
  275. # ------------------------------------------------------------- commandes slash
  276. @app_commands.command(name="reset", description="Réinitialise la mémoire du bot dans ce salon")
  277. @app_commands.guild_only()
  278. # Masque la commande aux membres sans « Gérer les messages » (modérateurs) ;
  279. # les administrateurs ont implicitement toutes les permissions.
  280. @app_commands.default_permissions(manage_messages=True)
  281. async def reset_command(interaction: discord.Interaction) -> None:
  282. # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
  283. perms = interaction.permissions
  284. if not (perms.manage_messages or perms.administrator):
  285. logger.info("Tentative de /reset refusée pour %s dans le salon %s", interaction.user, interaction.channel_id)
  286. await interaction.response.send_message(
  287. "Seuls les modérateurs et administrateurs du serveur peuvent utiliser cette commande.",
  288. ephemeral=True,
  289. )
  290. return
  291. bot: ChatBot = interaction.client # type: ignore[assignment]
  292. await bot.history.reset(interaction.channel_id)
  293. logger.info("Historique réinitialisé dans le salon %s par %s", interaction.channel_id, interaction.user)
  294. await interaction.response.send_message("L'historique de conversation a été réinitialisé.")