Bot Discord + Claude API : architecture complète et production
Architecture robuste d'un bot Discord alimenté par Claude. Gestion d'erreurs, rate limiting, Docker, déploiement VPS et bonnes pratiques de production.
Bot Discord + Claude API : architecture complète et production
Ce guide construit un bot Discord connecté à l'API Claude d'Anthropic avec une architecture pensée pour la production : gestion d'erreurs, rate limiting, commandes slash, persistance des conversations, conteneurisation Docker et déploiement sur VPS.
On suppose que vous avez déjà une application Discord créée et un token bot fonctionnel. Si ce n'est pas le cas, passez d'abord par le guide débutant.
Pré-requis
- Python 3.11+
- Un token bot Discord avec les intents Message Content et Server Members
- Une clé API Anthropic avec du crédit
- Un VPS Linux pour le déploiement (Ubuntu 22.04/24.04 recommandé)
- Docker et Docker Compose (optionnel mais recommandé)
Structure du projet
claude-discord-bot/
├── bot/
│ ├── __init__.py
│ ├── main.py # Point d'entrée
│ ├── claude_client.py # Wrapper API Claude
│ ├── commands.py # Commandes slash
│ └── config.py # Configuration centralisée
├── .env
├── .env.example
├── .gitignore
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.mdConfiguration centralisée
`bot/config.py` :
import os
from dataclasses import dataclass
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Config:
discord_token: str
anthropic_api_key: str
claude_model: str = "claude-sonnet-4-20250514"
max_tokens: int = 2048
max_history: int = 30
rate_limit_per_user: int = 10 # requêtes par minute
system_prompt: str = (
"Tu es un assistant technique dans un serveur Discord francophone. "
"Réponds de manière précise et structurée. "
"Utilise le markdown Discord : **gras**, `code inline`, "
"```blocs de code``` avec le langage spécifié. "
"Si tu ne sais pas, dis-le."
)
@classmethod
def from_env(cls) -> "Config":
token = os.getenv("DISCORD_TOKEN")
api_key = os.getenv("ANTHROPIC_API_KEY")
if not token:
raise EnvironmentError("DISCORD_TOKEN manquant dans .env")
if not api_key:
raise EnvironmentError("ANTHROPIC_API_KEY manquant dans .env")
return cls(
discord_token=token,
anthropic_api_key=api_key,
claude_model=os.getenv("CLAUDE_MODEL", cls.claude_model),
max_tokens=int(os.getenv("MAX_TOKENS", cls.max_tokens)),
max_history=int(os.getenv("MAX_HISTORY", cls.max_history)),
rate_limit_per_user=int(os.getenv("RATE_LIMIT", cls.rate_limit_per_user)),
)Client Claude avec retry et rate limiting
`bot/claude_client.py` :
import time
import logging
from collections import defaultdict
from anthropic import Anthropic, APIError, RateLimitError
logger = logging.getLogger(__name__)
class RateLimiter:
"""Rate limiter par utilisateur avec fenêtre glissante."""
def __init__(self, max_requests: int, window_seconds: int = 60):
self.max_requests = max_requests
self.window = window_seconds
self.requests: dict[str, list[float]] = defaultdict(list)
def check(self, user_id: str) -> bool:
now = time.time()
# Nettoyer les requêtes expirées
self.requests[user_id] = [
t for t in self.requests[user_id] if now - t < self.window
]
if len(self.requests[user_id]) >= self.max_requests:
return False
self.requests[user_id].append(now)
return True
def remaining(self, user_id: str) -> int:
now = time.time()
active = [t for t in self.requests.get(user_id, []) if now - t < self.window]
return max(0, self.max_requests - len(active))
class ClaudeClient:
"""Wrapper autour de l'API Anthropic avec retry exponentiel."""
def __init__(self, api_key: str, model: str, max_tokens: int, system_prompt: str):
self.client = Anthropic(api_key=api_key)
self.model = model
self.max_tokens = max_tokens
self.system_prompt = system_prompt
def ask(self, messages: list[dict], retries: int = 3) -> str:
for attempt in range(retries):
try:
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
messages=messages,
)
return response.content[0].text
except RateLimitError:
wait = 2 ** attempt * 5
logger.warning(f"Rate limit Anthropic, retry dans {wait}s")
time.sleep(wait)
except APIError as e:
logger.error(f"Erreur API Anthropic : {e}")
if attempt == retries - 1:
raise
time.sleep(2 ** attempt)
raise RuntimeError("Échec après plusieurs tentatives")Commandes slash
`bot/commands.py` :
import discord
from discord import app_commands
def setup_commands(tree: app_commands.CommandTree, conversations: dict):
"""Enregistrer les commandes slash du bot."""
@tree.command(name="ask", description="Poser une question à Claude")
@app_commands.describe(question="Votre question pour Claude")
async def ask(interaction: discord.Interaction, question: str):
# Defer car la réponse peut prendre quelques secondes
await interaction.response.defer(thinking=True)
salon_id = str(interaction.channel_id)
if salon_id not in conversations:
conversations[salon_id] = []
conversations[salon_id].append({"role": "user", "content": question})
conversations[salon_id] = conversations[salon_id][-20:]
try:
from bot.main import claude_client
reponse = claude_client.ask(conversations[salon_id])
conversations[salon_id].append({"role": "assistant", "content": reponse})
if len(reponse) <= 2000:
await interaction.followup.send(reponse)
else:
morceaux = [reponse[i:i+2000] for i in range(0, len(reponse), 2000)]
await interaction.followup.send(morceaux[0])
for m in morceaux[1:]:
await interaction.channel.send(m)
except Exception as e:
await interaction.followup.send(f"Erreur : {e}")
@tree.command(name="clear", description="Effacer l'historique de conversation")
async def clear(interaction: discord.Interaction):
salon_id = str(interaction.channel_id)
conversations.pop(salon_id, None)
await interaction.response.send_message(
"Historique effacé pour ce salon.", ephemeral=True
)
@tree.command(name="model", description="Voir ou changer le modèle Claude utilisé")
@app_commands.describe(nom="Nom du modèle (laisser vide pour voir le modèle actuel)")
async def model_cmd(interaction: discord.Interaction, nom: str = None):
from bot.main import claude_client
if nom:
claude_client.model = nom
await interaction.response.send_message(
f"Modèle changé : `{nom}`", ephemeral=True
)
else:
await interaction.response.send_message(
f"Modèle actuel : `{claude_client.model}`", ephemeral=True
)Point d'entrée principal
`bot/main.py` :
import logging
import discord
from discord import app_commands
from bot.config import Config
from bot.claude_client import ClaudeClient, RateLimiter
from bot.commands import setup_commands
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
config = Config.from_env()
claude_client = ClaudeClient(
api_key=config.anthropic_api_key,
model=config.claude_model,
max_tokens=config.max_tokens,
system_prompt=config.system_prompt,
)
rate_limiter = RateLimiter(max_requests=config.rate_limit_per_user)
intents = discord.Intents.default()
intents.message_content = True
class ClaudeBot(discord.Client):
def __init__(self):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
self.conversations: dict[str, list[dict]] = {}
async def setup_hook(self):
setup_commands(self.tree, self.conversations)
await self.tree.sync()
logger.info("Commandes slash synchronisées")
bot = ClaudeBot()
@bot.event
async def on_ready():
logger.info(f"Connecté en tant que {bot.user} (ID: {bot.user.id})")
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.listening,
name="vos questions | /ask"
)
)
@bot.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if bot.user not in message.mentions:
return
user_id = str(message.author.id)
if not rate_limiter.check(user_id):
remaining = rate_limiter.remaining(user_id)
await message.reply(
f"Doucement ! Tu as atteint la limite de {config.rate_limit_per_user} "
f"messages par minute. Restant : {remaining}"
)
return
texte = message.content.replace(f"<@{bot.user.id}>", "").strip()
if not texte:
return
salon_id = str(message.channel.id)
if salon_id not in bot.conversations:
bot.conversations[salon_id] = []
bot.conversations[salon_id].append({
"role": "user",
"content": f"{message.author.display_name}: {texte}",
})
bot.conversations[salon_id] = bot.conversations[salon_id][-config.max_history:]
async with message.channel.typing():
try:
reponse = claude_client.ask(bot.conversations[salon_id])
bot.conversations[salon_id].append(
{"role": "assistant", "content": reponse}
)
if len(reponse) <= 2000:
await message.reply(reponse)
else:
morceaux = [
reponse[i:i + 2000]
for i in range(0, len(reponse), 2000)
]
for morceau in morceaux:
await message.channel.send(morceau)
except Exception as e:
logger.exception("Erreur lors de la réponse")
await message.reply(f"Erreur interne : `{type(e).__name__}`")
def run():
bot.run(config.discord_token, log_handler=None)
if __name__ == "__main__":
run()Fichiers de support
`requirements.txt` :
discord.py>=2.3.0
anthropic>=0.39.0
python-dotenv>=1.0.0`.env.example` :
DISCORD_TOKEN=
ANTHROPIC_API_KEY=
CLAUDE_MODEL=claude-sonnet-4-20250514
MAX_TOKENS=2048
MAX_HISTORY=30
RATE_LIMIT=10`.gitignore` :
.env
venv/
__pycache__/
*.pyc
.DS_StoreConteneurisation Docker
`Dockerfile` :
FROM python:3.12-slim
WORKDIR /app
## Copier et installer les dépendances d'abord (cache Docker)
COPY requirements.txt .
RUN pip install —no-cache-dir -r requirements.txt
## Copier le code source
COPY bot/ bot/
COPY .env .env
## Utilisateur non-root pour la sécurité
RUN useradd -r -s /bin/false botuser
USER botuser
CMD ["python", "-m", "bot.main"]`docker-compose.yml` :
version: "3.8"
services:
bot:
build: .
container_name: claude-discord-bot
restart: unless-stopped
env_file:
- .env
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"Pour lancer :
## Build et lancement en arrière-plan
docker compose up -d —build
## Vérifier les logs
docker compose logs -f bot
## Redémarrer après modification
docker compose down && docker compose up -d —buildDéploiement sur VPS (sans Docker)
Si vous préférez un déploiement direct avec systemd :
## Sur le VPS, cloner le projet
git clone https://votre-repo.git /opt/claude-discord-bot
cd /opt/claude-discord-bot
## Créer l'environnement Python
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
## Configurer le .env
cp .env.example .env
nano .env # Remplir les clés`/etc/systemd/system/claude-bot.service` :
[Unit]
Description=Claude Discord Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/claude-discord-bot
ExecStart=/opt/claude-discord-bot/venv/bin/python -m bot.main
Restart=on-failure
RestartSec=10
StartLimitBurst=5
## Sécurité
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/claude-discord-bot
PrivateTmp=true
## Environnement
EnvironmentFile=/opt/claude-discord-bot/.env
[Install]
WantedBy=multi-user.target## Activer et démarrer le service
sudo systemctl daemon-reload
sudo systemctl enable claude-bot
sudo systemctl start claude-bot
## Vérifier le statut
sudo systemctl status claude-bot
## Voir les logs en temps réel
sudo journalctl -u claude-bot -fHardening sécurité
Quelques mesures indispensables avant de mettre en production.
Permissions Discord minimales : ne donnez au bot que les permissions strictement nécessaires. Send Messages, Read Message History et Use Slash Commands suffisent pour ce projet.
Filtrage des salons : restreignez le bot à certains salons plutôt que de le laisser répondre partout.
## Ajouter dans on_message, après la vérification des mentions
ALLOWED_CHANNELS = {123456789, 987654321} # IDs des salons autorisés
if message.channel.id not in ALLOWED_CHANNELS:
returnBlocage d'utilisateurs : empêchez certains utilisateurs d'utiliser le bot.
BLOCKED_USERS = set() # Ajouter les IDs des utilisateurs à bloquer
if message.author.id in BLOCKED_USERS:
returnSanitisation des entrées : Claude gère bien les injections de prompt, mais c'est une bonne pratique de limiter la longueur des messages.
MAX_INPUT_LENGTH = 4000
if len(texte) > MAX_INPUT_LENGTH:
await message.reply("Message trop long. Limite : 4000 caractères.")
returnDépannage
Le bot ne répond à aucune mention
- Vérifiez que Message Content Intent est activé dans le portail développeur
- Vérifiez les logs :
docker compose logs botoujournalctl -u claude-bot - Testez le token :
curl -H "Authorization: Bot VOTRE_TOKEN" https://discord.com/api/v10/users/@me
Erreur `403 Forbidden` de Discord
Le bot manque de permissions dans le salon. Vérifiez les permissions du rôle du bot dans les paramètres du serveur.
Erreur `529 Overloaded` d'Anthropic
L'API Claude est surchargée. Le retry exponentiel dans ClaudeClient gère ce cas automatiquement. Si ça persiste, augmentez le délai de retry.
Consommation mémoire qui augmente
L'historique des conversations s'accumule. La limite max_history dans la config empêche une croissance infinie, mais si le bot tourne sur beaucoup de salons, ajoutez un nettoyage périodique :
import asyncio
async def cleanup_old_conversations():
while True:
await asyncio.sleep(3600) # Toutes les heures
bot.conversations.clear()
logger.info("Historique des conversations vidé")
## Ajouter dans setup_hook :
asyncio.create_task(cleanup_old_conversations())Les commandes slash n'apparaissent pas
Les commandes slash peuvent mettre jusqu'à une heure à se propager sur Discord. Pour forcer la synchro sur un serveur spécifique (instantané) :
## Dans setup_hook, remplacer await self.tree.sync() par :
guild = discord.Object(id=VOTRE_GUILD_ID)
await self.tree.sync(guild=guild)Monitoring
Pour suivre l'activité du bot, ajoutez un compteur simple :
import json
from datetime import datetime
class Stats:
def __init__(self):
self.total_requests = 0
self.errors = 0
self.started_at = datetime.now().isoformat()
def to_dict(self):
return {
"total_requests": self.total_requests,
"errors": self.errors,
"started_at": self.started_at,
"uptime_hours": round(
(datetime.now() - datetime.fromisoformat(self.started_at))
.total_seconds() / 3600, 1
),
}
stats = Stats()Ajoutez une commande /stats pour consulter les métriques depuis Discord.
Ce bot est prêt pour un usage quotidien sur un serveur de taille petite à moyenne. Pour des serveurs plus importants (1000+ utilisateurs actifs), envisagez une base de données Redis pour l'historique, un système de file d'attente pour les requêtes, et un load balancer si nécessaire.
À lire aussi
Fine-tuner un modèle IA avec LoRA : le guide pas à pas
Apprenez à personnaliser un modèle d'IA en le fine-tunant avec LoRA, même avec une carte graphique modeste. Guide complet pour débutants.
Fine-tuning LoRA/QLoRA : configuration avancée et optimisation
Guide technique complet pour fine-tuner un LLM avec LoRA : quantification QLoRA, hyperparamètres, multi-GPU, troubleshooting et déploiement GGUF.
Créer un chatbot Discord avec Claude : le guide pas à pas
Apprenez à créer votre propre chatbot Discord propulsé par l'IA Claude d'Anthropic. Aucune expérience en programmation requise, on part de zéro.