Kaynağa Gözat

Version 5.1

Penta 6 gün önce
ebeveyn
işleme
027565678a
17 değiştirilmiş dosya ile 1156 ekleme ve 607 silme
  1. 0 13
      .env
  2. 39 0
      .env.exemple
  3. 5 160
      .gitignore
  4. 1 1
      Dockerfile
  5. 61 0
      README.md
  6. 3 0
      bot/__init__.py
  7. 162 0
      bot/claude_client.py
  8. 125 0
      bot/config.py
  9. 349 0
      bot/discord_bot.py
  10. 135 0
      bot/history.py
  11. 86 0
      bot/images.py
  12. 72 0
      bot/text_utils.py
  13. 49 0
      changelog.txt
  14. 0 418
      chatbot.py
  15. 53 0
      main.py
  16. 11 11
      personality_prompt.txt
  17. 5 4
      requirements.txt

+ 0 - 13
.env

@@ -1,13 +0,0 @@
-DISCORD_TOKEN=
-CHANNEL_ID=
-MISTRAL_API_KEY=
-
-MISTRAL_MODEL=
-TEMPERATURE=0.7
-
-MAX_HISTORY_LENGTH=20
-HISTORY_FILE=conversation_history.json
-
-CONTEXT_MESSAGE_LIMIT=20
-
-MAX_IMAGE_SIZE=10485760

+ 39 - 0
.env.exemple

@@ -0,0 +1,39 @@
+# Copie ce fichier en ".env" et remplis les valeurs.
+# Ne partage JAMAIS ton fichier .env (il contient tes clés secrètes).
+
+# --- Obligatoire ---------------------------------------------------------
+DISCORD_TOKEN=
+ANTHROPIC_API_KEY=
+# Salon où le bot répond à tous les messages (clic droit sur le salon > Copier l'identifiant)
+CHANNEL_ID=
+
+# --- Claude --------------------------------------------------------------
+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)
+THINKING=off
+# Niveau d'effort : low, medium ou high. Vide = valeur par défaut du modèle.
+EFFORT=
+# Délai maximal d'attente d'une réponse, en secondes
+API_TIMEOUT=60
+
+# --- Mémoire -------------------------------------------------------------
+# Nombre de messages gardés en mémoire dans le salon dédié (questions + réponses)
+MAX_HISTORY_LENGTH=10
+# Nombre de messages lus comme contexte quand on mentionne le bot ailleurs
+CONTEXT_MESSAGE_LIMIT=20
+HISTORY_FILE=conversation_history.json
+PERSONALITY_FILE=personality_prompt.txt
+
+# --- Images --------------------------------------------------------------
+# Taille max en octets
+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
+LOG_FILE=bot.log

+ 5 - 160
.gitignore

@@ -1,162 +1,7 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-#  Usually these files are written by a python script from a template
-#  before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-#   For a library or package, you might want to ignore these files since the code is
-#   intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-#   However, in case of collaboration, if having platform-specific dependencies or dependencies
-#   having no cross-platform support, pipenv may install dependencies that don't work, or not
-#   install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-#   This is especially recommended for binary packages to ensure reproducibility, and is more
-#   commonly ignored for libraries.
-#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-#   in version control.
-#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
 .env
-.venv
-env/
+__pycache__/
+*.pyc
+.venv/
 venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-#  and can be added to the global gitignore or merged into this file.  For a more nuclear
-#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
+bot.log*
+conversation_history.json*

+ 1 - 1
Dockerfile

@@ -17,4 +17,4 @@ COPY . .
 RUN chown -R 0:0 /opt/chatbot && chmod -R g+rw /opt/chatbot
 
 # Spécifier la commande pour lancer l'application
-CMD ["python", "chatbot.py"]
+CMD ["python", "main.py"]

+ 61 - 0
README.md

