Przeglądaj źródła

Emojis du serveur : liste automatique et noms courts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 3 dni temu
rodzic
commit
f05d377347
6 zmienionych plików z 110 dodań i 17 usunięć
  1. 2 0
      README.md
  2. 1 1
      bot/__init__.py
  3. 16 5
      bot/claude_client.py
  4. 19 11
      bot/discord_bot.py
  5. 52 0
      bot/emojis.py
  6. 20 0
      changelog.txt

+ 2 - 0
README.md

@@ -13,6 +13,7 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 - **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 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.
+- **Emojis dans les réponses** : la liste des emojis du serveur est donnée automatiquement à Claude, qui les écrit par leur nom (`:hap:`) ; le bot les remplace par l'emoji avant l'envoi. Les emojis animés sont compris (un bot n'a pas besoin de Nitro) ; seuls ceux réservés à des rôles que le bot n'a pas sont exclus. Un emoji ajouté ou retiré du serveur est pris en compte tout de suite : la personnalité dit seulement quand en mettre (par exemple « Tu termines chaque message par un seul emoji du serveur »), sans recopier la liste.
 - **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.
@@ -62,6 +63,7 @@ bot/config.py         lecture et validation du .env
 bot/claude_client.py  appels à l'API de Claude
 bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
+bot/emojis.py         emojis du serveur : liste pour Claude, noms remplacés à l'envoi
 bot/presence.py       statut Discord : tirage au sort et changements
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
 bot/text_files.py     lecture des fichiers texte joints (.txt, .md, code…)

+ 1 - 1
bot/__init__.py

@@ -1,3 +1,3 @@
 """Chatbot Discord propulsé par Claude."""
 
-VERSION = "5.8"
+VERSION = "5.9"

+ 16 - 5
bot/claude_client.py

@@ -11,6 +11,7 @@ from pathlib import Path
 import anthropic
 
 from .config import Settings
+from .emojis import DISCORD_EMOJI, SHORT_EMOJI
 from .models import thinking_off
 from .text_utils import strip_reasoning
 
@@ -78,6 +79,15 @@ PRESENCE_NOTE = (
     "comme quelqu'un qui revient, naturellement, sans forcément t'excuser."
 )
 
+# Ajouté quand le serveur a des emojis utilisables : leur liste, tirée de Discord. Les codes Discord
+# (<:nom:123…>) sont 3 fois plus longs et Claude se tromperait d'identifiant : il écrit le nom seul,
+# remplacé à l'envoi (voir emojis.py).
+EMOJI_NOTE = (
+    "\n\nEmojis du serveur : écris-les par leur nom entre deux-points, par exemple :{example}: ; "
+    "ils s'affichent ensuite en image sur Discord, et les messages de la conversation les montrent "
+    "sous cette même forme. Les seuls disponibles : {names}."
+)
+
 # Demandes de statut personnalisé (pensée du moment, raison d'une absence), écrit avec la
 # personnalité du bot. Les clés sont les noms des statuts de presence.py.
 CUSTOM_STATUS_REQUESTS = {
@@ -95,8 +105,6 @@ CUSTOM_STATUS_PROMPT = (
     "Réponds uniquement par ce statut, sans guillemets ni explication."
 )
 
-CUSTOM_EMOJI = re.compile(r"<a?:\w+:\d+>")
-
 # Erreur due à la configuration (clé, crédit, modèle, réglages) : seul l'administrateur peut la corriger.
 CONFIG_ERROR = "Je suis mal configuré, préviens l'administrateur du bot."
 
@@ -184,9 +192,10 @@ class ClaudeClient:
         return content
 
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
-                  cache_history: bool = True) -> Answer:
+                  emojis: list[str] | None = None, cache_history: bool = True) -> Answer:
         """Envoie l'historique + le ou les nouveaux messages (texte, image ou PDF), et renvoie la réponse de Claude.
 
+        emojis : noms des emojis du serveur que Claude peut écrire (voir EMOJI_NOTE).
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
         reprendra pas (mentions : contexte différent à chaque fois)."""
         messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
@@ -197,6 +206,8 @@ class ClaudeClient:
             system += WEB_NOTE
         if self.settings.presence:
             system += PRESENCE_NOTE
+        if emojis:
+            system += EMOJI_NOTE.format(example=emojis[0], names=" ".join(f":{name}:" for name in emojis))
 
         # Cache de prompt : les outils et la personnalité, identiques d'un message à l'autre, sont
         # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
@@ -278,8 +289,8 @@ class ClaudeClient:
             return None
         self._log_usage([response])
         lines = [line.strip() for line in self._final_text([response]).splitlines() if line.strip()]
