Prechádzať zdrojové kódy

Stickers, emojis et « en train d'écrire » : erreurs Discord gérées

Un refus de l'indicateur « en train d'écrire » (permission manquante,
panne passagère) ne fait plus perdre la réponse. Seul un sticker envoyé
sans texte ni pièce jointe reçoit un sticker en retour ; accompagné de
texte, il est décrit à Claude avec le message, même pendant une absence.
« Aucun sticker utilisable » et « Aucun emoji personnalisé » ne sont plus
envoyés à chaque fois : si le serveur n'a rien d'utilisable, Rika répond
avec des mots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 4 dní pred
rodič
commit
9a97088a05
3 zmenil súbory, kde vykonal 50 pridanie a 19 odobranie
  1. 1 1
      README.md
  2. 41 18
      bot/discord_bot.py
  3. 8 0
      changelog.txt

+ 1 - 1
README.md

@@ -11,7 +11,7 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 - **Réponses** : quand on utilise « Répondre » sur un message, même ancien, le bot lit le message cité (et son image, si on n'en joint pas une autre).
 - **Transferts** : dans le salon dédié, le bot lit un message transféré (son texte et son image).
 - **Autres bots** : le bot ignore les autres bots, sauf quand ils le mentionnent ou répondent à l'un de ses messages (bouton « Répondre ») : deux bots peuvent ainsi discuter ensemble. Les messages système (arrivées, épinglages, boosts…) sont ignorés.
-- **Stickers et emojis** : répond à un sticker par un sticker du serveur, et à un emoji personnalisé seul par un emoji du serveur.
+- **Stickers et emojis** : répond à un sticker seul par un sticker du serveur, et à un emoji personnalisé seul par un emoji du serveur (avec des mots si le serveur n'en a aucun d'utilisable). Un sticker accompagné de texte est décrit à Claude avec le message.
 - **Statut Discord** : sous son pseudo, le bot regarde quelques épisodes d'un anime (top 500 d'AniList), écoute quelques morceaux (tops Deezer), joue à un jeu (toute la boutique Steam, sans les jeux pour adultes), affiche une pensée du moment, ou passe en « Ne pas déranger » ou absent avec une raison écrite par Claude. Chaque statut a sa propre durée (réglages `PRESENCE_*` du `.env`), le bot est absent la nuit, et il sait ce qu'il est en train de faire quand on lui parle. Absent, il ne répond qu'à son retour ; en « Ne pas déranger », au bout de 5 à 15 minutes. S'il change de statut en pleine conversation (message dans son salon depuis moins de 3 minutes), il prévient d'abord qu'il s'absente. Les messages reçus entre-temps reçoivent une réponse groupée dans le salon dédié, et une réponse à chaque mention ailleurs (gardés dans `waiting_messages.json`, même après un redémarrage). Les listes sont téléchargées au démarrage puis une fois par jour, et gardées dans `presence_sources.json` à côté de l'historique.
 - **`/reset`** : efface la mémoire du salon (réservé aux modérateurs et administrateurs).
 - **Personnalité** modifiable dans `personality_prompt.txt`, prise en compte sans redémarrer. Pour une personnalité perso, crée `personality_prompt.local.txt` et indique-le dans `PERSONALITY_FILE` du `.env` : les fichiers `*.local.txt` ne sont jamais envoyés sur GitHub.

+ 41 - 18
bot/discord_bot.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import asyncio
+import contextlib
 import json
 import logging
 import random
@@ -45,6 +46,22 @@ def looks_like_text(attachment: discord.Attachment) -> bool:
     return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
 
 
+@contextlib.asynccontextmanager
+async def typing(channel: discord.abc.Messageable):
+    """« En train d'écrire… » pendant le bloc, sans empêcher la réponse si Discord le refuse."""
+    indicator = channel.typing()
+    try:
+        await indicator.__aenter__()  # seul ce premier envoi peut échouer : les suivants sont protégés
+    except discord.HTTPException as exc:
+        logger.warning("Indicateur « en train d'écrire » impossible dans #%s : %s", channel, exc)
+        indicator = None
+    try:
+        yield
+    finally:
+        if indicator is not None:
+            await indicator.__aexit__(None, None, None)
+
+
 def text_file_prompt(name: str, content: str) -> str:
     """Contenu d'un fichier .txt tel qu'il est transmis à Claude."""
     return f"\n\n[Contenu du fichier joint « {name} » :]\n{content}\n[Fin du fichier « {name} »]"
@@ -161,14 +178,17 @@ class ChatBot(commands.Bot):
     async def handle_main_channel(self, message: discord.Message) -> None:
         if self._last_main_message is None or message.created_at > self._last_main_message:
             self._last_main_message = message.created_at
-        if not self.available():
-            if message.stickers or CUSTOM_EMOJI_ONLY.match(message.content.strip()):
+        content = message.content.strip()
+        # Un sticker ou un emoji seul reçoit un sticker ou un emoji du serveur. S'il n'y en a aucun
+        # d'utilisable, Claude répond avec des mots ; accompagné de texte, le sticker lui est décrit.
+        if (message.stickers and not content and not message.attachments) or CUSTOM_EMOJI_ONLY.match(content):
+            if not self.available():
                 return  # absent : pas de réaction aux stickers ni aux emojis