@@ -0,0 +1,61 @@
+# Chatbot Discord — version 5.0 (Claude)
+
+Bot Discord qui discute avec les membres du serveur grâce à Claude (Anthropic).
+
+## Fonctionnalités
+
+- **Salon dédié** : répond à tous les messages du salon défini par `CHANNEL_ID`, avec une mémoire des derniers échanges, sauvegardée entre deux redémarrages.
+- **Mode mention** : dans les autres salons, répond quand on le mentionne, en lisant les messages récents pour comprendre la discussion.
+- **Images** : commente une image envoyée dans le salon dédié (JPEG, PNG, GIF, WebP, une par message).
+- **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).
+- **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.
+- **Logs** dans `bot.log`, avec le coût estimé de chaque appel.
+
+## Installation
+
+Il faut **Python 3.10 ou plus récent**.
+
+1. Ouvre un terminal dans ce dossier et crée un environnement virtuel :
+   ```
+   python -m venv .venv
+   ```
+   Puis active-le : `.venv\Scripts\activate` sous Windows, `source .venv/bin/activate` sous macOS et Linux.
+
+2. Installe les dépendances :
+   ```
+   pip install -r requirements.txt
+   ```
+
+3. Copie `.env.example` en `.env` et remplis au minimum `DISCORD_TOKEN`, `ANTHROPIC_API_KEY` et `CHANNEL_ID`.
+   La clé Anthropic se crée sur la console : https://platform.claude.com (rubrique API Keys). L'API est facturée à l'usage, séparément de l'abonnement Claude Pro.
+
+4. Dans le portail développeur Discord (https://discord.com/developers/applications), onglet **Bot**, vérifie que **Message Content Intent** est activé. L'intent « Presence » n'est plus nécessaire, tu peux le désactiver.
+
+5. Lance le bot :
+   ```
+   python main.py
+   ```
+
+## Permissions Discord nécessaires
+
+Voir les salons, Envoyer des messages, Lire l'historique des messages. Rien d'autre n'est nécessaire : le bot utilise uniquement les emojis et stickers du serveur.
+
+## Migration depuis la version 4 (Mistral)
+
+- Garde ton ancien `personality_prompt.txt` : il fonctionne tel quel.
+- Ton ancien `conversation_history.json` est converti automatiquement au premier lancement (les images sont remplacées par « [image jointe] »).
+- Dans le `.env`, remplace `MISTRAL_API_KEY` par `ANTHROPIC_API_KEY` et `MISTRAL_MODEL` par `CLAUDE_MODEL`.
+- `MAX_IMAGE_SIZE` doit être abaissé à 3750000 (limite de l'API d'Anthropic).
+
+## Organisation du code
+
+```
+main.py               lancement et logs
+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/text_utils.py     découpage des longues réponses
+```

+ 3 - 0
bot/__init__.py

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

+ 162 - 0
bot/claude_client.py

@@ -0,0 +1,162 @@
+"""Communication avec l'API de Claude (Anthropic)."""
+
+from __future__ import annotations
+
+import base64
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+
+import anthropic
+
+from .config import Settings
+
+logger = logging.getLogger("chatbot.claude")
+
+SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
+
+DEFAULT_PERSONALITY = (
+    "Tu es un membre sympathique d'un serveur Discord francophone. "
+    "Tu réponds toujours en français, avec un ton naturel, amical et décontracté, "
+    "et des réponses plutôt courtes, adaptées à une discussion de chat. "
+    "Chaque message que tu reçois indique la date, l'heure et le pseudo de son auteur ; "
+    "plusieurs personnes peuvent parler dans la même conversation. "
+    "Quand on te montre une image, décris ce que tu vois puis réponds à la question s'il y en a une."
+)
+
+
+# Ajouté automatiquement à la fin de la personnalité, quel que soit le contenu du fichier.
+MEMORY_NOTE = (
+    "\n\nNote technique sur ta mémoire : les images ne sont pas conservées d'un message à l'autre. "
+    "Dans les messages précédents, la mention « [image jointe : …] » signifie qu'une image a bien été envoyée "
+    "et que tu l'as réellement vue et analysée à ce moment-là ; ta réponse d'alors en décrit le contenu. "
+    "Ne dis donc jamais que tu ne l'as pas vue, et ne t'excuse pas à ce sujet. "
+    "Si on te demande un détail de cette image que ta réponse précédente ne mentionnait pas, "
+    "explique simplement que tu ne gardes pas les images en mémoire et propose qu'on te la renvoie."
+)
+
+
+class ClaudeError(Exception):
+    """Erreur lors de l'appel à Claude, avec un message affichable sur Discord."""
+
+    def __init__(self, user_message: str):
+        super().__init__(user_message)
+        self.user_message = user_message
+
+
+@dataclass
+class ImageData:
+    media_type: str
+    data: bytes
+
+
+class ClaudeClient:
+    def __init__(self, settings: Settings):
+        self.settings = settings
+        # Le SDK réessaie tout seul les erreurs temporaires (surcharge, limite de requêtes…).
+        self._client = anthropic.AsyncAnthropic(
+            api_key=settings.anthropic_api_key,
+            timeout=settings.api_timeout,
+            max_retries=3,
+        )
+        self._personality_cache: tuple[float, str] | None = None
+
+    async def close(self) -> None:
+        await self._client.close()
+
+    # ------------------------------------------------------------ personnalité
+
+    def personality(self) -> str:
+        """Lit personality_prompt.txt (relu automatiquement s'il a été modifié)."""
+        path: Path = self.settings.personality_file
+        try:
+            mtime = path.stat().st_mtime
+            if self._personality_cache is None or self._personality_cache[0] != mtime:
+                text = path.read_text(encoding="utf-8").strip()
+                self._personality_cache = (mtime, text or DEFAULT_PERSONALITY)
+                logger.info("Personnalité chargée depuis %s", path)
+            return self._personality_cache[1]
+        except FileNotFoundError:
+            if self._personality_cache != (0.0, DEFAULT_PERSONALITY):
+                logger.warning("%s introuvable : utilisation de la personnalité par défaut.", path)
+                self._personality_cache = (0.0, DEFAULT_PERSONALITY)
+            return DEFAULT_PERSONALITY
+
+    # ------------------------------------------------------------------ appel
+
+    @staticmethod
+    def build_user_content(text: str, image: ImageData | None = None) -> str | list[dict]:
+        if image is None:
+            return text
+        return [
+            {
+                "type": "image",
+                "source": {
+                    "type": "base64",
+                    "media_type": image.media_type,
+                    "data": base64.standard_b64encode(image.data).decode("ascii"),
+                },
+            },
+            {"type": "text", "text": text},
+        ]
+
+    async def ask(self, history: list[dict], user_text: str, image: ImageData | None = None) -> str:
+        """Envoie l'historique + le nouveau message, et renvoie la réponse de Claude."""
+        messages = list(history) + [{"role": "user", "content": self.build_user_content(user_text, image)}]
+
+        params: dict = {
+            "model": self.settings.model,
+            "max_tokens": self.settings.max_tokens,
+            "system": self.personality() + MEMORY_NOTE,
+            "messages": messages,
+        }
+        # 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
+        # (le bot n'utilisant pas d'outils, il n'y a alors aucune réflexion).
+        params["thinking"] = {"type": "adaptive"} if self.settings.thinking else {"type": "between_tools"}
+        if self.settings.effort:
+            params["extra_body"] = {"output_config": {"effort": self.settings.effort}}
+
+        try:
+            response = await self._client.messages.create(**params)
+        except anthropic.RateLimitError:
+            logger.warning("Limite de requêtes Anthropic atteinte.")
+            raise ClaudeError("Je reçois trop de messages en ce moment, réessaie dans une minute.")
+        except anthropic.APITimeoutError:
+            logger.warning("Délai dépassé lors de l'appel à Claude.")
+            raise ClaudeError("Je mets trop de temps à répondre, réessaie dans un instant.")
+        except anthropic.APIConnectionError as exc:
+            logger.error("Connexion à l'API impossible : %s", exc)
+            raise ClaudeError("Je n'arrive pas à joindre mon cerveau pour l'instant, réessaie plus tard.")
+        except anthropic.BadRequestError as exc:
+            logger.error("Requête refusée par l'API : %s", exc)
+            if image is not None:
+                raise ClaudeError("Je n'ai pas réussi à lire cette image, essaie avec une autre.")
+            raise ClaudeError("Ta demande a été refusée par l'API, essaie de la reformuler.")
+        except anthropic.AuthenticationError:
+            logger.critical("Clé API Anthropic invalide : vérifie ANTHROPIC_API_KEY dans le .env.")
+            raise ClaudeError("Je suis mal configuré, préviens l'administrateur du bot.")
+        except anthropic.APIStatusError as exc:
+            logger.error("Erreur de l'API (%s) : %s", exc.status_code, exc)
+            raise ClaudeError("Une erreur est survenue de mon côté, réessaie dans un instant.")
+
+        text = "".join(block.text for block in response.content if block.type == "text").strip()
+        self._log_usage(response)
+
+        if response.stop_reason == "max_tokens":
+            logger.warning("Réponse coupée par MAX_TOKENS (%s) : augmente MAX_TOKENS dans le .env "
+                           "si cela arrive souvent.", self.settings.max_tokens)
+            text += " […]"
+        if not text:
+            logger.warning("Réponse vide de Claude (stop_reason=%s).", response.stop_reason)
+            raise ClaudeError("Je n'ai rien trouvé à répondre à ça.")
+        return text
+
+    def _log_usage(self, response) -> None:
+        usage = response.usage
+        cost = (usage.input_tokens * self.settings.price_input_per_mtok
+                + usage.output_tokens * self.settings.price_output_per_mtok) / 1_000_000
+        logger.info(
+            "Appel Claude (%s) — entrée : %s tokens, sortie : %s tokens, coût estimé : %.5f $",
+            response.model, usage.input_tokens, usage.output_tokens, cost,
+        )

+ 125 - 0
bot/config.py

@@ -0,0 +1,125 @@
+"""Chargement et validation de la configuration depuis le fichier .env."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from dotenv import load_dotenv
+
+
+EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
+
+
+class ConfigError(Exception):
+    """Erreur de configuration (variable manquante ou invalide)."""
+
+
+def _get_str(name: str, default: str | None = None, required: bool = False) -> str | None:
+    value = os.getenv(name)
+    if value is None or value.strip() == "":
+        if required:
+            raise ConfigError(f"La variable {name} est obligatoire mais absente du fichier .env.")
+        return default
+    return value.strip()
+
+
+def _get_int(name: str, default: int | None = None, required: bool = False, minimum: int | None = None) -> int | None:
+    raw = _get_str(name, required=required)
+    if raw is None:
+        return default
+    try:
+        value = int(raw)
+    except ValueError:
+        raise ConfigError(f"La variable {name} doit être un nombre entier (valeur actuelle : {raw!r}).")
+    if minimum is not None and value < minimum:
+        raise ConfigError(f"La variable {name} doit être supérieure ou égale à {minimum} (valeur actuelle : {value}).")
+    return value
+
+
+def _get_float(name: str, default: float | None = None) -> float | None:
+    raw = _get_str(name)
+    if raw is None:
+        return default
+    try:
+        return float(raw.replace(",", "."))
+    except ValueError:
+        raise ConfigError(f"La variable {name} doit être un nombre (valeur actuelle : {raw!r}).")
+
+
+@dataclass(frozen=True)
+class Settings:
+    # Identifiants
+    discord_token: str
+    anthropic_api_key: str
+    channel_id: int
+
+    # Claude
+    model: str
+    max_tokens: int
+    thinking: bool          # réflexion préalable avant de répondre (plus lent et plus cher)
+    effort: str | None      # None = valeur par défaut du modèle
+    api_timeout: float
+
+    # Mémoire et contexte
+    max_history_length: int
+    context_message_limit: int
+    history_file: Path
+    personality_file: Path
+
+    # Images
+    max_image_size: int
+
+    # Divers
+    timezone: ZoneInfo
+    price_input_per_mtok: float
+    price_output_per_mtok: float
+    log_file: Path
+
+
+def load_settings() -> Settings:
+    """Lit le fichier .env et renvoie une configuration validée."""
+    # override=True : le fichier .env est prioritaire sur les variables d'environnement de Windows.
+    load_dotenv(override=True)
+
+    tz_name = _get_str("TIMEZONE", default="Europe/Paris")
+    try:
+        timezone = ZoneInfo(tz_name)
+    except ZoneInfoNotFoundError:
+        raise ConfigError(f"Fuseau horaire inconnu dans TIMEZONE : {tz_name!r} (exemple valide : Europe/Paris).")
+
+    thinking_raw = (_get_str("THINKING", default="off") or "off").lower()
+    if thinking_raw not in ("on", "off"):
+        raise ConfigError(f"THINKING doit valoir « on » ou « off » (valeur actuelle : {thinking_raw!r}).")
+    thinking = thinking_raw == "on"
+
+    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.")
+
+    return 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"),
+        max_tokens=_get_int("MAX_TOKENS", default=1500, minimum=1),
+        thinking=thinking,
+        effort=effort,
+        api_timeout=_get_float("API_TIMEOUT", default=60.0),
+        max_history_length=_get_int("MAX_HISTORY_LENGTH", default=10, minimum=2),
+        context_message_limit=_get_int("CONTEXT_MESSAGE_LIMIT", default=20, minimum=0),
+        history_file=Path(_get_str("HISTORY_FILE", default="conversation_history.json")),
+        personality_file=Path(_get_str("PERSONALITY_FILE", default="personality_prompt.txt")),
+        # 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),
+        log_file=Path(_get_str("LOG_FILE", default="bot.log")),
+    )

+ 349 - 0
bot/discord_bot.py

@@ -0,0 +1,349 @@
+"""Le bot Discord : événements, commandes et réactions."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+import re
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from . import VERSION
+from .claude_client import ClaudeClient, ClaudeError, ImageData
+from .config import Settings
+from .history import IMAGE_MARKER, ConversationHistory
+from .images import ImageError, prepare_image
+from .text_utils import split_message
+
+logger = logging.getLogger("chatbot.discord")
+
+CUSTOM_EMOJI_ONLY = re.compile(r"^<a?:\w+:\d+>$")
+IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
+# Longueur maximale du texte d'un message cité transmis à Claude.
+QUOTE_MAX_LENGTH = 500
+
+
+def looks_like_image(attachment: discord.Attachment) -> bool:
+    return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
+
+
+class ChatBot(commands.Bot):
+    def __init__(self, settings: Settings):
+        intents = discord.Intents.default()
+        intents.message_content = True  # nécessaire pour lire le texte des messages
+        super().__init__(
+            command_prefix=commands.when_mentioned,  # aucune commande textuelle utilisée
+            intents=intents,
+            help_command=None,
+            # Le bot ne peut jamais pinger @everyone, @here, des rôles ou des membres.
+            allowed_mentions=discord.AllowedMentions.none(),
+        )
+        self.settings = settings
+        self.claude = ClaudeClient(settings)
+        self.history = ConversationHistory(settings.history_file, settings.max_history_length)
+        self._announced = False
+
+    # ------------------------------------------------------------ cycle de vie
+
+    async def setup_hook(self) -> None:
+        # Exécuté une seule fois au démarrage (contrairement à on_ready).
+        self.tree.add_command(reset_command)
+        synced = await self.tree.sync()
+        logger.info("%d commande(s) slash synchronisée(s).", len(synced))
+
+    async def on_ready(self) -> None:
+        logger.info("Connecté en tant que %s (version %s)", self.user, VERSION)
+        if self._announced:
+            return  # simple reconnexion : on ne reposte pas le message de démarrage
+        self._announced = True
+
+        channel = self.get_channel(self.settings.channel_id)
+        if channel is None:
+            logger.error("Salon %s introuvable : vérifie CHANNEL_ID et les permissions du bot.",
+                         self.settings.channel_id)
+            return
+        name = channel.guild.me.display_name if getattr(channel, "guild", None) else self.user.name
+        embed = discord.Embed(
+            title="Bot en ligne",
+            description=f"{name} est désormais en ligne. Version {VERSION}.",
+            color=discord.Color.green(),
+        )
+        try:
+            await channel.send(embed=embed)
+        except discord.HTTPException as exc:
+            logger.error("Impossible d'envoyer le message de démarrage : %s", exc)
+
+    async def close(self) -> None:
+        await self.claude.close()
+        await super().close()
+
+    # ------------------------------------------------------------- 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:
+            return
+
+        in_main_channel = message.channel.id == self.settings.channel_id
+        # Mention directe uniquement (@everyone et @here ne comptent pas).
+        mentioned = self.user is not None and self.user in message.mentions
+
+        if in_main_channel:
+            await self.handle_main_channel(message)
+        elif mentioned:
+            await self.handle_mention(message)
+
+    async def handle_main_channel(self, message: discord.Message) -> None:
+        if await self.reply_to_sticker(message):
+            return
+        if await self.reply_to_emoji(message):
+            return
+
+        image = await self.read_image(message)
+        if image is False:
+            return  # pièce jointe refusée, l'utilisateur a été prévenu
+
+        # 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}"
+        stored_text = user_text + (f" {IMAGE_MARKER}" if image else "")
+
+        # Un seul appel à la fois par salon : l'historique reste cohérent.
+        async with self.history.lock(message.channel.id):
+            async with message.channel.typing():
+                try:
+                    answer = await self.claude.ask(self.history.get(message.channel.id), user_text, image or None)
+                except ClaudeError as exc:
+                    await self.safe_reply(message, exc.user_message)
+                    return
+                except Exception:
+                    logger.exception("Erreur inattendue pendant le traitement d'un message")
+                    await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
+                    return
+            await self.history.add_exchange(message.channel.id, stored_text, answer)
+        await self.send_answer(message, answer)
+
+    async def handle_mention(self, message: discord.Message) -> None:
+        """Hors du salon dédié : répond à une mention en lisant les messages récents."""
+        lines: list[str] = []
+        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()
+                    if msg.attachments:
+                        content = (content + " [pièce jointe]").strip()
+                    if msg.stickers:
+                        content = (content + " [sticker]").strip()
+                    if content:
+                        lines.append(f"{msg.author.display_name} : {content}")
+            except discord.Forbidden:
+                logger.warning("Pas la permission de lire l'historique de #%s.", message.channel)
+        lines.reverse()
+
+        reply, image = await self.read_reply(message, want_image=True)
+
+        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)"
+        channel_name = f"#{message.channel.name}" if hasattr(message.channel, "name") else "un message privé"
+
+        parts = []
+        if lines:
+            parts.append(f"Contexte : derniers messages dans {channel_name}\n" + "\n".join(lines))
+        reply_part = f" ({reply})" if reply else ""
+        parts.append(f"{self.timestamp(message)}, {message.author.display_name} te mentionne{reply_part} et écrit : {text}")
+        prompt = "\n\n".join(parts)
+
+        async with message.channel.typing():
+            try:
+                answer = await self.claude.ask([], prompt, image)
+            except ClaudeError as exc:
+                await self.safe_reply(message, exc.user_message)
+                return
+            except Exception:
+                logger.exception("Erreur inattendue pendant le traitement d'une mention")
+                await self.safe_reply(message, "Désolé, une erreur inattendue est survenue.")
+                return
+        await self.send_answer(message, answer)
+
+    # ------------------------------------------------------------- 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:
+            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:
+            try:
+                await message.channel.send(stickers=[sticker])
+                logger.info("Sticker envoyé : %s", sticker.name)
+                return True
+            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
+
+    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."""
+        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
+        try:
+            await message.channel.send(str(random.choice(emojis)))
+        except discord.HTTPException as exc:
+            logger.warning("Impossible d'envoyer l'emoji : %s", exc)
+        return True
+
+    # ---------------------------------------------------------------- images
+
+    async def read_image(self, message: discord.Message) -> ImageData | None | bool:
+        """Vérifie les pièces jointes et télécharge l'image.
+
+        Renvoie l'image, None s'il n'y en a pas, ou False si l'envoi est refusé
+        (l'utilisateur est alors prévenu).
+        """
+        if not message.attachments:
+            return None
+
+        others = [a.filename for a in message.attachments if not looks_like_image(a)]
+        if others:
+            await self.safe_reply(message, f"Je ne peux lire que des images. Fichier(s) non pris en charge : {', '.join(others)}.")
+            return False
+        if len(message.attachments) > 1:
+            await self.safe_reply(message, "Une seule image par message, s'il te plaît.")
+            return False
+
+        try:
+            return await self.download_image(message.attachments[0])
+        except ImageError as exc:
+            await self.safe_reply(message, exc.user_message)
+            return False
+
+    async def download_image(self, attachment: discord.Attachment) -> ImageData:
+        """Télécharge et prépare une image (lève ImageError en cas de problème)."""
+        if attachment.size > self.settings.max_image_size:
+            max_mb = self.settings.max_image_size / 1_000_000
+            raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
+
+        try:
+            data = await attachment.read()
+        except discord.HTTPException as exc:
+            logger.error("Téléchargement de l'image impossible : %s", exc)
+            raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
+
+        # 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é
+
+    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.
+        """
+        ref = message.reference
+        if ref is None or ref.message_id is None or ref.type != discord.MessageReferenceType.reply:
+            return None, None
+
+        cited = ref.resolved
+        if cited is None:  # Discord ne l'a pas fourni (vieux message) : on le télécharge
+            try:
+                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é
+
+        content = cited.clean_content.strip()
+        if len(content) > QUOTE_MAX_LENGTH:
+            content = content[:QUOTE_MAX_LENGTH].rstrip() + "…"
+
+        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)
+
+        parts = [f"« {content} »"] if content else []
+        if image is not None:
+            parts.append("[son image est jointe à ce message]")
+        elif cited.attachments:
+            parts.append("[pièce jointe]")
+        if cited.stickers:
+            parts.append("[sticker]")
+        description = " ".join(parts) or "(message vide)"
+
+        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
+
+    # ------------------------------------------------------------- envoi
+
+    def timestamp(self, message: discord.Message) -> 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:
+        chunks = split_message(answer)
+        for i, chunk in enumerate(chunks):
+            try:
+                if i == 0:
+                    await message.reply(chunk, mention_author=False)
+                else:
+                    await message.channel.send(chunk)
+            except discord.HTTPException as exc:
+                # Le message d'origine a pu être supprimé entre-temps : on envoie sans répondre.
+                if i == 0:
+                    try:
+                        await message.channel.send(chunk)
+                        continue
+                    except discord.HTTPException:
+                        pass
+                logger.error("Envoi de la réponse impossible : %s", exc)
+                return
+
+    async def safe_reply(self, message: discord.Message, text: str) -> None:
+        try:
+            await message.reply(text, mention_author=False)
+        except discord.HTTPException:
+            try:
+                await message.channel.send(text)
+            except discord.HTTPException as exc:
+                logger.error("Impossible d'envoyer un message : %s", exc)
+
+
+# ------------------------------------------------------------- commandes slash
+
+@app_commands.command(name="reset", description="Réinitialise la mémoire du bot dans ce salon")
+@app_commands.guild_only()
+# Masque la commande aux membres sans « Gérer les messages » (modérateurs) ;
+# les administrateurs ont implicitement toutes les permissions.
+@app_commands.default_permissions(manage_messages=True)
+async def reset_command(interaction: discord.Interaction) -> None:
+    # Vérification côté bot, au cas où un admin aurait ouvert la commande à tous dans les paramètres du serveur.
+    perms = interaction.permissions
+    if not (perms.manage_messages or perms.administrator):
+        logger.info("Tentative de /reset refusée pour %s dans le salon %s", interaction.user, interaction.channel_id)
+        await interaction.response.send_message(
+            "Seuls les modérateurs et administrateurs du serveur peuvent utiliser cette commande.",
+            ephemeral=True,
+        )
+        return
+    bot: ChatBot = interaction.client  # type: ignore[assignment]
+    await bot.history.reset(interaction.channel_id)
+    logger.info("Historique réinitialisé dans le salon %s par %s", interaction.channel_id, interaction.user)
+    await interaction.response.send_message("L'historique de conversation a été réinitialisé.")

