discord_bot.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. """Le bot Discord : événements, réponses et réactions (commandes slash dans commands.py)."""
  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 .attachments import (MAX_IMAGES, AttachmentReader, AttachmentRefused, attachments_marker, looks_like_image,
  17. looks_like_pdf, looks_like_text, text_file_marker, text_file_prompt)
  18. from .claude_client import ClaudeClient, ClaudeError
  19. from .commands import memory_command, reset_command, server_memory_command, skip_command
  20. from .config import Settings, memory_warning
  21. from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
  22. from .history import ConversationHistory, render_line
  23. from .images import ImageError
  24. from .media import ImageData
  25. from .members import MemberRegistry, with_username
  26. from .memory import DATE_FORMAT, MemoryStore
  27. from .memory_views import reply_error, see_user
  28. from .models import display_name, is_known, thinking_off
  29. from .pdf_files import PdfError
  30. from .presence import PresenceManager, Status
  31. from .text_files import TextFileError
  32. from .text_utils import split_message
  33. logger = logging.getLogger("chatbot.discord")
  34. CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
  35. USER_MENTION = re.compile(r"<@!?(\d+)>")
  36. # Couleur du message de démarrage : le vert du statut « en ligne » de Discord.
  37. ONLINE_COLOR = discord.Color(0x23A55A)
  38. # Longueur maximale du texte d'un message cité ou transféré transmis à Claude.
  39. QUOTE_MAX_LENGTH = 500
  40. # Messages écrits par quelqu'un ; les autres types sont des messages système
  41. # (arrivée d'un membre, message épinglé, boost, fil créé…).
  42. USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
  43. # En passant absent ou en « Ne pas déranger », le bot prévient son salon si on y a parlé depuis moins de :
  44. DEPARTURE_WINDOW = timedelta(minutes=3)
  45. # Messages de bots auxquels le bot répond d'affilée dans un salon, sans message d'un membre entre deux.
  46. MAX_BOT_REPLIES = 5
  47. # Passé ce délai sans message d'un bot dans le salon, le compte repart de zéro.
  48. BOT_STREAK_RESET = timedelta(minutes=10)
  49. def author_name(user: discord.User | discord.Member) -> str:
  50. """Nom d'un auteur pour Claude, avec son nom d'utilisateur (voir with_username)."""
  51. return with_username(user.display_name, user.name)
  52. def channel_problem(client: discord.Client, channel_id: int) -> str | None:
  53. """Pourquoi le salon dédié (CHANNEL_ID) est inutilisable, ou None s'il convient."""
  54. channel = client.get_channel(channel_id)
  55. if channel is None:
  56. if client.get_guild(channel_id):
  57. return (f"CHANNEL_ID ({channel_id}) est l'identifiant du serveur, pas du salon : fais un clic droit "
  58. "sur le salon > Copier l'identifiant du salon.")
  59. return (f"Salon {channel_id} introuvable : vérifie CHANNEL_ID (clic droit sur le salon > Copier l'identifiant "
  60. "du salon, avec le mode développeur de Discord) et que le bot est bien invité sur ce serveur.")
  61. guild = getattr(channel, "guild", None)
  62. if guild is None or not isinstance(channel, discord.abc.Messageable):
  63. kind = ("une catégorie" if isinstance(channel, discord.CategoryChannel)
  64. else "un forum" if isinstance(channel, discord.ForumChannel)
  65. else "une conversation privée" if guild is None else "un salon où l'on ne peut pas écrire")
  66. return f"CHANNEL_ID ({channel_id}) désigne {kind} : donne l'identifiant d'un salon textuel."
  67. permissions = channel.permissions_for(guild.me)
  68. in_thread = isinstance(channel, discord.Thread)
  69. needed = (("view_channel", "Voir les salons"),
  70. ("send_messages_in_threads" if in_thread else "send_messages",
  71. "Envoyer des messages dans les fils" if in_thread else "Envoyer des messages"),
  72. ("read_message_history", "Voir les anciens messages"))
  73. if missing := [label for name, label in needed if not getattr(permissions, name)]:
  74. names = ", ".join(f"« {label} »" for label in missing)
  75. plural = "les permissions" if len(missing) > 1 else "la permission"
  76. return (f"Il manque au bot {plural} {names} dans #{channel} : ajoute-{'les' if len(missing) > 1 else 'la'} "
  77. "à son rôle ou dans les permissions du salon.")
  78. return None
  79. @contextlib.asynccontextmanager
  80. async def typing(channel: discord.abc.Messageable):
  81. """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
  82. indicator = channel.typing()
  83. try:
  84. await indicator.__aenter__() # seul ce premier envoi peut échouer : les suivants sont protégés
  85. except discord.HTTPException as exc:
  86. logger.warning("Indicateur « en train d'écrire » impossible dans #%s : %s", channel, exc)
  87. indicator = None
  88. try:
  89. yield
  90. finally:
  91. if indicator is not None:
  92. await indicator.__aexit__(None, None, None)
  93. @dataclass
  94. class PendingMessage:
  95. """Message du salon dédié, prêt à être envoyé à Claude."""
  96. message: discord.Message
  97. line: dict # ligne conservée en mémoire (voir history.render_line)
  98. extra: str # ajouté pour Claude seulement : contenu du fichier texte joint
  99. images: list[ImageData] # images, ou un seul PDF
  100. @property
  101. def user_text(self) -> str:
  102. """Texte envoyé à Claude : les images ou le fichier eux-mêmes remplacent leur repère."""
  103. line = {key: value for key, value in self.line.items() if key != "attachments"}
  104. return render_line(line) + self.extra
  105. class BotStreaks:
  106. """Messages de bots reçus d'affilée, par salon. Deux bots qui se répondent tourneraient sans fin,
  107. avec un appel à Claude à chaque tour : au-delà de MAX_BOT_REPLIES, le bot ne leur répond plus."""
  108. def __init__(self) -> None:
  109. self._streaks: dict[int, tuple[int, datetime]] = {} # salon : (messages, date du dernier)
  110. def human_spoke(self, channel_id: int) -> None:
  111. self._streaks.pop(channel_id, None)
  112. def count(self, channel_id: int, when: datetime) -> int:
  113. """Compte un message de bot adressé au bot et renvoie son rang dans la série (1 pour le premier).
  114. Un message ignoré compte aussi : un bot qui relance sans cesse reste ignoré."""
  115. count, last = self._streaks.get(channel_id, (0, when))
  116. if when - last > BOT_STREAK_RESET:
  117. count = 0
  118. self._streaks[channel_id] = (count + 1, when)
  119. return count + 1
  120. class HomeTree(app_commands.CommandTree):
  121. """Commandes slash acceptées seulement sur le serveur du bot (voir ChatBot.home_guild_id) : un
  122. administrateur d'un autre serveur où le bot a été invité ne peut ni lire ni modifier la mémoire."""
  123. async def interaction_check(self, interaction: discord.Interaction) -> bool:
  124. bot: ChatBot = self.client # type: ignore[assignment]
  125. if interaction.guild_id is not None and interaction.guild_id == bot.home_guild_id():
  126. return True
  127. logger.warning("/%s refusée pour %s : serveur %s, hors du serveur du bot",
  128. (interaction.data or {}).get("name", "?"), interaction.user, interaction.guild_id)
  129. if interaction.type is discord.InteractionType.application_command:
  130. await interaction.response.send_message("Ce bot ne fonctionne que sur son propre serveur.",
  131. ephemeral=True)
  132. return False
  133. async def on_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
  134. logger.error("Erreur pendant /%s", (interaction.data or {}).get("name", "?"), exc_info=error)
  135. await reply_error(interaction)
  136. class ChatBot(commands.Bot):
  137. def __init__(self, settings: Settings):
  138. intents = discord.Intents.default()
  139. intents.message_content = True # nécessaire pour lire le texte des messages
  140. super().__init__(
  141. command_prefix=commands.when_mentioned, # aucune commande textuelle utilisée
  142. intents=intents,
  143. help_command=None,
  144. tree_cls=HomeTree,
  145. # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
  146. allowed_mentions=discord.AllowedMentions.none(),
  147. )
  148. self.settings = settings
  149. self.members = MemberRegistry(settings.memory_dir / "registre.json") if settings.memory else None
  150. if self.members and self.members.broken:
  151. self.members = None # registre illisible : mémoire coupée comme avec MEMORY=off (voir MemberRegistry)
  152. self.memory = MemoryStore(settings.memory_dir, self.members, settings.memory_member_limit,
  153. settings.memory_server_limit, settings.timezone) if self.members else None
  154. self.claude = ClaudeClient(settings, self.memory)
  155. self.attachments = AttachmentReader(settings)
  156. self.history = ConversationHistory(settings.history_file, settings.max_history_length)
  157. self._pending: dict[int, list[PendingMessage]] = {} # messages en attente de réponse, par salon
  158. self._bot_streaks = BotStreaks()
  159. self._announced = False
  160. self.config_error: str | None = None # salon dédié inutilisable : main.py sort en erreur
  161. self._memory_warning = memory_warning(settings) if self.memory else None
  162. self.presence =PresenceManager(self, self.claude, settings) if settings.presence else None
  163. # Absent ou en « Ne pas déranger » : réponses différées, par salon.
  164. self._waiters: dict[int, asyncio.Task] = {} # attente en cours
  165. self._mentions: dict[int, list[discord.Message]] = {} # mentions en attente (hors salon dédié)
  166. # Messages en attente, gardés sur le disque pour y répondre même après un redémarrage.
  167. self._waiting_file = settings.history_file.with_name("waiting_messages.json")
  168. self._waiting: set[tuple[int, int]] = set() # (salon, message)
  169. self._last_main_message: datetime | None = None # date du dernier message reçu dans le salon dédié
  170. # Empreinte des commandes slash envoyées à Discord (voir sync_commands).
  171. self._commands_file = settings.history_file.with_name("slash_commands.sha256")
  172. # ------------------------------------------------------------ cycle de vie
  173. async def setup_hook(self) -> None:
  174. # Exécuté une seule fois au démarrage (contrairement à on_ready).
  175. if problem := await self.claude.check_access():
  176. # Sinon le bot s'annoncerait « En ligne » puis échouerait sur chaque message.
  177. logger.critical("Erreur de configuration : %s", problem)
  178. self.config_error = problem
  179. await self.close() # connect() voit le bot fermé et rend la main à main.py
  180. return
  181. self.tree.add_command(reset_command)
  182. if self.presence:
  183. self.tree.add_command(skip_command)
  184. # Toujours présentes : avec MEMORY=off, elles indiquent que la mémoire est coupée.
  185. self.tree.add_command(memory_command)
  186. self.tree.add_command(server_memory_command)
  187. await self.sync_commands()
  188. if self.memory:
  189. # Lecture de toutes les fiches, avant la connexion : elle ne bloque aucun message.
  190. await asyncio.to_thread(self.memory.index)
  191. s = self.settings
  192. memory = "off" if not s.memory else f"on ({s.memory_dir})" if self.memory else "coupée (registre illisible)"
  193. thinking = "on" if s.thinking else "off"
  194. if not s.thinking and thinking_off(s.model) is None:
  195. # Ce modèle réfléchit toujours : seul EFFORT en règle la quantité.
  196. thinking = "impossible à couper sur ce modèle (règle EFFORT)"
  197. logger.info("Modèle %s — réflexion : %s, effort : %s, recherche web : %s, mémoire des membres : %s",
  198. s.model, thinking, s.effort or "par défaut",
  199. "on" if s.web_search else "off", memory)
  200. logger.info("MAX_TOKENS : %s, API_TIMEOUT : %g s, journal : %s (niveau %s)", s.max_tokens, s.api_timeout,
  201. s.log_file.resolve() if s.log_file else "console seulement", s.log_level)
  202. logger.info("Historique : %s, personnalité : %s", s.history_file.resolve(), s.personality_file.resolve())
  203. if not is_known(s.model):
  204. # Un nom mal écrit ne serait sinon découvert qu'au premier message (erreur 404).
  205. prices = ""
  206. if s.price_input_per_mtok is None or s.price_output_per_mtok is None:
  207. prices = (" S'il existe bien, renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK "
  208. "pour estimer les coûts.")
  209. logger.warning("Modèle %s inconnu du bot : vérifie CLAUDE_MODEL dans le .env.%s", s.model, prices)
  210. # Une réponse qui remplit MAX_TOKENS s'écrit à environ 80 tokens par seconde au mieux ;
  211. # les recherches web et les pages lues ajoutent leur propre attente.
  212. needed = s.max_tokens // 80 + (40 if s.web_search else 0)
  213. if s.api_timeout < needed:
  214. logger.warning("API_TIMEOUT (%g s) est court pour MAX_TOKENS=%s%s : une longue réponse serait "
  215. "abandonnée (« Je mets trop de temps à répondre »). Mets au moins %s dans le .env.",
  216. s.api_timeout, s.max_tokens, " avec la recherche web" if s.web_search else "", needed)
  217. if self._memory_warning:
  218. logger.warning("%s", self._memory_warning) # rappelé dans le message « En ligne »
  219. if self.presence:
  220. self.presence.start()
  221. async def sync_commands(self) -> None:
  222. """Envoie les commandes slash à Discord, seulement si elles ont changé depuis le dernier envoi
  223. (Discord limite ces envois : inutile de les refaire à chaque démarrage)."""
  224. payload = [command.to_dict(self.tree) for command in self.tree.get_commands()]
  225. signature = json.dumps([self.application_id, payload], sort_keys=True, ensure_ascii=False)
  226. digest = hashlib.sha256(signature.encode("utf-8")).hexdigest()
  227. try:
  228. if self._commands_file.read_text(encoding="utf-8").strip() == digest:
  229. logger.info("Commandes slash inchangées : pas de synchronisation.")
  230. return
  231. except OSError:
  232. pass # premier démarrage, ou fichier illisible : on synchronise
  233. try:
  234. synced = await self.tree.sync()
  235. except discord.HTTPException as exc: # le bot fonctionne quand même ; nouvel essai au prochain démarrage
  236. logger.error("Synchronisation des commandes slash impossible : %s", exc)
  237. return
  238. logger.info("%d commande(s) slash synchronisée(s).", len(synced))
  239. try:
  240. self._commands_file.write_text(digest, encoding="utf-8")
  241. except OSError as exc:
  242. logger.warning("Impossible d'écrire %s : %s", self._commands_file, exc)
  243. async def on_ready(self) -> None:
  244. logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
  245. if self.presence:
  246. await self.presence.apply() # une reconnexion complète efface le statut
  247. if not self._announced: # simple reconnexion : on ne reposte pas le message de démarrage
  248. await self.announce()
  249. async def on_guild_available(self, guild: discord.Guild) -> None:
  250. # Serveur du salon indisponible au démarrage (panne Discord), revenu depuis : le bot s'annonce enfin.
  251. if self.is_ready() and not self._announced and self.home_guild_id() == guild.id:
  252. await self.announce()
  253. async def announce(self) -> None:
  254. """Vérifie le salon dédié, puis y poste le message de démarrage. Un salon inutilisable arrête le bot."""
  255. if problem := channel_problem(self, self.settings.channel_id):
  256. if self.get_channel(self.settings.channel_id) is None and any(g.unavailable for g in self.guilds):
  257. # Le salon est peut-être sur ce serveur : Discord n'en envoie le contenu qu'à son retour.
  258. logger.warning("Salon %s introuvable pour l'instant : un serveur du bot est indisponible chez "
  259. "Discord. Le bot s'annoncera dès son retour.", self.settings.channel_id)
  260. return
  261. logger.critical("Erreur de configuration : %s", problem)
  262. self.config_error = problem
  263. await self.close()
  264. return
  265. self._announced = True
  266. self._replay_task = asyncio.create_task(self.replay_waiting()) # référence gardée : pas de ramasse-miettes
  267. channel = self.get_channel(self.settings.channel_id)
  268. if others := [guild for guild in self.guilds if guild.id != self.home_guild_id()]:
  269. logger.warning("Le bot est aussi sur %d autre(s) serveur(s), où il ne répond pas : %s. Retire-le de ces "
  270. "serveurs, et décoche Public Bot dans le portail Discord (voir le README).", len(others),
  271. ", ".join(f"{guild.name} ({guild.id})" for guild in others))
  272. me = channel.guild.me if getattr(channel, "guild", None) else self.user
  273. logger.info("%d emoji(s) du serveur utilisable(s) dans les réponses.",
  274. len(usable_emojis(getattr(channel, "guild", None))))
  275. started = discord.utils.utcnow()
  276. embed = discord.Embed(
  277. title="En ligne",
  278. description=f"**{me.display_name}** vient de démarrer et répond de nouveau aux messages.",
  279. color=ONLINE_COLOR,
  280. timestamp=started,
  281. )
  282. embed.set_thumbnail(url=me.display_avatar.url)
  283. embed.add_field(name="Version", value=f"`{VERSION}`")
  284. embed.add_field(name="Modèle", value=display_name(self.settings.model))
  285. embed.add_field(name="Démarrage", value=discord.utils.format_dt(started, "R"))
  286. if self._memory_warning:
  287. # Sur OpenShift, personne ne lit le journal de démarrage.
  288. embed.add_field(name="⚠️ Réglage à revoir", value=self._memory_warning, inline=False)
  289. try:
  290. await channel.send(embed=embed)
  291. except discord.HTTPException as exc:
  292. logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
  293. async def close(self) -> None:
  294. if self.presence:
  295. self.presence.stop()
  296. for task in self._waiters.values():
  297. task.cancel() # les messages en attente restent dans waiting_messages.json
  298. await self.claude.close()
  299. await super().close()
  300. def home_guild_id(self) -> int | None:
  301. """Le serveur du salon dédié (CHANNEL_ID), le seul où le bot répond : la mémoire n'existe qu'en
  302. un exemplaire. None tant que le salon est introuvable (avant la connexion, CHANNEL_ID faux)."""
  303. guild = getattr(self.get_channel(self.settings.channel_id), "guild", None)
  304. return guild.id if guild else None
  305. async def on_guild_join(self, guild: discord.Guild) -> None:
  306. logger.warning("Bot ajouté au serveur %s (%s), où il ne répond pas : retire-le de ce serveur, et "
  307. "décoche Public Bot dans le portail Discord (voir le README).", guild.name, guild.id)
  308. # ------------------------------------------------------------- messages
  309. async def on_message(self, message: discord.Message) -> None:
  310. # Ignore ses propres messages et les webhooks : évite les boucles infinies.
  311. if message.author == self.user or message.webhook_id is not None:
  312. return
  313. # Ni autre serveur ni message privé : réponses payées par le propriétaire, mémoire du serveur.
  314. if message.guild is None or message.guild.id != self.home_guild_id():
  315. return
  316. if message.type not in USER_MESSAGE_TYPES:
  317. return # message système
  318. # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
  319. # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble, mais pas sans fin.
  320. if not message.author.bot:
  321. self._bot_streaks.human_spoke(message.channel.id)
  322. elif self.user not in message.mentions and not await self.replies_to_me(message):
  323. return
  324. elif (rank := self._bot_streaks.count(message.channel.id, message.created_at)) > MAX_BOT_REPLIES:
  325. if rank == MAX_BOT_REPLIES + 1:
  326. logger.warning("#%s : %s réponses à des bots d'affilée (dernier : %s). Le bot ne leur répond plus "
  327. "jusqu'au prochain message d'un membre, ou après %s minutes sans message de bot.",
  328. message.channel, MAX_BOT_REPLIES, message.author,
  329. BOT_STREAK_RESET.seconds // 60)
  330. return
  331. in_main_channel = message.channel.id == self.settings.channel_id
  332. # Mention directe uniquement (@everyone et @here ne comptent pas).
  333. mentioned = self.user is not None and self.user in message.mentions
  334. if (in_main_channel or mentioned) and self.members:
  335. await self.register_members(message)
  336. if in_main_channel:
  337. await self.handle_main_channel(message)
  338. elif mentioned:
  339. await self.handle_mention(message)
  340. async def concerned_members(self, message: discord.Message) -> list[discord.User | discord.Member]:
  341. """Membres concernés par un message, sans les bots : auteur, auteur du message cité, membres
  342. mentionnés (ordre des fiches jointes quand elles sont trop nombreuses)."""
  343. users = [message.author]
  344. if (cited := await self.fetch_cited(message)) is not None:
  345. users.append(cited.author)
  346. users += message.mentions
  347. unique = {user.id: user for user in users if not user.bot}
  348. return list(unique.values())
  349. async def member_notes(self, messages: list[discord.Message]) -> str:
  350. """Fiches des membres concernés par ces messages, à joindre pour Claude (vide sans mémoire)."""
  351. if not self.memory:
  352. return ""
  353. ids = [user.id for message in messages for user in await self.concerned_members(message)]
  354. return self.memory.notes_for(ids, author_ids=[message.author.id for message in messages])
  355. async def register_members(self, message: discord.Message) -> None:
  356. """Ajoute au registre les membres concernés par le message, ou met leurs noms à jour."""
  357. for user in await self.concerned_members(message):
  358. await see_user(self.members, user)
  359. async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
  360. """Message modifié : la mémoire, ou le message s'il attend encore sa réponse, prend le nouveau texte."""
  361. message = payload.message
  362. # Aperçu d'un lien qui s'affiche, ou transfert (qui ne se modifie pas) : rien n'a changé.
  363. if message.edited_at is None or message.message_snapshots:
  364. return
  365. content = self.message_body(message)
  366. for pending in self._pending.get(payload.channel_id, []):
  367. if pending.message.id == message.id:
  368. pending.line["content"] = content
  369. if await self.history.edit(payload.channel_id, message.id, content):
  370. logger.info("Message modifié dans le salon %s : mémoire mise à jour.", payload.channel_id)
  371. async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None:
  372. await self.forget_deleted(payload.channel_id, {payload.message_id})
  373. async def on_raw_bulk_message_delete(self, payload: discord.RawBulkMessageDeleteEvent) -> None:
  374. await self.forget_deleted(payload.channel_id, payload.message_ids)
  375. async def forget_deleted(self, channel_id: int, message_ids: set[int]) -> None:
  376. """Messages supprimés : ils sortent de la mémoire et n'attendent plus de réponse."""
  377. # Listes modifiées sur place : une réponse sur le point de partir les lit encore.
  378. if pending := self._pending.get(channel_id):
  379. pending[:] = [p for p in pending if p.message.id not in message_ids]
  380. if mentions := self._mentions.get(channel_id):
  381. mentions[:] = [m for m in mentions if m.id not in message_ids]
  382. keys = {(channel_id, message_id) for message_id in message_ids}
  383. if keys & self._waiting:
  384. self._waiting -= keys
  385. self._save_waiting()
  386. if await self.history.delete(channel_id, message_ids):
  387. logger.info("%s message(s) supprimé(s) dans le salon %s : mémoire mise à jour.", len(message_ids), channel_id)
  388. async def handle_main_channel(self, message: discord.Message) -> None:
  389. if self._last_main_message is None or message.created_at > self._last_main_message:
  390. self._last_main_message = message.created_at
  391. content = message.content.strip()
  392. # Un sticker ou un emoji seul reçoit un sticker ou un emoji du serveur. S'il n'y en a aucun
  393. # d'utilisable, Claude répond avec des mots ; accompagné de texte, le sticker lui est décrit.
  394. if (message.stickers and not content and not message.attachments) or CUSTOM_EMOJI_ONLY.match(content):
  395. if not self.available():
  396. return # absent : pas de réaction aux stickers ni aux emojis
  397. if await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
  398. return
  399. forward, forward_images = await self.read_forward(message)
  400. if not (content or message.attachments or message.stickers or forward):
  401. return # sondage, transfert d'un message vide… : rien à lire
  402. attachments = await self.read_attachments(message)
  403. if attachments is False:
  404. return # pièce jointe refusée, l'utilisateur a été prévenu
  405. images, text_file = attachments
  406. # Les images du message cité ne sont utilisées que si le message n'en contient pas lui-même.
  407. reply, reply_images = await self.read_reply(message, want_images=not (images or forward_images))
  408. images = images or forward_images or reply_images
  409. line: dict = {"id": message.id, "date": self.timestamp(message), "author": author_name(message.author)}
  410. if forward:
  411. line["forward"] = forward
  412. else:
  413. if reply:
  414. line["reply_to"] = message.reference.message_id # citation retirée si ce message est supprimé
  415. line["reply"] = reply
  416. line["content"] = self.message_body(message)
  417. markers = [attachments_marker(images)] if images else []
  418. extra = ""
  419. if text_file:
  420. extra = text_file_prompt(*text_file)
  421. markers.append(text_file_marker(text_file[0]))
  422. if markers:
  423. line["attachments"] = markers
  424. # Un seul appel à la fois par salon : l'historique reste cohérent. Les messages arrivés
  425. # pendant une réponse attendent dans la file, puis partent ensemble dans un seul appel.
  426. self._pending.setdefault(message.channel.id, []).append(PendingMessage(message, line, extra, images))
  427. if not self.available() or message.channel.id in self._waiters:
  428. self.answer_later(message, self.answer_main_channel)
  429. return
  430. await self.answer_main_channel(message.channel)
  431. async def answer_main_channel(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  432. """Répond en une fois aux messages en attente du salon dédié."""
  433. pending = self._pending.setdefault(channel.id, [])
  434. async with self.history.lock(channel.id):
  435. if not pending:
  436. return # déjà parti avec un message précédent
  437. batch = sorted(pending, key=lambda p: p.message.id)
  438. pending.clear()
  439. last = batch[-1].message
  440. if len(batch) > 1:
  441. logger.info("%s messages regroupés en une seule réponse dans #%s", len(batch), channel)
  442. # Le statut en cours est aussi gardé en mémoire, comme le reste du message.
  443. parts = [(p.user_text, p.images) for p in batch]
  444. lines = [p.line for p in batch]
  445. notes = [self.presence.late_note(late, several=len(batch) > 1)] if late else []
  446. if note := self.presence_note():
  447. notes.append(note)
  448. for note in reversed(notes):
  449. parts.insert(0, (note, []))
  450. lines.insert(0, {"content": note})
  451. try:
  452. async with typing(channel):
  453. try:
  454. notes = await self.member_notes([p.message for p in batch])
  455. answer = await self.claude.ask(self.history.get(channel.id), parts,
  456. emojis=emoji_names(last.guild), member_notes=notes)
  457. except ClaudeError as exc:
  458. await self.safe_reply(last, exc.user_message)
  459. return
  460. except Exception:
  461. logger.exception("Erreur inattendue pendant le traitement d'un message")
  462. await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
  463. return
  464. finally:
  465. self.forget_waiting(*(p.message for p in batch))
  466. await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
  467. await self.send_answer(last, answer.text)
  468. async def handle_mention(self, message: discord.Message) -> None:
  469. # Comme dans le salon dédié : les mentions arrivées pendant une réponse attendent dans la file,
  470. # puis partent ensemble dans un seul appel.
  471. self._mentions.setdefault(message.channel.id, []).append(message)
  472. if not self.available() or message.channel.id in self._waiters:
  473. self.answer_later(message, self.answer_mentions)
  474. return
  475. await self.answer_mentions(message.channel)
  476. async def answer_mentions(self, channel: discord.abc.Messageable, late: Status | None = None) -> None:
  477. """Répond en une fois aux mentions en attente d'un salon (hors salon dédié)."""
  478. # Un seul appel à la fois par salon, envoi compris : la réponse suivante voit la précédente dans le contexte.
  479. async with self.history.lock(channel.id):
  480. batch = sorted(self._mentions.pop(channel.id, []), key=lambda m: m.id)
  481. if not batch:
  482. return # déjà parties avec une mention précédente
  483. if len(batch) > 1:
  484. logger.info("%s mentions regroupées en une seule réponse dans #%s", len(batch), channel)
  485. try:
  486. await self.answer_mention(batch, late)
  487. finally:
  488. self.forget_waiting(*batch)
  489. async def answer_mention(self, mentions: list[discord.Message], late: Status | None = None) -> None:
  490. """Hors du salon dédié : répond aux mentions d'un même salon en lisant les messages récents."""
  491. channel = mentions[-1].channel
  492. mention_ids = {m.id for m in mentions}
  493. lines: list[str] = []
  494. # Jusqu'à maintenant : une réponse groupée voit celle que le bot vient d'envoyer (postée après ces
  495. # mentions). En retard, jusqu'à la dernière mention : la discussion qui a suivi recouvrirait celle-ci.
  496. before = mentions[-1] if late else None
  497. if self.settings.context_message_limit > 0:
  498. try:
  499. async for msg in channel.history(limit=self.settings.context_message_limit, before=before):
  500. if msg.id in mention_ids:
  501. continue # décrite plus bas, avec ses pièces jointes
  502. content = short_emojis(msg.clean_content.strip())
  503. if msg.attachments:
  504. content = (content + " [pièce jointe]").strip()
  505. if msg.stickers:
  506. content = (content + " [sticker]").strip()
  507. if content:
  508. lines.append(f"{author_name(msg.author)} : {content}")
  509. except discord.Forbidden:
  510. logger.warning("Pas la permission de lire l'historique de #%s.", channel)
  511. lines.reverse()
  512. answered, parts = [], []
  513. for message in mentions:
  514. if part := await self.mention_part(message):
  515. answered.append(message)
  516. parts.append(part)
  517. if not parts:
  518. return # pièces jointes refusées, les auteurs ont été prévenus
  519. last = answered[-1]
  520. channel_name = f"#{channel.name}" if hasattr(channel, "name") else "un message privé"
  521. header = []
  522. if late:
  523. header.append(self.presence.late_note(late, several=len(parts) > 1))
  524. if note := self.presence_note():
  525. header.append(note)
  526. if lines:
  527. header.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
  528. text, images = parts[0]
  529. parts[0] = ("\n\n".join([*header, text]), images)
  530. async with typing(channel):
  531. try:
  532. answer = await self.claude.ask([], parts, emojis=emoji_names(last.guild), cache_history=False,
  533. member_notes=await self.member_notes(answered))
  534. except ClaudeError as exc:
  535. await self.safe_reply(last, exc.user_message)
  536. return
  537. except Exception:
  538. logger.exception("Erreur inattendue pendant le traitement d'une mention")
  539. await self.safe_reply(last, "Désolé, une erreur inattendue est survenue.")
  540. return
  541. # En retard, la réponse cite la dernière mention : on sait à quoi elle répond.
  542. await self.send_answer(last, answer.text, reply=late is not None)
  543. async def mention_part(self, message: discord.Message) -> tuple[str, list[ImageData]] | None:
  544. """Une mention telle qu'elle est transmise à Claude, ou None si une pièce jointe est refusée
  545. (l'auteur a été prévenu)."""
  546. # Seuls les MAX_IMAGES premières images (à défaut, le premier PDF) et le premier fichier texte
  547. # de la mention sont lus (les autres pièces jointes sont ignorées). Les images du message cité
  548. # ne servent que s'il n'y a ni image ni PDF.
  549. image_files = [a for a in message.attachments if looks_like_image(a)][:MAX_IMAGES]
  550. pdfs = [a for a in message.attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
  551. try:
  552. if image_files:
  553. images = await self.attachments.download_images(image_files)
  554. else:
  555. images = [await self.attachments.download_pdf(pdfs[0])] if pdfs else []
  556. except (ImageError, PdfError) as exc:
  557. await self.safe_reply(message, exc.user_message)
  558. return None
  559. reply, reply_images = await self.read_reply(message, want_images=not images)
  560. images = images or reply_images
  561. bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
  562. text = short_emojis(message.clean_content.replace(bot_name, "").strip()) or "(simple mention, sans texte)"
  563. reply_part = f" ({reply})" if reply else ""
  564. prompt = f"{self.timestamp(message)}, {author_name(message.author)} te mentionne{reply_part} et écrit : {text}"
  565. text_files = [a for a in message.attachments if looks_like_text(a)]
  566. if text_files:
  567. try:
  568. prompt += text_file_prompt(*await self.attachments.download_text_file(text_files[0]))
  569. except TextFileError as exc:
  570. await self.safe_reply(message, exc.user_message)
  571. return None
  572. return prompt, images
  573. # ------------------------------------------------------ réponses différées
  574. def available(self) -> bool:
  575. return self.presence is None or self.presence.available
  576. def answer_later(self, message: discord.Message, answer) -> None:
  577. """Met le message de côté : answer(salon, late=statut) y répondra quand le bot le pourra."""
  578. self.remember_waiting(message)
  579. channel = message.channel
  580. if channel.id in self._waiters:
  581. return # une attente est déjà en cours dans ce salon : le message en profitera
  582. status = self.presence.status
  583. logger.info("Réponse différée dans #%s : %s.", channel, status.unavailability())
  584. self._waiters[channel.id] = asyncio.create_task(self._answer_when_possible(channel, answer, status))
  585. async def _answer_when_possible(self, channel: discord.abc.Messageable, answer, status: Status) -> None:
  586. try:
  587. await self.presence.wait_turn()
  588. finally:
  589. self._waiters.pop(channel.id, None)
  590. try:
  591. await answer(channel, late=status)
  592. except Exception:
  593. logger.exception("Erreur inattendue pendant une réponse différée")
  594. async def on_status_change(self, before: Status, after: Status) -> None:
  595. """Passe absent ou en « Ne pas déranger » : prévient le salon dédié si une conversation y est en cours."""
  596. if not before.available or after.available or self._last_main_message is None:
  597. return
  598. if discord.utils.utcnow() - self._last_main_message > DEPARTURE_WINDOW:
  599. return
  600. channel = self.get_channel(self.settings.channel_id)
  601. if channel is None:
  602. return
  603. async with self.history.lock(channel.id):
  604. # Attend la fin d'une réponse en cours ; le statut a pu changer encore entre-temps.
  605. if self.presence.status is not after:
  606. return
  607. parts = [(note, []) for note in (self.presence_note(), self.presence.departure_note()) if note]
  608. try:
  609. async with typing(channel):
  610. answer = await self.claude.ask(self.history.get(channel.id), parts,
  611. emojis=emoji_names(channel.guild))
  612. except ClaudeError as exc:
  613. logger.warning("Message d'absence non envoyé : %s", exc.user_message)
  614. return
  615. lines = [{"content": text} for text, _ in parts]
  616. await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
  617. logger.info("Conversation en cours dans #%s : le salon est prévenu de l'absence.", channel)
  618. for chunk in split_message(discord_emojis(short_emojis(answer.text), channel.guild)):
  619. try:
  620. await channel.send(chunk)
  621. except discord.HTTPException as exc:
  622. logger.error("Envoi du message d'absence impossible : %s", exc)
  623. return
  624. def remember_waiting(self, message: discord.Message) -> None:
  625. self._waiting.add((message.channel.id, message.id))
  626. self._save_waiting()
  627. def forget_waiting(self, *messages: discord.Message) -> None:
  628. keys = {(m.channel.id, m.id) for m in messages}
  629. if keys & self._waiting:
  630. self._waiting -= keys
  631. self._save_waiting()
  632. def _save_waiting(self) -> None:
  633. try:
  634. self._waiting_file.write_text(json.dumps(sorted(self._waiting)), encoding="utf-8")
  635. except OSError as exc:
  636. logger.warning("Impossible d'écrire %s : %s", self._waiting_file, exc)
  637. async def replay_waiting(self) -> None:
  638. """Au démarrage : reprend les messages restés sans réponse avant le redémarrage."""
  639. try:
  640. saved = json.loads(self._waiting_file.read_text(encoding="utf-8"))
  641. except FileNotFoundError:
  642. return
  643. except (OSError, ValueError) as exc:
  644. logger.warning("%s illisible, ignoré : %s", self._waiting_file, exc)
  645. return
  646. messages = []
  647. for channel_id, message_id in saved:
  648. try:
  649. channel = self.get_channel(channel_id) or await self.fetch_channel(channel_id)
  650. messages.append(await channel.fetch_message(message_id))
  651. except discord.HTTPException:
  652. pass # message supprimé, salon inaccessible…
  653. self._waiting.clear()
  654. self._save_waiting()
  655. if messages:
  656. logger.info("%d message(s) resté(s) sans réponse avant le redémarrage : reprise.", len(messages))
  657. # En parallèle, comme à leur arrivée : ceux du salon dédié sont regroupés.
  658. await asyncio.gather(*(self.on_message(m) for m in sorted(messages, key=lambda m: m.id)),
  659. return_exceptions=True)
  660. # ------------------------------------------------------------- réactions
  661. async def reply_to_sticker(self, message: discord.Message) -> bool:
  662. """Répond à un sticker par un sticker aléatoire du serveur (False si aucun n'est utilisable)."""
  663. if not message.stickers or message.guild is None:
  664. return False
  665. stickers = [s for s in message.guild.stickers if s.available]
  666. random.shuffle(stickers)
  667. for sticker in stickers:
  668. try:
  669. await message.channel.send(stickers=[sticker])
  670. logger.info("Sticker envoyé : %s", sticker.name)
  671. return True
  672. except discord.Forbidden as exc: # pas le droit d'envoyer de sticker : inutile d'insister
  673. logger.warning("Envoi de sticker interdit : %s", exc)
  674. break
  675. except discord.HTTPException as exc:
  676. logger.warning("Sticker %s inutilisable : %s", sticker.name, exc)
  677. return False
  678. async def reply_to_emoji(self, message: discord.Message) -> bool:
  679. """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur
  680. (False si le serveur n'en a aucun)."""
  681. if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
  682. return False
  683. emojis = usable_emojis(message.guild)
  684. if not emojis:
  685. return False
  686. try:
  687. await message.channel.send(str(random.choice(emojis)))
  688. except discord.HTTPException as exc:
  689. logger.warning("Impossible d'envoyer l'emoji : %s", exc)
  690. return True
  691. # ------------------------------------------------------- pièces jointes
  692. async def read_attachments(
  693. self, message: discord.Message
  694. ) -> tuple[list[ImageData], tuple[str, str] | None] | bool:
  695. """Pièces jointes du message (voir AttachmentReader.read), ou False si l'envoi est refusé :
  696. l'utilisateur est alors prévenu."""
  697. try:
  698. return await self.attachments.read(message.attachments)
  699. except (AttachmentRefused, ImageError, PdfError, TextFileError) as exc:
  700. await self.safe_reply(message, exc.user_message)
  701. return False
  702. # ------------------------------------------- message cité ou transféré
  703. async def fetch_cited(self, message: discord.Message) -> discord.Message | None:
  704. """Renvoie le message auquel ce message répond (bouton « Répondre » de Discord),
  705. ou None s'il ne répond à rien ou si le message cité est inaccessible."""
  706. ref = message.reference
  707. if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
  708. return None
  709. cited = ref.resolved
  710. if cited is None: # Discord ne l'a pas fourni (vieux message) : on le télécharge
  711. try:
  712. cited = await message.channel.fetch_message(ref.message_id)
  713. except discord.HTTPException as exc:
  714. logger.info("Message cité inaccessible : %s", exc)
  715. return None
  716. ref.resolved = cited # évite de le télécharger une seconde fois
  717. return cited if isinstance(cited, discord.Message) else None # None : message cité supprimé
  718. async def replies_to_me(self, message: discord.Message) -> bool:
  719. cited = await self.fetch_cited(message)
  720. return cited is not None and cited.author == self.user
  721. async def read_reply(self, message: discord.Message, want_images: bool) -> tuple[str | None, list[ImageData]]:
  722. """Lit le message auquel l'utilisateur répond.
  723. Renvoie sa description pour Claude, ainsi que ses images si want_images est vrai,
  724. ou (None, []) si le message ne répond à rien ou si le message cité est inaccessible.
  725. """
  726. cited = await self.fetch_cited(message)
  727. if cited is None:
  728. return None, []
  729. images = await self.attachments.quoted_images(cited.attachments, "cité") if want_images else []
  730. description = self.describe_quoted(cited.clean_content, images, cited.attachments, cited.stickers)
  731. who = "à ton message" if cited.author == self.user else f"au message de {author_name(cited.author)}"
  732. return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", images
  733. async def read_forward(self, message: discord.Message) -> tuple[str | None, list[ImageData]]:
  734. """Lit le message transféré (bouton « Transférer » de Discord).
  735. Renvoie sa description pour Claude et ses images, ou (None, []) si le message
  736. n'est pas un transfert ou si le message transféré ne contient rien de lisible.
  737. """
  738. if not message.message_snapshots:
  739. return None, []
  740. snapshot = message.message_snapshots[0]
  741. images = await self.attachments.quoted_images(snapshot.attachments, "transféré")
  742. # Contrairement à un message normal, le contenu transféré garde ses mentions brutes (<@123>).
  743. content = self.clean_mentions(snapshot.content)
  744. description = self.describe_quoted(content, images, snapshot.attachments, snapshot.stickers)
  745. if not description:
  746. return None, []
  747. return f"du {self.timestamp(snapshot)} : {description}", images
  748. @staticmethod
  749. def describe_quoted(content: str, images: list[ImageData], attachments: list, stickers: list) -> str:
  750. """Description d'un message cité ou transféré (vide s'il n'y a rien de lisible)."""
  751. content = short_emojis(content.strip())
  752. if len(content) > QUOTE_MAX_LENGTH:
  753. content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
  754. parts = [f"« {content} »"] if content else []
  755. if len(images) > 1:
  756. parts.append(f"[ses {len(images)} images sont jointes à ce message]")
  757. elif images:
  758. parts.append("[son image est jointe à ce message]")
  759. elif attachments:
  760. parts.append("[pièce jointe]")
  761. if stickers:
  762. parts.append("[sticker]")
  763. return " ".join(parts)
  764. def clean_mentions(self, text: str) -> str:
  765. """Remplace les mentions brutes (<@123>) par des pseudos, comme le fait clean_content."""
  766. def name(match: re.Match) -> str:
  767. user = self.get_user(int(match.group(1)))
  768. return f"@{user.display_name}" if user else "@quelqu'un"
  769. return USER_MENTION.sub(name, text)
  770. # ------------------------------------------------------------- envoi
  771. def presence_note(self) -> str | None:
  772. return self.presence.prompt_note() if self.presence else None
  773. @staticmethod
  774. def message_body(message: discord.Message) -> str:
  775. """Texte d'un message du salon dédié, tel qu'il est conservé en mémoire (sans ce qui le précède)."""
  776. text = short_emojis(message.clean_content.strip())
  777. if message.stickers:
  778. text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
  779. return text or "(message sans texte)"
  780. def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
  781. return message.created_at.astimezone(self.settings.timezone).strftime(DATE_FORMAT)
  782. async def send_answer(self, message: discord.Message, answer: str, reply: bool = False) -> None:
  783. # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre »
  784. # (sauf reply=True : le premier morceau répond alors au message).
  785. # Les emojis passent par leur nom : un code écrit avec un mauvais identifiant est ainsi corrigé.
  786. answer = discord_emojis(short_emojis(answer), message.guild)
  787. for i, chunk in enumerate(split_message(answer)):
  788. # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
  789. reference = message.to_reference(fail_if_not_exists=False) if reply and i == 0 else None
  790. try:
  791. await message.channel.send(chunk, reference=reference, mention_author=False)
  792. except discord.HTTPException as exc:
  793. logger.error("Envoi de la réponse impossible : %s", exc)
  794. return
  795. async def safe_reply(self, message: discord.Message, text: str) -> None:
  796. try:
  797. await message.reply(text, mention_author=False)
  798. except discord.HTTPException:
  799. try:
  800. await message.channel.send(text)
  801. except discord.HTTPException as exc:
  802. logger.error("Impossible d'envoyer un message : %s", exc)