Преглед на файлове

Emojis du serveur : liste automatique et noms courts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet преди 3 дни
родител
ревизия
f05d377347
променени са 6 файла, в които са добавени 110 реда и са изтрити 17 реда
  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).
 - **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.
 - **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.
 - **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.
 - **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).
 - **`/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.
 - **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/claude_client.py  appels à l'API de Claude
 bot/history.py        mémoire des conversations
 bot/history.py        mémoire des conversations
 bot/discord_bot.py    événements Discord, réactions, /reset
 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.py       statut Discord : tirage au sort et changements
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
 bot/presence_sources.py  animes, musiques et jeux (AniList, Deezer, Steam)
 bot/text_files.py     lecture des fichiers texte joints (.txt, .md, code…)
 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."""
 """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
 import anthropic
 
 
 from .config import Settings
 from .config import Settings
+from .emojis import DISCORD_EMOJI, SHORT_EMOJI
 from .models import thinking_off
 from .models import thinking_off
 from .text_utils import strip_reasoning
 from .text_utils import strip_reasoning
 
 
@@ -78,6 +79,15 @@ PRESENCE_NOTE = (
     "comme quelqu'un qui revient, naturellement, sans forcément t'excuser."
     "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
 # 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.
 # personnalité du bot. Les clés sont les noms des statuts de presence.py.
 CUSTOM_STATUS_REQUESTS = {
 CUSTOM_STATUS_REQUESTS = {
@@ -95,8 +105,6 @@ CUSTOM_STATUS_PROMPT = (
     "Réponds uniquement par ce statut, sans guillemets ni explication."
     "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.
 # 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."
 CONFIG_ERROR = "Je suis mal configuré, préviens l'administrateur du bot."
 
 
@@ -184,9 +192,10 @@ class ClaudeClient:
         return content
         return content
 
 
     async def ask(self, history: list[dict], parts: list[tuple[str, ImageData | None]],
     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.
         """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
         cache_history=False évite d'écrire les messages en cache quand l'appel suivant ne les
         reprendra pas (mentions : contexte différent à chaque fois)."""
         reprendra pas (mentions : contexte différent à chaque fois)."""
         messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
         messages = list(history) + [{"role": "user", "content": self.build_user_content(parts)}]
@@ -197,6 +206,8 @@ class ClaudeClient:
             system += WEB_NOTE
             system += WEB_NOTE
         if self.settings.presence:
         if self.settings.presence:
             system += PRESENCE_NOTE
             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
         # 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).
         # relus depuis le cache à 10 % du prix (pendant 5 minutes, prolongées à chaque message).
@@ -278,8 +289,8 @@ class ClaudeClient:
             return None
             return None
         self._log_usage([response])
         self._log_usage([response])
         lines = [line.strip() for line in self._final_text([response]).splitlines() if line.strip()]
         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)
         thought = re.sub(r"\s{2,}", " ", thought)
         if not thought or "<" in thought:  # vide, ou balise interne écrite par erreur
         if not thought or "<" in thought:  # vide, ou balise interne écrite par erreur
             logger.warning("Statut personnalisé inutilisable : %r", thought)
             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 . import VERSION
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .claude_client import ClaudeClient, ClaudeError, ImageData
 from .config import Settings
 from .config import Settings
+from .emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .history import IMAGE_MARKER, ConversationHistory, render_line
 from .images import ImageError, prepare_image
 from .images import ImageError, prepare_image
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
 from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
@@ -178,6 +179,8 @@ class ChatBot(commands.Bot):
                          self.settings.channel_id)
                          self.settings.channel_id)
             return
             return
         name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
         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(
         embed = discord.Embed(
             title="Bot en ligne",
             title="Bot en ligne",
             description=f"{name} est désormais en ligne. Version {VERSION}.",
             description=f"{name} est désormais en ligne. Version {VERSION}.",
@@ -323,7 +326,8 @@ class ChatBot(commands.Bot):
             try:
             try:
                 async with typing(channel):
                 async with typing(channel):
                     try:
                     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:
                     except ClaudeError as exc:
                         await self.safe_reply(last, exc.user_message)
                         await self.safe_reply(last, exc.user_message)
                         return
                         return
@@ -333,7 +337,7 @@ class ChatBot(commands.Bot):
                         return
                         return
             finally:
             finally:
                 self.forget_waiting(*(p.message for p in batch))
                 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)
         await self.send_answer(last, answer.text)
 
 
     async def handle_mention(self, message: discord.Message) -> None:
     async def handle_mention(self, message: discord.Message) -> None:
@@ -357,7 +361,7 @@ class ChatBot(commands.Bot):
         if self.settings.context_message_limit > 0:
         if self.settings.context_message_limit > 0:
             try:
             try:
                 async for msg in message.channel.history(limit=self.settings.context_message_limit, before=message):
                 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:
                     if msg.attachments:
                         content = (content + " [pièce jointe]").strip()
                         content = (content + " [pièce jointe]").strip()
                     if msg.stickers:
                     if msg.stickers:
@@ -385,7 +389,7 @@ class ChatBot(commands.Bot):
         image = image or reply_image
         image = image or reply_image
 
 
         bot_name = f"@{message.guild.me.display_name}" if message.guild else f"@{self.user.name}"
         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é"
         channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
 
 
         parts = []
         parts = []
@@ -409,7 +413,8 @@ class ChatBot(commands.Bot):
 
 
         async with typing(message.channel):
         async with typing(message.channel):
             try:
             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:
             except ClaudeError as exc:
                 await self.safe_reply(message, exc.user_message)
                 await self.safe_reply(message, exc.user_message)
                 return
                 return
@@ -461,14 +466,15 @@ class ChatBot(commands.Bot):
             parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
             parts = [(note, None) for note in (self.presence_note(), self.presence.departure_note()) if note]
             try:
             try:
                 async with typing(channel):
                 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:
             except ClaudeError as exc:
                 logger.warning("Message d'absence non envoyé : %s", exc.user_message)
                 logger.warning("Message d'absence non envoyé : %s", exc.user_message)
                 return
                 return
             lines = [{"content": text} for text, _ in parts]
             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)
         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:
             try:
                 await channel.send(chunk)
                 await channel.send(chunk)
             except discord.HTTPException as exc:
             except discord.HTTPException as exc:
@@ -540,7 +546,7 @@ class ChatBot(commands.Bot):
         (False si le serveur n'en a aucun)."""
         (False si le serveur n'en a aucun)."""
         if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
         if not CUSTOM_EMOJI_ONLY.match(message.content.strip()):
             return False
             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:
         if not emojis:
             return False
             return False
         try:
         try:
@@ -705,7 +711,7 @@ class ChatBot(commands.Bot):
     @staticmethod
     @staticmethod
     def describe_quoted(content: str, image: ImageData | None, attachments: list, stickers: list) -> str:
     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)."""
         """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:
         if len(content) > QUOTE_MAX_LENGTH:
             content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
             content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
         parts = [f"« {content} »"] if content else []
         parts = [f"« {content} »"] if content else []
@@ -732,7 +738,7 @@ class ChatBot(commands.Bot):
     @staticmethod
     @staticmethod
     def message_body(message: discord.Message) -> str:
     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)."""
         """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:
         if message.stickers:
             text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
             text = f"{text} [sticker « {message.stickers[0].name} »]".strip()
         return text or "(message sans texte)"
         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:
     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 »
         # 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).
         # (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)):
         for i, chunk in enumerate(split_message(answer)):
             # fail_if_not_exists=False : si le message a été supprimé entre-temps, envoi normal.
             # 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
             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
 Version 5.8
 -----------
 -----------