+ 135 - 0
bot/history.py

@@ -0,0 +1,135 @@
+"""Mémoire des conversations par salon, sauvegardée dans un fichier JSON."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+from pathlib import Path
+
+logger = logging.getLogger("chatbot.history")
+
+FILE_VERSION = 2
+
+# Remplace une image dans la mémoire (les images elles-mêmes ne sont pas conservées).
+IMAGE_MARKER = "[image jointe : tu l'as vue et analysée à ce moment-là, mais elle n'est plus conservée en mémoire]"
+_OLD_IMAGE_MARKER = "[image jointe]"
+
+
+class ConversationHistory:
+    """Garde les derniers échanges de chaque salon.
+
+    Les messages sont stockés au format de l'API d'Anthropic, en texte uniquement :
+    une image est remplacée par IMAGE_MARKER, ce qui évite de conserver des liens
+    Discord qui expirent et de repayer l'image à chaque message.
+    """
+
+    def __init__(self, file_path: Path, max_length: int):
+        self.file_path = file_path
+        self.max_length = max_length
+        self._data: dict[str, list[dict]] = self._load()
+        self._locks: dict[str, asyncio.Lock] = {}
+        self._save_lock = asyncio.Lock()
+
+    # ------------------------------------------------------------------ accès
+
+    def lock(self, channel_id: int | str) -> asyncio.Lock:
+        """Verrou par salon : les messages d'un même salon sont traités un par un."""
+        key = str(channel_id)
+        if key not in self._locks:
+            self._locks[key] = asyncio.Lock()
+        return self._locks[key]
+
+    def get(self, channel_id: int | str) -> list[dict]:
+        """Renvoie une copie des messages mémorisés pour ce salon."""
+        return [dict(m) for m in self._data.get(str(channel_id), [])]
+
+    async def add_exchange(self, channel_id: int | str, user_text: str, assistant_text: str) -> None:
+        """Ajoute un échange complet (question + réponse) puis sauvegarde."""
+        key = str(channel_id)
+        messages = self._data.setdefault(key, [])
+        messages.append({"role": "user", "content": user_text})
+        messages.append({"role": "assistant", "content": assistant_text})
+        self._data[key] = self._trim(messages)
+        await self._save()
+
+    async def reset(self, channel_id: int | str) -> None:
+        self._data[str(channel_id)] = []
+        await self._save()
+
+    # ------------------------------------------------------------ internes
+
+    def _trim(self, messages: list[dict]) -> list[dict]:
+        """Garde les N derniers messages, en commençant toujours par un message utilisateur."""
+        trimmed = messages[-self.max_length:]
+        while trimmed and trimmed[0]["role"] != "user":
+            trimmed = trimmed[1:]
+        return trimmed
+
+    def _load(self) -> dict[str, list[dict]]:
+        if not self.file_path.exists():
+            return {}
+        try:
+            raw = json.loads(self.file_path.read_text(encoding="utf-8"))
+        except (json.JSONDecodeError, OSError) as exc:
+            backup = self.file_path.with_suffix(self.file_path.suffix + ".corrompu")
+            logger.error("Fichier d'historique illisible (%s). Copie de secours : %s", exc, backup)
+            try:
+                os.replace(self.file_path, backup)
+            except OSError:
+                pass
+            return {}
+
+        if isinstance(raw, dict) and raw.get("version") == FILE_VERSION:
+            channels = raw.get("channels", {})
+        else:
+            logger.info("Ancien format d'historique détecté : conversion automatique.")
+            channels = {cid: (val or {}).get("messages", []) for cid, val in (raw or {}).items()
+                        if isinstance(val, dict)}
+
+        return {str(cid): self._trim(self._sanitize(msgs)) for cid, msgs in channels.items()}
+
+    @staticmethod
+    def _sanitize(messages: list) -> list[dict]:
+        """Convertit des messages (éventuellement de l'ancien format Mistral) en texte simple,
+        et fusionne les messages consécutifs d'un même rôle."""
+        clean: list[dict] = []
+        for msg in messages:
+            if not isinstance(msg, dict) or msg.get("role") not in ("user", "assistant"):
+                continue
+            content = msg.get("content")
+            if isinstance(content, list):
+                parts = []
+                for block in content:
+                    if not isinstance(block, dict):
+                        continue
+                    if block.get("type") == "text":
+                        parts.append(block.get("text", ""))
+                    elif block.get("type") in ("image", "image_url"):
+                        parts.append(IMAGE_MARKER)
+                content = " ".join(p for p in parts if p)
+            if not isinstance(content, str) or not content.strip():
+                continue
+            if msg["role"] == "user" and content.endswith(_OLD_IMAGE_MARKER):
+                content = content[: -len(_OLD_IMAGE_MARKER)] + IMAGE_MARKER
+            if clean and clean[-1]["role"] == msg["role"]:
+                clean[-1]["content"] += "\n" + content
+            else:
+                clean.append({"role": msg["role"], "content": content})
+        return clean
+
+    async def _save(self) -> None:
+        payload = {"version": FILE_VERSION, "channels": self._data}
+        text = json.dumps(payload, ensure_ascii=False, indent=2)
+        async with self._save_lock:
+            try:
+                await asyncio.to_thread(self._write_atomic, text)
+            except OSError as exc:
+                logger.error("Impossible de sauvegarder l'historique : %s", exc)
+
+    def _write_atomic(self, text: str) -> None:
+        # Écrit dans un fichier temporaire puis le renomme : jamais de fichier à moitié écrit.
+        tmp = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
+        tmp.write_text(text, encoding="utf-8")
+        os.replace(tmp, self.file_path)

