Prechádzať zdrojové kódy

Changement de modèle, transferts et discussion entre bots

- Changement de modèle via CLAUDE_MODEL (Sonnet 5.5, Haiku 5.5, Opus 5.5) :
  THINKING=off et les tarifs du coût estimé s'adaptent au modèle.
- Lecture des messages transférés dans le salon dédié (texte et image).
- Le bot répond aux autres bots qui le mentionnent ou répondent à ses messages.
- Les messages système et les messages vides ne déclenchent plus de réponse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 5 dní pred
rodič
commit
4555006f78
8 zmenil súbory, kde vykonal 192 pridanie a 58 odobranie
  1. 11 5
      .env.exemple
  2. 2 0
      README.md
  3. 1 1
      bot/__init__.py
  4. 18 9
      bot/claude_client.py
  5. 13 7
      bot/config.py
  6. 97 36
      bot/discord_bot.py
  7. 25 0
      bot/models.py
  8. 25 0
      changelog.txt

+ 11 - 5
.env.exemple

@@ -8,12 +8,16 @@ ANTHROPIC_API_KEY=
 CHANNEL_ID=
 
 # --- Claude --------------------------------------------------------------
+# Modèle utilisé : claude-sonnet-5-5 (recommandé), claude-haiku-5-5 (bien moins cher)
+# ou claude-opus-5-5 (plus capable, plus cher). Redémarre le bot après un changement.
 CLAUDE_MODEL=claude-sonnet-5-5
 # Longueur maximale d'une réponse (en tokens, ~0,75 mot par token)
 MAX_TOKENS=3000
-# Réflexion avant de répondre : off (rapide) ou on (plus réfléchi, plus lent et plus cher)
+# Réflexion avant de répondre : off (rapide) ou on (plus réfléchi, plus lent et plus cher).
+# Sur claude-opus-5-5, la réflexion ne peut pas être coupée : règle plutôt EFFORT=low.
 THINKING=off
-# Niveau d'effort : low, medium ou high. Vide = valeur par défaut du modèle.
+# Niveau d'effort : low, medium, high, xhigh ou max (xhigh et max nécessitent THINKING=on,
+# sauf sur claude-opus-5-5). Vide = valeur par défaut du modèle.
 EFFORT=
 # Délai maximal d'attente d'une réponse, en secondes
 API_TIMEOUT=90
@@ -41,9 +45,11 @@ MAX_IMAGE_SIZE=25000000
 # --- Divers --------------------------------------------------------------
 # Fuseau horaire utilisé pour l'heure indiquée à Claude
 TIMEZONE=Europe/Paris
-# Tarifs en dollars par million de tokens, pour l'estimation des coûts dans les logs
-PRICE_INPUT_PER_MTOK=2.0
-PRICE_OUTPUT_PER_MTOK=10.0
+# Tarifs en dollars par million de tokens, pour l'estimation des coûts dans les logs.
+# Vide = tarifs connus du modèle choisi (Sonnet 5.5, Haiku 5.5, Opus 5.5) : ne les
+# renseigne que pour un autre modèle, sinon ils resteraient fixes en changeant de modèle.
+PRICE_INPUT_PER_MTOK=
+PRICE_OUTPUT_PER_MTOK=
 # Tarif en dollars pour 1000 recherches web
 PRICE_WEB_SEARCH_PER_1000=10.0
 LOG_FILE=bot.log

+ 2 - 0
README.md

