1
0

discord_bot.py 18 KB

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