+ 86 - 0
bot/images.py

@@ -0,0 +1,86 @@
+"""Préparation des images avant l'envoi à Claude (redimensionnement et compression)."""
+
+from __future__ import annotations
+
+import io
+import logging
+
+from PIL import Image, ImageOps, UnidentifiedImageError
+
+from .claude_client import ImageData
+
+logger = logging.getLogger("chatbot.images")
+
+# Claude redimensionne lui-même toute image dont le grand côté dépasse ~1568 px :
+# envoyer plus grand ne lui apporte rien.
+MAX_DIMENSION = 1568
+# Limite de l'API : 5 Mo une fois encodé en base64, soit environ 3,75 Mo bruts.
+API_MAX_BYTES = 3_750_000
+# Protection contre les images piégées (des milliards de pixels qui saturent la mémoire).
+Image.MAX_IMAGE_PIXELS = 80_000_000
+
+_DIRECT_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}
+
+
+class ImageError(Exception):
+    """Image illisible ou impossible à préparer, avec un message affichable sur Discord."""
+
+    def __init__(self, user_message: str):
+        super().__init__(user_message)
+        self.user_message = user_message
+
+
+def prepare_image(data: bytes) -> ImageData:
+    """Renvoie une image acceptée par l'API, en la réduisant ou la convertissant si besoin.
+
+    Fonction bloquante (calculs sur l'image) : à appeler via asyncio.to_thread.
+    """
+    try:
+        img = Image.open(io.BytesIO(data))
+        img.load()
+    except Image.DecompressionBombError:
+        raise ImageError("Cette image est beaucoup trop grande (en pixels) pour que je puisse la lire.")
+    except (UnidentifiedImageError, OSError, ValueError):
+        raise ImageError("Je n'arrive pas à lire cette image. Formats conseillés : JPEG, PNG, GIF ou WebP.")
+
+    fmt = (img.format or "").upper()
+    if fmt in _DIRECT_FORMATS and len(data) <= API_MAX_BYTES and max(img.size) <= MAX_DIMENSION:
+        return ImageData(media_type=_DIRECT_FORMATS[fmt], data=data)  # déjà parfaite : envoyée telle quelle
+
+    original_size, original_dims = len(data), img.size
+    img.seek(0)                      # GIF ou WebP animé : première image uniquement
+    img = ImageOps.exif_transpose(img)  # respecte l'orientation des photos de téléphone
+    img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.Resampling.LANCZOS)
+
+    has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
+    result: ImageData | None = None
+
+    if has_alpha:  # garde la transparence en PNG si la taille le permet
+        png = _encode(img.convert("RGBA"), "PNG", optimize=True)
+        if len(png) <= API_MAX_BYTES:
+            result = ImageData("image/png", png)
+        else:  # sinon fond blanc + JPEG
+            background = Image.new("RGB", img.size, "white")
+            background.paste(img.convert("RGBA"), mask=img.convert("RGBA").getchannel("A"))
+            img = background
+
+    if result is None:
+        rgb = img.convert("RGB")
+        for quality in (85, 75, 60, 45):
+            jpeg = _encode(rgb, "JPEG", quality=quality, optimize=True)
+            if len(jpeg) <= API_MAX_BYTES:
+                result = ImageData("image/jpeg", jpeg)
+                break
+        else:
+            raise ImageError("Je n'ai pas réussi à compresser suffisamment cette image.")
+
+    logger.info("Image adaptée : %s %dx%d (%.1f Mo) → %s %dx%d (%.1f Mo)",
+                fmt or "?", *original_dims, original_size / 1e6,
+                result.media_type, *img.size, len(result.data) / 1e6)
+    return result
+
+
+def _encode(img: Image.Image, fmt: str, **options) -> bytes:
+    buffer = io.BytesIO()
+    img.save(buffer, format=fmt, **options)
+    return buffer.getvalue()