@@ -9,6 +9,8 @@ Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic)
 - **Images** : commente une image envoyée dans le salon dédié (JPEG, PNG, GIF, WebP, une par message).
 - **Fichiers texte** : lit un fichier `.txt` joint au message (un par message, 100 Ko maximum), dans le salon dédié et en mode mention. Seul le nom du fichier est gardé en mémoire, pas son contenu.
 - **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.
 - **`/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.

+ 1 - 1
bot/__init__.py

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

+ 18 - 9
bot/claude_client.py

@@ -10,6 +10,7 @@ from pathlib import Path
 import anthropic
 
 from .config import Settings
+from .models import thinking_off
 
 logger = logging.getLogger("chatbot.claude")
 
@@ -141,10 +142,13 @@ class ClaudeClient:
             "messages": messages,
             "cache_control": {"type": "ephemeral"},
         }
-        # Par défaut, Sonnet 5.5 réfléchit avant de répondre, et cette réflexion consomme
-        # le budget MAX_TOKENS. "between_tools" désactive la réflexion préalable
-        # (il ne reste qu'une courte réflexion entre deux recherches web, si elles sont activées).
-        params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
+        # Par défaut, les modèles récents réfléchissent avant de répondre, et cette réflexion
+        # consomme le budget MAX_TOKENS. Avec THINKING=off, on la coupe de la façon propre au
+        # modèle (voir models.py) ; sur Opus 5.5, impossible : seul EFFORT la réduit.
+        if self.settings.thinking:
+            params["thinking"] = {"type": "adaptive"}
+        elif (off := thinking_off(self.settings.model)) is not None:
+            params["thinking"] = off
         if self.settings.effort:
             params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
         if self.settings.web_search:
@@ -224,14 +228,19 @@ class ClaudeClient:
             if r.usage.server_tool_use:
                 searches += r.usage.server_tool_use.web_search_requests or 0
                 fetches += r.usage.server_tool_use.web_fetch_requests or 0
-        # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
-        cost = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * self.settings.price_input_per_mtok
-                + output_tokens * self.settings.price_output_per_mtok) / 1_000_000
-        cost += searches * self.settings.price_web_search_per_1000 / 1000
+        price_input, price_output = self.settings.price_input_per_mtok, self.settings.price_output_per_mtok
+        if price_input is None or price_output is None:
+            cost = "inconnu (renseigne PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK dans le .env)"
+        else:
+            # Écriture en cache : 125 % du prix d'entrée ; lecture depuis le cache : 10 %.
+            amount = ((input_tokens + cache_write * 1.25 + cache_read * 0.1) * price_input
+                      + output_tokens * price_output) / 1_000_000
+            amount += searches * self.settings.price_web_search_per_1000 / 1000
+            cost = f"{amount:.5f} $"
         web = f", recherches web : {searches}, pages lues : {fetches}" if searches or fetches else ""
         logger.info(
             "Appel Claude (%s) — entrée : %s tokens (+ cache : %s lus, %s écrits), sortie : %s tokens%s, "
-            "coût estimé : %.5f $",
+            "coût estimé : %s",
             responses[-1].model, input_tokens, cache_read, cache_write, output_tokens, web, cost,
         )
         return bool(searches or fetches)

+ 13 - 7
bot/config.py

@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from dotenv import load_dotenv
 
+from .models import PRICES, thinking_off
 
 EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
 
@@ -78,8 +79,8 @@ class Settings:
 
     # Divers
     timezone: ZoneInfo
-    price_input_per_mtok: float
-    price_output_per_mtok: float
+    price_input_per_mtok: float | None   # None = modèle absent de la table des tarifs
+    price_output_per_mtok: float | None
     price_web_search_per_1000: float
     log_file: Path
 
@@ -100,13 +101,18 @@ def load_settings() -> Settings:
         raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
     thinking = thinking_raw == "on"
 
+    model = _get_str("CLAUDE_MODEL", default="claude-sonnet-5-5")
     effort = _get_str("EFFORT")
     if effort is not None:
         effort = effort.lower()
         if effort not in EFFORT_LEVELS:
             raise ConfigError(f"EFFORT doit valoir {', '.join(EFFORT_LEVELS)} (valeur actuelle : {effort!r}).")
-        if not thinking and effort in ("xhigh", "max"):
-            raise ConfigError(f"EFFORT={effort} nécessite THINKING=on.")
+        # Couper la réflexion n'est accepté qu'avec un effort high ou moins.
+        if not thinking and effort in ("xhigh", "max") and thinking_off(model) is not None:
+            raise ConfigError(f"EFFORT={effort} nécessite THINKING=on avec le modèle {model}.")
+
+    # Tarifs du .env s'ils sont renseignés, sinon ceux du modèle choisi.
+    default_input, default_output = PRICES.get(model, (None, None))
 
     web_search_raw = (_get_str("WEB_SEARCH", default="off") or "off").lower()
     if web_search_raw not in ("on", "off"):
@@ -116,7 +122,7 @@ def load_settings() -> Settings:
         discord_token=_get_str("DISCORD_TOKEN", required=True),
         anthropic_api_key=_get_str("ANTHROPIC_API_KEY", required=True),
         channel_id=_get_int("CHANNEL_ID", required=True),
-        model=_get_str("CLAUDE_MODEL", default="claude-sonnet-5-5"),
+        model=model,
         max_tokens=_get_int("MAX_TOKENS", default=1500, minimum=1),
         thinking=thinking,
         effort=effort,
@@ -130,8 +136,8 @@ def load_settings() -> Settings:
         # Taille maximale acceptée depuis Discord ; les grosses images sont réduites avant l'envoi à Claude.
         max_image_size=_get_int("MAX_IMAGE_SIZE", default=25_000_000, minimum=1),
         timezone=timezone,
-        price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=2.0),
-        price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=10.0),
+        price_input_per_mtok=_get_float("PRICE_INPUT_PER_MTOK", default=default_input),
+        price_output_per_mtok=_get_float("PRICE_OUTPUT_PER_MTOK", default=default_output),
         price_web_search_per_1000=_get_float("PRICE_WEB_SEARCH_PER_1000", default=10.0),
         log_file=Path(_get_str("LOG_FILE", default="bot.log")),
     )

+ 97 - 36
bot/discord_bot.py

@@ -22,9 +22,13 @@ from .text_utils import split_message
 logger = logging.getLogger("chatbot.discord")
 
 CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
+USER_MENTION = re.compile(r"<@!?(\d+)>")
 IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
-# Longueur maximale du texte d'un message cité transmis à Claude.
+# Longueur maximale du texte d'un message cité ou transféré transmis à Claude.
 QUOTE_MAX_LENGTH = 500
+# Messages écrits par quelqu'un ; les autres types sont des messages système
+# (arrivée d'un membre, message épinglé, boost, fil créé…).
+USER_MESSAGE_TYPES = (discord.MessageType.default, discord.MessageType.reply)
 
 
 def looks_like_image(attachment: discord.Attachment) -> bool:
@@ -98,8 +102,14 @@ class ChatBot(commands.Bot):
     # ------------------------------------------------------------- messages
 
     async def on_message(self, message: discord.Message) -> None:
-        # Ignore les bots (dont lui-même) et les webhooks : évite les boucles infinies.
-        if message.author.bot or message.webhook_id is not None:
+        # Ignore ses propres messages et les webhooks : évite les boucles infinies.
+        if message.author == self.user or message.webhook_id is not None:
+            return
+        if message.type not in USER_MESSAGE_TYPES:
+            return  # message système
+        # Les autres bots ne sont écoutés que lorsqu'ils s'adressent au bot : mention,
+        # ou réponse à l'un de ses messages. Deux bots peuvent ainsi discuter ensemble.
+        if message.author.bot and self.user not in message.mentions and not await self.replies_to_me(message):
             return
 
         in_main_channel = message.channel.id == self.settings.channel_id
@@ -117,18 +127,25 @@ class ChatBot(commands.Bot):
         if 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):
+            return  # sondage, transfert d'un message vide… : rien à lire
+
         attachments = await self.read_attachments(message)
         if attachments is False:
             return  # pièce jointe refusée, l'utilisateur a été prévenu
         image, text_file = attachments
 
         # L'image du message cité n'est utilisée que si le message n'en contient pas lui-même.
-        reply, reply_image = await self.read_reply(message, want_image=image is None)
-        image = image or reply_image
-
-        text = message.clean_content.strip() 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}"
+        reply, reply_image = await self.read_reply(message, want_image=image is None and forward_image is None)
+        image = image or forward_image or reply_image
+
+        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)"
+            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 "")
         if text_file:
             user_text += text_file_prompt(*text_file)
@@ -298,17 +315,14 @@ class ChatBot(commands.Bot):
         # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot).
         return await asyncio.to_thread(prepare_image, data)
 
-    # ------------------------------------------------------- message cité
+    # ------------------------------------------- message cité ou transféré
 
-    async def read_reply(self, message: discord.Message, want_image: bool) -> tuple[str | None, ImageData | None]:
-        """Lit le message auquel l'utilisateur répond (bouton « Répondre » de Discord).
-
-        Renvoie sa description pour Claude, ainsi que son image si want_image est vrai,
-        ou (None, None) si le message ne répond à rien ou si le message cité est inaccessible.
-        """
+    async def fetch_cited(self, message: discord.Message) -> discord.Message | None:
+        """Renvoie le message auquel ce message répond (bouton « Répondre » de Discord),
+        ou None s'il ne répond à rien ou si le message cité est inaccessible."""
         ref = message.reference
         if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
-            return None, None
+            return None
 
         cited = ref.resolved
         if cited is None:  # Discord ne l'a pas fourni (vieux message) : on le télécharge
@@ -316,37 +330,84 @@ class ChatBot(commands.Bot):
                 cited = await message.channel.fetch_message(ref.message_id)
             except discord.HTTPException as exc:
                 logger.info("Message cité inaccessible : %s", exc)
-                return None, None
-        if not isinstance(cited, discord.Message):
-            return None, None  # message cité supprimé
+                return None
+            ref.resolved = cited  # évite de le télécharger une seconde fois
+        return cited if isinstance(cited, discord.Message) else None  # None : message cité supprimé
 
-        content = cited.clean_content.strip()
-        if len(content) > QUOTE_MAX_LENGTH:
-            content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
+    async def replies_to_me(self, message: discord.Message) -> bool:
+        cited = await self.fetch_cited(message)
+        return cited is not None and cited.author == self.user
 
-        image = None
-        images = [a for a in cited.attachments if looks_like_image(a)]
-        if want_image and images:
-            try:
-                image = await self.download_image(images[0])
-            except ImageError as exc:
-                logger.warning("Image du message cité ignorée : %s", exc.user_message)
+    async def read_reply(self, message: discord.Message, want_image: bool) -> tuple[str | None, ImageData | None]:
+        """Lit le message auquel l'utilisateur répond.
+
+        Renvoie sa description pour Claude, ainsi que son image si want_image est vrai,
+        ou (None, None) si le message ne répond à rien ou si le message cité est inaccessible.
+        """
+        cited = await self.fetch_cited(message)
+        if cited is None:
+            return None, None
+
+        image = await self.first_image(cited.attachments, "cité") if want_image else None
+        description = self.describe_quoted(cited.clean_content, image, cited.attachments, cited.stickers)
+
+        who = "à ton message" if cited.author == self.user else f"au message de {cited.author.display_name}"
+        return f"en réponse {who} du {self.timestamp(cited)} : {description or '(message vide)'}", image
+
+    async def read_forward(self, message: discord.Message) -> tuple[str | None, ImageData | None]:
+        """Lit le message transféré (bouton « Transférer » de Discord).
+
+        Renvoie sa description pour Claude et son image, ou (None, None) si le message
+        n'est pas un transfert ou si le message transféré ne contient rien de lisible.
+        """
+        if not message.message_snapshots:
+            return None, None
+        snapshot = message.message_snapshots[0]
+
+        image = await self.first_image(snapshot.attachments, "transféré")
+        # Contrairement à un message normal, le contenu transféré garde ses mentions brutes (<@123>).
+        content = self.clean_mentions(snapshot.content)
+        description = self.describe_quoted(content, image, snapshot.attachments, snapshot.stickers)
+        if not description:
+            return None, None
+        return f"du {self.timestamp(snapshot)} : {description}", image
 
+    async def first_image(self, attachments: list[discord.Attachment], kind: str) -> ImageData | None:
+        """Télécharge la première image d'un message cité ou transféré (None si aucune ou illisible)."""
+        images = [a for a in attachments if looks_like_image(a)]
+        if not images:
+            return None
+        try:
+            return await self.download_image(images[0])
+        except ImageError as exc:
+            logger.warning("Image du message %s ignorée : %s", kind, exc.user_message)
+            return None
+
+    @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()
+        if len(content) > QUOTE_MAX_LENGTH:
+            content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
         parts = [f"« {content} »"] if content else []
         if image is not None:
             parts.append("[son image est jointe à ce message]")
-        elif cited.attachments:
+        elif attachments:
             parts.append("[pièce jointe]")
-        if cited.stickers:
+        if stickers:
             parts.append("[sticker]")
-        description = " ".join(parts) or "(message vide)"
+        return " ".join(parts)
 
-        who = "à ton message" if cited.author == self.user else f"au message de {cited.author.display_name}"
-        return f"en réponse {who} du {self.timestamp(cited)} : {description}", image
+    def clean_mentions(self, text: str) -> str:
+        """Remplace les mentions brutes (<@123>) par des pseudos, comme le fait clean_content."""
+        def name(match: re.Match) -> str:
+            user = self.get_user(int(match.group(1)))
+            return f"@{user.display_name}" if user else "@quelqu'un"
+        return USER_MENTION.sub(name, text)
 
     # ------------------------------------------------------------- envoi
 
-    def timestamp(self, message: discord.Message) -> str:
+    def timestamp(self, message: discord.Message | discord.MessageSnapshot) -> str:
         return message.created_at.astimezone(self.settings.timezone).strftime("%d/%m/%Y %H:%M")
 
     async def send_answer(self, message: discord.Message, answer: str) -> None:

+ 25 - 0
bot/models.py

@@ -0,0 +1,25 @@
+"""Particularités de chaque modèle Claude : réglage de la réflexion et tarifs."""
+
+from __future__ import annotations
+
+# Façon de couper la réflexion préalable (THINKING=off), qui dépend du modèle.
+# None : impossible sur ce modèle, seul EFFORT permet alors de la réduire.
+_THINKING_OFF: dict[str, dict | None] = {
+    "claude-sonnet-5-5": {"type": "between_tools"},  # n'existe que sur Sonnet 5.5
+    "claude-opus-5-5": None,
+}
+# Haiku 5.5 et les modèles plus anciens (Sonnet 5, Opus 4.8…).
+_DEFAULT_THINKING_OFF = {"type": "disabled"}
+
+# Tarifs en dollars par million de tokens (entrée, sortie), pour l'estimation des coûts.
+# Haiku 5.5 : tarif des requêtes de moins de 100 000 tokens (0,50 / 2,50 au-delà).
+PRICES: dict[str, tuple[float, float]] = {
+    "claude-sonnet-5-5": (2.0, 10.0),
+    "claude-haiku-5-5": (0.10, 0.50),
+    "claude-opus-5-5": (4.0, 20.0),
+}
+
+
+def thinking_off(model: str) -> dict | None:
+    """Paramètre « thinking » à envoyer quand THINKING=off (None : ne rien envoyer)."""
+    return _THINKING_OFF.get(model, _DEFAULT_THINKING_OFF)

+ 25 - 0
changelog.txt

@@ -2,6 +2,31 @@ CHANGELOG — Chatbot Discord
 ===========================
 
 
+Version 5.4.0 — 29/10/2026
+--------------------------
+
+Nouveautés
+- Changement de modèle facile : il suffit de modifier CLAUDE_MODEL dans le
+  .env (claude-sonnet-5-5, claude-haiku-5-5 ou claude-opus-5-5) et de
+  redémarrer le bot.
+  - THINKING=off coupe la réflexion de la façon propre à chaque modèle
+    (auparavant, tout autre modèle que Sonnet 5.5 était refusé par l'API).
+  - Sur Opus 5.5, la réflexion ne peut pas être coupée : régler EFFORT=low.
+  - Les tarifs utilisés pour le coût estimé dans les logs suivent le modèle
+    choisi ; PRICE_INPUT_PER_MTOK et PRICE_OUTPUT_PER_MTOK ne sont plus à
+    renseigner que pour un autre modèle (à vider dans le .env existant).
+- Messages transférés : dans le salon dédié, le bot lit le message transféré
+  (son texte et son image).
+- Discussion entre bots : le bot répond aux autres bots quand ils le
+  mentionnent ou répondent à l'un de ses messages ; le reste de ce qu'ils
+  postent est toujours ignoré.
+
+Corrections
+- Les messages système (arrivée d'un membre, message épinglé, boost, fil
+  créé…) et les messages sans rien à lire (sondages…) ne déclenchent plus de
+  réponse payante de Claude.
+
+
 Version 5.3.0 — 29/10/2026
 --------------------------