-        # Un emoji du serveur s'afficherait sous forme de code (<:nom:123…>) : on le retire.
-        thought = CUSTOM_EMOJI.sub("", lines[0]).strip("\"'«» ") if lines else ""
+        # Un emoji du serveur s'afficherait sous forme de code (<:nom:123…> ou :nom:) : on le retire.
+        thought = SHORT_EMOJI.sub("", DISCORD_EMOJI.sub("", lines[0])).strip("\"'«» ") if lines else ""
         thought = re.sub(r"\s{2,}", " ", thought)
         if not thought or "<" in thought:  # vide, ou balise interne écrite par erreur
             logger.warning("Statut personnalisé inutilisable : %r", thought)

+ 19 - 11
bot/discord_bot.py

@@ -19,6 +19,7 @@ from discord.ext import commands
 from . import VERSION
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
+from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
@@ -178,6 +179,8 @@ class ChatBot(commands.Bot):
                          self.settings.channel_id)
             return
         name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
+        logger.info("%d emoji(s) du serveur utilisable(s) dans les réponses.",
+                    len(usable_emojis(getattr(channel, "guild", None))))
         embed = discord.Embed(
             title="Bot en ligne",
             description=f"{name} est désormais en ligne. Version {VERSION}.",
@@ -323,7 +326,8 @@ class ChatBot(commands.Bot):
             try:
                 async with typing(channel):
                     try:
-                        answer = await self.claude.ask(self.history.get(channel.id), parts)
+                        answer = await self.claude.ask(self.history.get(channel.id), parts,
+                                                       emojis=emoji_names(last.guild))
                     except ClaudeError as exc:
                         await self.safe_reply(last, exc.user_message)
                         return
@@ -333,7 +337,7 @@ class ChatBot(commands.Bot):
                         return
             finally:
                 self.forget_waiting(*(p.message for p in batch))
-            await self.history.add_exchange(channel.id, lines, answer.text, answer.web_used)
+            await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
         await self.send_answer(last, answer.text)
 
     async def handle_mention(self, message: discord.Message) -> None:
@@ -357,7 +361,7 @@ class ChatBot(commands.Bot):
         if self.settings.context_message_limit > 0:
             try:
                 async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
-                    content = msg.clean_content.strip()
+                    content = short_emojis(msg.clean_content.strip())
                     if msg.attachments:
                         content = (content + " [pièce jointe]").strip()
                     if msg.stickers:
@@ -385,7 +389,7 @@ class ChatBot(commands.Bot):
         image = image or reply_image
 
         bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
-        text = message.clean_content.replace(bot_name, "").strip() or "(simple mention, sans texte)"
+        text = short_emojis(message.clean_content.replace(bot_name, "").strip()) or "(simple mention, sans texte)"
         channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
 
         parts = []
@@ -409,7 +413,8 @@ class ChatBot(commands.Bot):
 
         async with typing(message.channel):
             try:
-                answer = await self.claude.ask([], [(prompt, image)], cache_history=False)
+                answer = await self.claude.ask([], [(prompt, image)], emojis=emoji_names(message.guild),
+                                               cache_history=False)
             except ClaudeError as exc:
                 await self.safe_reply(message, exc.user_message)
                 return
@@ -461,14 +466,15 @@ class ChatBot(commands.Bot):
             parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
             try:
                 async with typing(channel):
-                    answer = await self.claude.ask(self.history.get(channel.id), parts)
+                    answer = await self.claude.ask(self.history.get(channel.id), parts,
+                                                   emojis=emoji_names(channel.guild))
             except ClaudeError as exc:
                 logger.warning("Message d'absence non envoyé : %s", exc.user_message)
                 return
             lines = [{"content": text} for text, _ in parts]
-            await self.history.add_exchange(channel.id, lines, answer.text, answer.web_used)
+            await self.history.add_exchange(channel.id, lines, short_emojis(answer.text), answer.web_used)
         logger.info("Conversation en cours dans #%s : le salon est prévenu de l'absence.", channel)
-        for chunk in split_message(answer.text):
+        for chunk in split_message(discord_emojis(short_emojis(answer.text), channel.guild)):
             try:
                 await channel.send(chunk)
             except discord.HTTPException as exc:
@@ -540,7 +546,7 @@ class ChatBot(commands.Bot):
         (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 []
+        emojis = usable_emojis(message.guild)
         if not emojis:
             return False
         try:
@@ -705,7 +711,7 @@ class ChatBot(commands.Bot):
     @staticmethod
     def describe_quoted(content: str, image: ImageData | None, attachments: list, stickers: list) -> str:
         """Description d'un message cité ou transféré (vide s'il n'y a rien de lisible)."""
-        content = content.strip()
+        content = short_emojis(content.strip())
         if len(content) > QUOTE_MAX_LENGTH:
             content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
         parts = [f"« {content} »"] if content else []
@@ -732,7 +738,7 @@ class ChatBot(commands.Bot):
     @staticmethod
     def message_body(message: discord.Message) -> str:
         """Texte d'un message du salon dédié, tel qu'il est conservé en mémoire (sans ce qui le précède)."""
-        text = message.clean_content.strip()
+        text = short_emojis(message.clean_content.strip())
         if message.stickers:
             text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
         return text or "(message sans texte)"
@@ -743,6 +749,8 @@ class ChatBot(commands.Bot):
     async def send_answer(self, message: discord.Message, answer: str, reply: bool = False) -> None:
         # Comme un humain : la réponse est postée à la suite dans le salon, sans « Répondre »
         # (sauf reply=True : le premier morceau répond alors au message).
+        # Les emojis passent par leur nom : un code écrit avec un mauvais identifiant est ainsi corrigé.
+        answer = discord_emojis(short_emojis(answer), message.guild)
         for i, chunk in enumerate(split_message(answer)):
             # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
             reference = message.to_reference(fail_if_not_exists=False) if reply and i == 0 else None

+ 52 - 0
bot/emojis.py

@@ -0,0 +1,52 @@
+"""Emojis du serveur : Claude les écrit par leur nom (:hap:), Discord les reçoit sous forme de code."""
+
+from __future__ import annotations
+
+import re
+
+import discord
+
+# Code Discord d'un emoji personnalisé : <:nom:123…>, ou <a:nom:123…> s'il est animé.
+DISCORD_EMOJI = re.compile(r"<a?:(\w+):\d+>")
+# Nom d'emoji entre deux-points ; le code est repris tel quel, comme les blocs de code.
+_SHORT_EMOJI = re.compile(r"```.*?```|`[^`\n]*`|<a?:\w+:\d+>|:([A-Za-z0-9_]{2,32}):", re.S)
+SHORT_EMOJI = re.compile(r":[A-Za-z0-9_]{2,32}:")
+
+
+def usable_emojis(guild: discord.Guild | None) -> list[discord.Emoji]:
+    """Emojis que le bot peut envoyer, triés par nom (la liste du prompt reste ainsi en cache).
+
+    Animés compris : un bot n'a pas besoin de Nitro. Ceux réservés à des rôles que le bot
+    n'a pas sont exclus."""
+    if guild is None:
+        return []
+    roles = set(guild.me.roles) if guild.me else set()
+    emojis = [e for e in guild.emojis if e.available and (not e.roles or roles.intersection(e.roles))]
+    return sorted(emojis, key=lambda e: (e.name.lower(), e.id))
+
+
+def emoji_names(guild: discord.Guild | None) -> list[str]:
+    return [e.name for e in usable_emojis(guild)]
+
+
+def short_emojis(text: str) -> str:
+    """Remplace les codes des emojis (<:hap:123…>) par leur nom (:hap:), plus court à lire pour Claude."""
+    return DISCORD_EMOJI.sub(r":\1:", text)
+
+
+def discord_emojis(text: str, guild: discord.Guild | None) -> str:
+    """Remplace les noms d'emojis écrits par Claude (:hap:) par leur code Discord.
+
+    Un nom inconnu reste tel quel ; la casse est tolérée (:Hap: pour :hap:)."""
+    emojis = usable_emojis(guild)
+    if not emojis:
+        return text
+    exact = {e.name: e for e in emojis}
+    loose = {e.name.lower(): e for e in reversed(emojis)}
+
+    def replace(match: re.Match) -> str:
+        name = match.group(1)
+        emoji = name and (exact.get(name) or loose.get(name.lower()))
+        return str(emoji) if emoji else match.group(0)
+
+    return _SHORT_EMOJI.sub(replace, text)

+ 20 - 0
changelog.txt

@@ -2,6 +2,26 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.9
+-----------
+
+Nouveautés
+- Emojis du serveur : Rika reçoit automatiquement la liste des emojis du
+  serveur et peut tous les utiliser, sans qu'il faille les recopier dans
+  la personnalité ; un emoji ajouté ou retiré est pris en compte tout de
+  suite. Elle les écrit par leur nom (:hap:), et le bot les remplace par
+  l'emoji avant l'envoi : plus d'identifiant inventé, et environ 3 fois
+  moins de tokens par emoji. Les emojis animés sont compris (un bot n'a
+  pas besoin de Nitro) ; seuls ceux réservés à des rôles qu'elle n'a pas
+  sont exclus, y compris quand elle répond à un emoji seul. Dans sa
+  mémoire et dans les messages qu'elle
+  lit, les emojis apparaissent aussi par leur nom.
+  La personnalité dit seulement quand en mettre, par exemple : « Tu
+  termines chaque message par un seul emoji du serveur, directement
+  après le dernier mot. » Une ancienne liste de codes (<:hap:123…>)
+  fonctionne encore, mais n'est plus utile.
+
+
 Version 5.8
 -----------