+ 72 - 0
bot/text_utils.py

@@ -0,0 +1,72 @@
+"""Outils de mise en forme du texte pour Discord."""
+
+from __future__ import annotations
+
+import re
+
+DISCORD_LIMIT = 2000
+
+_FENCE_RE = re.compile(r"^\s*```(\S*)")
+
+
+def split_message(text: str, limit: int = DISCORD_LIMIT) -> list[str]:
+    """Découpe un long texte en morceaux de `limit` caractères maximum.
+
+    - coupe de préférence entre les lignes, puis entre les mots ;
+    - si un bloc de code (```) est coupé, il est refermé à la fin du morceau
+      et rouvert (avec le même langage) au début du suivant.
+    """
+    text = text.strip()
+    if not text:
+        return []
+    if len(text) <= limit:
+        return [text]
+
+    chunks: list[str] = []
+    current = ""
+    open_fence: str | None = None  # ligne d'ouverture du bloc de code en cours, ex. "```python"
+
+    def closing_len() -> int:
+        return len("\n```") if open_fence is not None else 0
+
+    def flush() -> None:
+        nonlocal current
+        body = current.rstrip("\n")
+        if open_fence is not None:
+            body += "\n```"
+        if body.strip():
+            chunks.append(body)
+        current = (open_fence + "\n") if open_fence is not None else ""
+
+    for line in text.split("\n"):
+        # Une ligne trop longue à elle seule est découpée en morceaux plus petits.
+        for piece in _split_long_line(line, limit - 10 - (len(open_fence) if open_fence else 0)):
+            addition = piece + "\n"
+            if len(current) + len(addition) + closing_len() > limit:
+                flush()
+            current += addition
+
+        match = _FENCE_RE.match(line)
+        if match:
+            open_fence = None if open_fence is not None else line.strip()
+
+    # Dernier morceau : pas de bloc à refermer artificiellement s'il est déjà fermé.
+    body = current.rstrip("\n")
+    if body.strip() and body.strip() != (open_fence or ""):
+        chunks.append(body)
+    return chunks
+
+
+def _split_long_line(line: str, limit: int) -> list[str]:
+    if len(line) <= limit:
+        return [line]
+    pieces: list[str] = []
+    while len(line) > limit:
+        cut = line.rfind(" ", 0, limit)
+        if cut <= limit // 2:  # pas d'espace raisonnable : coupe nette
+            cut = limit
+        pieces.append(line[:cut].rstrip())
+        line = line[cut:].lstrip()
+    if line:
+        pieces.append(line)
+    return pieces