-        elif await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
-            return
+            if await self.reply_to_sticker(message) or await self.reply_to_emoji(message):
+                return
 
         forward, forward_image = await self.read_forward(message)
-        if not (message.content.strip() or message.attachments or forward):
+        if not (content or message.attachments or message.stickers or forward):
             return  # sondage, transfert d'un message vide… : rien à lire
 
         attachments = await self.read_attachments(message)
@@ -183,7 +203,10 @@ class ChatBot(commands.Bot):
         if forward:
             user_text = f"{self.timestamp(message)}, {message.author.display_name} a transféré un message {forward}"
         else:
-            text = message.clean_content.strip() or "(message sans texte)"
+            text = message.clean_content.strip()
+            if message.stickers:
+                text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
+            text = text or "(message sans texte)"
             reply_part = f" ({reply})" if reply else ""
             user_text = f"{self.timestamp(message)}, {message.author.display_name} a écrit{reply_part} : {text}"
         stored_text = user_text + (f" {IMAGE_MARKER}" if image else "")
@@ -220,7 +243,7 @@ class ChatBot(commands.Bot):
                 parts.insert(0, (note, None))
                 stored_lines.insert(0, note)
             try:
-                async with channel.typing():
+                async with typing(channel):
                     try:
                         answer = await self.claude.ask(self.history.get(channel.id), parts)
                     except ClaudeError as exc:
@@ -304,7 +327,7 @@ class ChatBot(commands.Bot):
                 await self.safe_reply(message, exc.user_message)
                 return
 
-        async with message.channel.typing():
+        async with typing(message.channel):
             try:
                 answer = await self.claude.ask([], [(prompt, image)], cache_history=False)
             except ClaudeError as exc:
@@ -357,7 +380,7 @@ class ChatBot(commands.Bot):
                 return
             parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
             try:
-                async with channel.typing():
+                async with typing(channel):
                     answer = await self.claude.ask(self.history.get(channel.id), parts)
             except ClaudeError as exc:
                 logger.warning("Message d'absence non envoyé : %s", exc.user_message)
@@ -417,11 +440,9 @@ class ChatBot(commands.Bot):
     # ------------------------------------------------------------- réactions
 
     async def reply_to_sticker(self, message: discord.Message) -> bool:
-        """Répond à un sticker par un sticker aléatoire du serveur."""
-        if not message.stickers:
+        """Répond à un sticker par un sticker aléatoire du serveur (False si aucun n'est utilisable)."""
+        if not message.stickers or message.guild is None:
             return False
-        if message.guild is None:
-            return True
         stickers = [s for s in message.guild.stickers if s.available]
         random.shuffle(stickers)
         for sticker in stickers:
@@ -429,19 +450,21 @@ class ChatBot(commands.Bot):
                 await message.channel.send(stickers=[sticker])
                 logger.info("Sticker envoyé : %s", sticker.name)
                 return True
+            except discord.Forbidden as exc:  # pas le droit d'envoyer de sticker : inutile d'insister
+                logger.warning("Envoi de sticker interdit : %s", exc)
+                break
             except discord.HTTPException as exc:
                 logger.warning("Sticker %s inutilisable : %s", sticker.name, exc)
-        await message.channel.send("Aucun sticker utilisable sur ce serveur.")
-        return True
+        return False
 
     async def reply_to_emoji(self, message: discord.Message) -> bool:
-        """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur."""
+        """Répond à un message composé d'un seul emoji personnalisé par un emoji aléatoire du serveur
+        (False si le serveur n'en a aucun)."""
         if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
             return False
         emojis = [e for e in message.guild.emojis if e.available] if message.guild else []
         if not emojis:
-            await message.channel.send("Aucun emoji personnalisé sur ce serveur.")
-            return True
+            return False
         try:
             await message.channel.send(str(random.choice(emojis)))
         except discord.HTTPException as exc:

+ 8 - 0
changelog.txt

@@ -26,6 +26,14 @@ Corrections
   la cause.
 - Les fichiers .txt enregistrés en « Unicode » par le Bloc-notes (UTF-16)
   sont lus, au lieu d'être refusés comme « pas du texte lisible ».
+- Stickers : un sticker accompagné de texte ou d'une pièce jointe n'efface
+  plus le message (même pendant une absence) : Claude reçoit le texte et
+  le nom du sticker. « Aucun sticker utilisable » et « Aucun emoji
+  personnalisé » ne sont plus envoyés à chaque fois : si le serveur n'a
+  rien d'utilisable, Rika répond avec des mots.
+- Si Discord refuse l'indicateur « en train d'écrire » (permission
+  manquante, panne passagère), la réponse est quand même envoyée, au lieu
+  d'être perdue.
 
 
 Version 5.6 — 30/10/2026