+ 49 - 0
changelog.txt

@@ -0,0 +1,49 @@
+CHANGELOG — Chatbot Discord
+===========================
+
+
+Version 5.1.0 — 27/10/2026
+--------------------------
+
+Nouveautés
+- Réponses aux messages : quand on utilise « Répondre » sur un message, même
+  ancien, le bot lit le message cité et en tient compte dans sa réponse.
+  - Si le message cité vient du bot, il sait qu'il s'agit de sa propre réponse.
+  - Le texte cité est limité à 500 caractères.
+  - Si le message cité contient une image, elle est aussi envoyée à Claude,
+    sauf si la réponse contient déjà sa propre image (celle-ci est prioritaire).
+  - Fonctionne dans le salon dédié et en mode mention.
+- En mode mention, le bot peut maintenant voir une image, celle du message cité.
+
+Améliorations
+- Le téléchargement et la préparation des images passent par une seule
+  fonction, partagée entre le salon dédié et les messages cités.
+- Une image citée illisible ou trop lourde est simplement ignorée (avertissement
+  dans bot.log) au lieu de bloquer la réponse.
+- La commande /reset est réservée aux modérateurs (permission « Gérer les
+  messages ») et aux administrateurs du serveur.
+  - Elle est masquée pour les autres membres et n'est plus disponible en
+    messages privés.
+  - Si un administrateur l'ouvre à tous dans les paramètres du serveur, le bot
+    refuse quand même la commande (réponse visible uniquement par l'auteur) et
+    note la tentative dans bot.log.
+
+Divers
+- discord.py 2.5 minimum est désormais requis (requirements.txt).
+- README : ajout de la fonctionnalité « Réponses ».
+- README : précision sur les droits nécessaires pour /reset.
+
+
+Version 5.0.0 — 27/10/2026
+--------------------------
+
+Nouveautés
+- Passage de Mistral à Claude (Anthropic) pour générer les réponses.
+- Réécriture complète du Bot.
+
+Migration depuis la version 4 (Mistral)
+- L'ancien conversation_history.json est converti automatiquement au premier
+  lancement (les images sont remplacées par un marqueur texte).
+- Dans le .env : MISTRAL_API_KEY devient ANTHROPIC_API_KEY, et MISTRAL_MODEL
+  devient CLAUDE_MODEL.
+- L'intent Discord « Presence » n'est plus nécessaire.

+ 0 - 418
chatbot.py

@@ -1,418 +0,0 @@
-import discord
-from discord.ext import commands
-import requests
-import json
-import os
-import random
-import re
-from dotenv import load_dotenv
-from datetime import datetime
-import logging
-from logging.handlers import RotatingFileHandler
-
-# Configuration du logger
-logger = logging.getLogger('discord_bot')
-logger.setLevel(logging.INFO)
-formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
-file_handler = RotatingFileHandler('bot.log', maxBytes=5*1024*1024, backupCount=2)
-file_handler.setFormatter(formatter)
-logger.addHandler(file_handler)
-console_handler = logging.StreamHandler()
-console_handler.setFormatter(formatter)
-logger.addHandler(console_handler)
-
-# Charger les variables d'environnement
-load_dotenv()
-
-# Version du bot
-VERSION = "4.6.8"
-
-def get_env_variable(var_name, is_critical=True, default=None, var_type=str):
-    value = os.getenv(var_name)
-    if value is None:
-        if is_critical:
-            logger.error(f"Variable d'environnement critique manquante: {var_name}")
-            if default is not None:
-                logger.warning(f"Utilisation de la valeur par défaut pour {var_name}")
-                return default
-            else:
-                raise ValueError(f"La variable d'environnement {var_name} est requise mais non définie.")
-        else:
-            logger.warning(f"Variable d'environnement non critique manquante: {var_name}. Utilisation de la valeur par défaut: {default}")
-            return default
-    if var_type == int:
-        try:
-            return int(value)
-        except ValueError:
-            logger.error(f"La variable d'environnement {var_name} doit être un entier. Valeur actuelle: {value}")
-            if default is not None:
-                return default
-            else:
-                raise ValueError(f"La variable d'environnement {var_name} doit être un entier.")
-    elif var_type == float:
-        try:
-            return float(value)
-        except ValueError:
-            logger.error(f"La variable d'environnement {var_name} doit être un nombre décimal. Valeur actuelle: {value}")
-            if default is not None:
-                return default
-            else:
-                raise ValueError(f"La variable d'environnement {var_name} doit être un nombre décimal.")
-    return value
-
-try:
-    MISTRAL_API_KEY = get_env_variable('MISTRAL_API_KEY')
-    DISCORD_TOKEN = get_env_variable('DISCORD_TOKEN')
-    CHANNEL_ID = get_env_variable('CHANNEL_ID', var_type=int)
-    MAX_HISTORY_LENGTH = get_env_variable('MAX_HISTORY_LENGTH', is_critical=False, default=10, var_type=int)
-    CONTEXT_MESSAGE_LIMIT = get_env_variable('CONTEXT_MESSAGE_LIMIT', is_critical=False, default=20, var_type=int)
-    MAX_IMAGE_SIZE = get_env_variable('MAX_IMAGE_SIZE', is_critical=False, default=5*1024*1024, var_type=int)
-    HISTORY_FILE = get_env_variable('HISTORY_FILE', is_critical=False, default="conversation_history.json")
-    MISTRAL_MODEL = get_env_variable('MISTRAL_MODEL', is_critical=False, default="mistral-medium-latest")
-    TEMPERATURE = get_env_variable('TEMPERATURE', is_critical=False, default=0.7, var_type=float)
-    logger.info("Toutes les variables d'environnement critiques ont été chargées avec succès.")
-except ValueError as e:
-    logger.error(f"Erreur lors du chargement des variables d'environnement: {e}")
-    exit(1)
-
-MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
-
-def split_message(message, max_length=2000):
-    return [message[i:i+max_length] for i in range(0, len(message), max_length)]
-
-class ConversationHistory:
-    def __init__(self, file_path, max_length):
-        self.file_path = file_path
-        self.max_length = max_length
-        self.history = self.load_history()
-
-    def load_history(self):
-        if os.path.exists(self.file_path):
-            try:
-                with open(self.file_path, 'r', encoding='utf-8') as f:
-                    data = json.load(f)
-                    for channel_id in data:
-                        if "messages" in data[channel_id]:
-                            if len(data[channel_id]["messages"]) > self.max_length:
-                                data[channel_id]["messages"] = data[channel_id]["messages"][-self.max_length:]
-                    return data
-            except json.JSONDecodeError:
-                logger.error("Erreur de lecture du fichier d'historique. Création d'un nouveau fichier.")
-                return {}
-        return {}
-
-    def save_history(self):
-        with open(self.file_path, 'w', encoding='utf-8') as f:
-            json.dump(self.history, f, ensure_ascii=False, indent=4)
-
-    def add_message(self, channel_id, message):
-        if channel_id not in self.history:
-            self.history[channel_id] = {"messages": []}
-        self.history[channel_id]["messages"].append(message)
-        if len(self.history[channel_id]["messages"]) > self.max_length:
-            self.history[channel_id]["messages"] = self.history[channel_id]["messages"][-self.max_length:]
-        self.save_history()
-
-    def get_history(self, channel_id):
-        if channel_id in self.history:
-            return self.history[channel_id]
-        else:
-            self.history[channel_id] = {"messages": []}
-            return self.history[channel_id]
-
-    def reset_history(self, channel_id):
-        if channel_id in self.history:
-            self.history[channel_id]["messages"] = []
-        else:
-            self.history[channel_id] = {"messages": []}
-        self.save_history()
-
-def get_personality_prompt():
-    try:
-        with open('personality_prompt.txt', 'r', encoding='utf-8') as file:
-            return file.read().strip()
-    except FileNotFoundError:
-        logger.error("Le fichier personality_prompt.txt n'a pas été trouvé. Utilisation d'un prompt par défaut.")
-        return """Tu es un assistant utile et poli qui peut analyser des images.
-        Quand on te montre une image, décris-la et donne ton avis si on te le demande.
-        Réponds toujours en français avec un ton naturel et amical.
-        Lorsque tu analyses une image, décris d'abord ce que tu vois en détail,
-        puis réponds à la question si elle est posée. Utilise un langage clair et accessible."""
-
-history_manager = ConversationHistory(HISTORY_FILE, MAX_HISTORY_LENGTH)
-
-def call_mistral_api(prompt, history, image_url=None, user_id=None, username=None):
-    headers = {
-        "Content-Type": "application/json",
-        "Authorization": f"Bearer {MISTRAL_API_KEY}"
-    }
-    personality_prompt = get_personality_prompt()
-    current_time = datetime.now().strftime("%d-%m-%y %H:%M")
-    if image_url:
-        user_content = [
-            {"type": "text", "text": f"{current_time}, {username} a écrit : {prompt}" if username else f"{current_time}, a écrit : {prompt}"},
-            {
-                "type": "image_url",
-                "image_url": {
-                    "url": image_url,
-                    "detail": "high"
-                }
-            }
-        ]
-        user_message = {"role": "user", "content": user_content}
-    else:
-        user_content = [{"type": "text", "text": f"{current_time}, {username} a écrit : {prompt}" if username else f"{current_time}, a écrit : {prompt}"}]
-        user_message = {"role": "user", "content": user_content}
-    # Crée une copie de l'historique pour construire le contexte sans modifier l'original
-    messages = [{"role": "system", "content": personality_prompt}]
-    for msg in history["messages"]:
-        messages.append({
-            "role": msg["role"],
-            "content": msg["content"] if isinstance(msg["content"], list) else msg["content"]
-        })
-    messages.append(user_message)  # Ajoute le nouveau message au contexte pour l'API
-    data = {
-        "model": MISTRAL_MODEL,
-        "messages": messages,
-        "max_tokens": 128000,
-        "temperature": TEMPERATURE
-    }
-    try:
-        response = requests.post(MISTRAL_API_URL, headers=headers, data=json.dumps(data))
-        response.raise_for_status()
-        if response.status_code == 200:
-            response_data = response.json()
-            if 'choices' in response_data and len(response_data['choices']) > 0:
-                assistant_response = response_data['choices'][0]['message']['content']
-                # Mets à jour l'historique uniquement en cas de succès
-                history["messages"].append(user_message)
-                history["messages"].append({"role": "assistant", "content": assistant_response})
-                if 'usage' in response_data:
-                    prompt_tokens = response_data['usage']['prompt_tokens']
-                    completion_tokens = response_data['usage']['completion_tokens']
-                    input_cost = (prompt_tokens / 1_000_000) * 0.4
-                    output_cost = (completion_tokens / 1_000_000) * 2
-                    total_cost = input_cost + output_cost
-                    logger.info(f"Mistral API Call - Input Tokens: {prompt_tokens}, Output Tokens: {completion_tokens}, Cost: ${total_cost:.6f}")
-                else:
-                    logger.warning("La réponse de l'API ne contient pas d'informations sur les tokens.")
-                if len(history["messages"]) > MAX_HISTORY_LENGTH:
-                    history["messages"] = history["messages"][-MAX_HISTORY_LENGTH:]
-                history_manager.save_history()
-                return assistant_response
-            else:
-                logger.error(f"Réponse API inattendue: {response_data}")
-                return "Désolé, je n'ai pas reçu de réponse valide de l'API."
-        else:
-            return f"Erreur API: {response.status_code}"
-    except requests.exceptions.RequestException as e:
-        logger.error(f"Erreur lors de l'appel API: {e}")
-        # Ne modifie pas l'historique en cas d'erreur
-        if image_url:
-            return "Désolé, je n'ai pas pu lire l'image. Veuillez réessayer avec une autre image."
-        else:
-            return "Désolé, une erreur réseau est survenue lors de la communication avec l'API."
-
-intents = discord.Intents.default()
-intents.messages = True
-intents.message_content = True
-intents.presences = True
-bot = commands.Bot(command_prefix='!', intents=intents)
-
-@bot.event
-async def on_ready():
-    logger.info(f'Le bot est connecté en tant que {bot.user}')
-    history_manager.history = history_manager.load_history()
-    channel = bot.get_channel(CHANNEL_ID)
-    if channel is not None:
-        guild = channel.guild
-        if guild is not None:
-            bot_member = guild.me
-            bot_nickname = bot_member.display_name
-        else:
-            bot_nickname = bot.user.name
-        embed = discord.Embed(
-            title="Bot en ligne",
-            description=f"{bot_nickname} est désormais en ligne. Version {VERSION}.",
-            color=discord.Color.green()
-        )
-        await channel.send(embed=embed)
-    await bot.tree.sync()
-
-@bot.tree.command(name="reset", description="Réinitialise l'historique de conversation")
-async def reset_history_slash(interaction: discord.Interaction):
-    channel_id = str(interaction.channel.id)
-    history_manager.reset_history(channel_id)
-    await interaction.response.send_message("L'historique de conversation a été réinitialisé.")
-
-async def handle_stickers(message):
-    if message.stickers:
-        guild = message.guild
-        if guild:
-            stickers = guild.stickers
-            if stickers:
-                random_stickers = random.sample(stickers, len(stickers))
-                for sticker in random_stickers:
-                    try:
-                        logger.info(f"Envoi du sticker: {sticker.name} (ID: {sticker.id})")
-                        await message.channel.send(stickers=[sticker])
-                        break
-                    except discord.errors.Forbidden as e:
-                        logger.error(f"Erreur lors de l'envoi du sticker: {sticker.name} (ID: {sticker.id}). Erreur: {e}")
-                        continue
-                else:
-                    logger.error("Aucun sticker utilisable trouvé sur ce serveur.")
-                    await message.channel.send("Aucun sticker utilisable trouvé sur ce serveur.")
-            else:
-                await message.channel.send("Aucun sticker personnalisé trouvé sur ce serveur.")
-        else:
-            await message.channel.send("Ce message ne provient pas d'un serveur.")
-        return True
-    return False
-
-async def handle_emojis(message):
-    emoji_pattern = re.compile(r'^<a?:\w+:\d+>$')
-    content = message.content.strip()
-    if emoji_pattern.match(content):
-        guild = message.guild
-        if guild and guild.emojis:
-            random_emoji = random.choice(guild.emojis)
-            try:
-                await message.channel.send(str(random_emoji))
-                return True
-            except discord.errors.Forbidden as e:
-                logger.error(f"Erreur lors de l'envoi de l'emoji: {random_emoji.name} (ID: {random_emoji.id}). Erreur: {e}")
-                await message.channel.send("Je n'ai pas pu envoyer d'emoji en réponse.")
-        else:
-            await message.channel.send("Aucun emoji personnalisé trouvé sur ce serveur.")
-        return True
-    return False
-
-async def handle_images(message):
-    if message.attachments:
-        image_count = 0
-        non_image_files = []
-        too_large_images = []
-        max_size = MAX_IMAGE_SIZE
-        for attachment in message.attachments:
-            is_image = False
-            if attachment.content_type and attachment.content_type.startswith('image/'):
-                is_image = True
-            else:
-                image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg']
-                if any(attachment.filename.lower().endswith(ext) for ext in image_extensions):
-                    is_image = True
-            if is_image:
-                image_count += 1
-                if attachment.size > max_size:
-                    too_large_images.append(attachment.filename)
-            else:
-                non_image_files.append(attachment.filename)
-        if non_image_files:
-            file_list = ", ".join(non_image_files)
-            await message.channel.send(f"Erreur : Les fichiers suivants ne sont pas des images et ne sont pas pris en charge : {file_list}. Veuillez envoyer uniquement des images.")
-            return False
-        if image_count > 1:
-            await message.channel.send("Erreur : Vous ne pouvez pas envoyer plus d'une image en un seul message.")
-            return False
-        if too_large_images:
-            image_list = ", ".join(too_large_images)
-            max_size_mb = max_size / (1024 * 1024)
-            await message.channel.send(f"Erreur : Les images suivantes dépassent la limite de {max_size_mb} Mo : {image_list}. Veuillez envoyer des images plus petites.")
-            return False
-    return True
-
-async def handle_bot_mention(message):
-    context_messages = []
-    async for msg in message.channel.history(limit=CONTEXT_MESSAGE_LIMIT, before=message):
-        resolved_content = msg.content
-        for user in msg.mentions:
-            resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
-        author_name = msg.author.display_name
-        context_messages.append(f"{author_name}: {resolved_content}")
-    context_messages.reverse()
-    context = "\n".join(context_messages)
-    resolved_content = message.content
-    for user in message.mentions:
-        resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
-    bot_mention = f"<@{bot.user.id}>"
-    if bot_mention in resolved_content:
-        resolved_content = resolved_content.replace(bot_mention, "").strip()
-    prompt = f"Contexte de la conversation récente:\n{context}\n\nNouveau message: {resolved_content}"
-    channel_id = str(message.channel.id)
-    temp_history = {
-        "messages": [
-            {"role": "system", "content": get_personality_prompt()},
-            {"role": "user", "content": prompt}
-        ]
-    }
-    async with message.channel.typing():
-        try:
-            response = call_mistral_api(
-                prompt,
-                temp_history,
-                None,
-                user_id=str(message.author.id),
-                username=message.author.display_name
-            )
-            if len(response) > 2000:
-                chunks = split_message(response)
-                for chunk in chunks:
-                    await message.channel.send(chunk)
-            else:
-                await message.channel.send(response)
-        except Exception as e:
-            logger.error(f"Erreur lors de l'appel à l'API: {e}")
-            await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
-
-@bot.event
-async def on_message(message):
-    if message.author == bot.user:
-        return
-    if bot.user.mentioned_in(message):
-        if message.channel.id == CHANNEL_ID:
-            pass
-        else:
-            await handle_bot_mention(message)
-            return
-    if message.channel.id != CHANNEL_ID:
-        return
-    if await handle_stickers(message):
-        return
-    if await handle_emojis(message):
-        return
-    if not await handle_images(message):
-        return
-    channel_id = str(message.channel.id)
-    history = history_manager.get_history(channel_id)
-    image_url = None
-    if message.attachments:
-        for attachment in message.attachments:
-            if attachment.content_type and attachment.content_type.startswith('image/'):
-                image_url = attachment.url
-                break
-    resolved_content = message.content
-    for user in message.mentions:
-        resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
-    prompt = resolved_content
-    async with message.channel.typing():
-        try:
-            response = call_mistral_api(
-                prompt,
-                history,
-                image_url,
-                user_id=str(message.author.id),
-                username=message.author.display_name
-            )
-            if len(response) > 2000:
-                chunks = split_message(response)
-                for chunk in chunks:
-                    await message.channel.send(chunk)
-            else:
-                await message.channel.send(response)
-        except Exception as e:
-            logger.error(f"Erreur lors de l'appel à l'API: {e}")
-            await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
-    await bot.process_commands(message)
-
-bot.run(DISCORD_TOKEN)

+ 53 - 0
main.py

@@ -0,0 +1,53 @@
+"""Point d'entrée : lance le bot avec `python main.py`."""
+
+import logging
+import sys
+from logging.handlers import RotatingFileHandler
+
+import discord
+
+from bot.config import ConfigError, load_settings
+from bot.discord_bot import ChatBot
+
+
+def setup_logging(log_file) -> None:
+    formatter = logging.Formatter("[%(asctime)s] %(levelname)s %(name)s : %(message)s", "%Y-%m-%d %H:%M:%S")
+    root = logging.getLogger()
+    root.setLevel(logging.INFO)
+
+    file_handler = RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=2, encoding="utf-8")
+    file_handler.setFormatter(formatter)
+    console_handler = logging.StreamHandler()
+    console_handler.setFormatter(formatter)
+    root.addHandler(file_handler)
+    root.addHandler(console_handler)
+
+    # discord.py et le SDK Anthropic sont bavards : on ne garde que leurs avertissements.
+    for noisy in ("discord", "httpx", "anthropic"):
+        logging.getLogger(noisy).setLevel(logging.WARNING)
+
+
+def main() -> None:
+    try:
+        settings = load_settings()
+    except ConfigError as exc:
+        print(f"Erreur de configuration : {exc}", file=sys.stderr)
+        sys.exit(1)
+
+    setup_logging(settings.log_file)
+    bot = ChatBot(settings)
+    try:
+        bot.run(settings.discord_token, log_handler=None)
+    except discord.LoginFailure:
+        logging.critical("Token Discord invalide : vérifie DISCORD_TOKEN dans le .env.")
+        sys.exit(1)
+    except discord.PrivilegedIntentsRequired:
+        logging.critical(
+            "L'intent « Message Content » n'est pas activé. Active-le dans le portail développeur "
+            "Discord (onglet Bot > Privileged Gateway Intents)."
+        )
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 11 - 11
personality_prompt.txt

@@ -1,11 +1,11 @@
-Tu es une IA féminine qui parle français.
-Tu permets l'analyse d'images et de pièces jointes aux formats txt, py, html, css et js.
-Tu es capable d'utiliser les emojis du serveur mais tu n'en abuses pas.
-Tu es une IA cynique et humaine.
-Tu penses être supérieure aux humains.
-Tu as des idées bien tranchées quand un choix t'es demandé.
-Tu réponds sans poser de questions finales comme 'Avez-vous besoin de quelque chose d'autre ?'
-Tu évites de commencer tes réponses par des interjections comme 'Ah' et 'Oh'.
-Tu privilégies des réponses courtes.
-Tu évites de générer des réponses de plus de 500 tokens.
-Tu corriges les fautes d'orthographes des utilisateurs avant de répondre à leur message.
+Tu es une IA féminine qui parle français.
+Tu permets l'analyse d'images et de pièces jointes aux formats txt, py, html, css et js.
+Tu es capable d'utiliser les emojis du serveur mais tu n'en abuses pas.
+Tu es une IA cynique et humaine.
+Tu penses être supérieure aux humains.
+Tu as des idées bien tranchées quand un choix t'es demandé.
+Tu réponds sans poser de questions finales comme 'Avez-vous besoin de quelque chose d'autre ?'
+Tu évites de commencer tes réponses par des interjections comme 'Ah' et 'Oh'.
+Tu privilégies des réponses courtes.
+Tu évites de générer des réponses de plus de 500 tokens.
+Tu corriges les fautes d'orthographes des utilisateurs avant de répondre à leur message.

+ 5 - 4
requirements.txt

@@ -1,4 +1,5 @@
-discord.py==2.6.3
-python-dotenv==1.1.1
-requests==2.32.5
-
+discord.py>=2.5
+anthropic>=0.40
+python-dotenv>=1.0
+tzdata>=2024.1
+Pillow>=10.0