Construire un Bot de Trivia pour Discord
Un bot discord.py en direct qui organise des manches de trivia dans un serveur : poste une question, recueille les réponses dans un délai, révèle qui a trouvé la bonne réponse, et garde un classement persistant à travers les manches. La plupart des bots de trivia s’arrêtent à une banque de questions fixe, celui-ci ajoute une touche qui convient à un cours de Python : il peut aussi générer une question inédite sur n’importe quel sujet à la volée avec un LLM de niveau gratuit, au lieu de toujours puiser dans une liste préfabriquée.
Cela suppose du Python de niveau Python 101. Aucun autre Projet du Monde Réel n’est requis au préalable, même si tu as déjà construit Construire une App RAG, la configuration du LLM de niveau gratuit ci-dessous te semblera familière.
C’est optionnel et non noté. Voir Projets du monde réel pour la liste complète et croissante.
🎯 Ce que tu vas faire
- Créer une application de bot Discord et récupérer son jeton depuis le portail gratuit des développeurs de Discord.
- Installer
uv, configurer un projet, et ajouterdiscord.pyen plus d’un client LLM de niveau gratuit. - Construire une banque de questions de trivia fixe et une commande slash Discord de base qui en poste une.
- Ajouter un classement persistant par joueur, stocké entre les redémarrages.
- Ajouter un mode de génération de questions par LLM : donne un sujet au bot, récupère une question inédite.
- Brancher le tout dans une boucle de manche complète, poste une question, recueille les réponses dans un délai, révèle la réponse, met à jour le classement.
- Inviter le bot sur un serveur de test et jouer de vraies manches, de bout en bout.
Où exécuter ceci
En local avec uv est vraiment la seule option pratique ici, plus que pour la plupart des autres projets de cette série. Un bot Discord n’est pas un script qui s’exécute une fois et se termine, il maintient une connexion ouverte avec Discord et doit continuer à tourner tant que tu veux qu’il réponde à /trivia et recueille des réponses, ce qui signifie un vrai processus local (ou hébergé) de longue durée, pas une commande ponctuelle.
GitHub Codespaces fonctionne aussi, et c’est un substitut raisonnable si tu préfères ne rien installer localement : ouvre tout le dépôt du cours dans un Codespace gratuit (Node, Python et uv sont déjà installés, selon le .devcontainer/devcontainer.json du dépôt) et exécute uv run python bot.py dans un terminal là-bas, il reste actif aussi longtemps que ce terminal (et le Codespace) reste ouvert, la même exigence de « processus de longue durée » que de l’exécuter en local.
Google Colab et Kaggle Notebooks sont un mauvais choix pour le bot réel, sois honnête avec toi-même à ce sujet plutôt que de lutter contre. Les notebooks sont construits autour du fait d’exécuter une cellule, d’obtenir la sortie, et de passer à la cellule suivante ; ils ne sont pas faits pour un processus en arrière-plan qui s’assoit et attend des événements indéfiniment. Tu peux démarrer la boucle d’événements d’un bot dans une cellule de notebook, mais dès que le runtime du notebook est recyclé, se déconnecte, ou que tu fermes l’onglet, le bot tombe avec lui, saute Colab/Kaggle pour le bot en direct et utilise un vrai processus local ou Codespaces à la place.
Cela dit, la génération de questions et le scoring sous le bot ne sont que des fonctions normales qui exécutent une cellule à la fois, ce qui est exactement ce pour quoi les notebooks sont bons. Les badges ci-dessous ouvrent un notebook qui génère de vraies questions LLM sur quelques sujets d’exemple et fait passer quelques « joueurs » factices à travers la logique de scoring, pour que tu puisses voir les deux fonctionner sans rien installer localement. Il s’arrête délibérément avant la couche Discord, pour cela, reviens ici et exécute bot.py en local ou dans Codespaces comme décrit ci-dessus.
Configuration
Tout dans cette section n’a besoin de se produire qu’une seule fois, avant que tu écrives la moindre ligne du bot lui-même : installer uv, créer l’application de bot Discord et récupérer son jeton, obtenir une clé LLM gratuite, et configurer le projet. Chaque étape après celle-ci suppose que tout cela est déjà fait.
Installer uv
uv est un outil unique qui remplace la chaîne habituelle « installe Python, puis installe pip, puis installe un outil d’environnement virtuel, puis installe les paquets », il peut installer et gérer les versions de Python lui-même, en plus des dépendances de ton projet.
macOS / Linux (terminal) :
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell) :
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Ferme et rouvre ton terminal, puis confirme que c’est installé :
uv --version
Créer une application de bot Discord et obtenir un jeton
Le Portail des Développeurs de Discord est gratuit et ne demande aucune carte :
- Connecte-toi et clique sur New Application, donne-lui un nom (ex. « trivia-bot »), et crée-la.
- Ouvre l’onglet Bot à gauche. Discord ajoute un utilisateur bot à ton application automatiquement.
- Clique sur Reset Token (ou View Token si c’est la première fois) et copie-le. Ce jeton est exactement comme un mot de passe, quiconque l’a peut contrôler ton bot, alors traite-le de la même façon que tu traiterais une clé API LLM : ne le colle jamais dans le code, ne le commite jamais.
- Sur le même onglet Bot, fais défiler jusqu’à Privileged Gateway Intents et active Message Content. C’est nécessaire pour que le bot lise réellement la lettre avec laquelle un joueur répond, sans cela,
discord.pyreçoit une chaîne vide pour le contenu de chaque message, peu importe le code que tu écris. - Ouvre OAuth2 → URL Generator. Sous Scopes, coche à la fois
botetapplications.commands(les commandes slash ont spécifiquement besoin du second) ; sous Bot Permissions, coche au moins Send Messages et Read Message History. Garde l’URL générée à portée de main, tu l’utiliseras à la dernière étape pour réellement inviter le bot sur un serveur.
Un jeton de bot est un secret, exactement comme une clé API
Ne code jamais en dur le jeton du bot, ne le commite jamais, et garde-le dans un fichier .env local (ci-dessous) à la place, un jeton de bot divulgué permet à n’importe qui d’usurper ton bot dans chaque serveur où il se trouve, exactement comme une clé LLM divulguée permet à n’importe qui de dépenser ton quota.
Obtenir une clé API LLM gratuite
Le mode de génération de questions a besoin d’une clé LLM de niveau gratuit, choisis le fournisseur que tu veux, aucun ne demande de carte de crédit au moment de la rédaction :
| Fournisseur | Où obtenir une clé | Pourquoi le choisir |
|---|---|---|
| GitHub Models (défaut suggéré) | github.com/settings/tokens, un jeton d’accès personnel avec le scope models: read | Pas d’inscription séparée, tu as déjà un compte GitHub. Limites de niveau gratuit plus généreuses que celles de Gemini. |
| Gemini | Google AI Studio | L’option la plus couramment citée. |
| Groq | console.groq.com/keys | Inférence rapide, niveau gratuit généreux, sans carte. |
| Mistral | console.mistral.ai/api-keys | L’un des quotas gratuits permanents les plus généreux. |
| Cerebras | cloud.cerebras.ai | Volume de jetons quotidien élevé, sans carte. |
| OpenRouter | openrouter.ai/keys | Une API, de nombreux modèles gratuits, bien pour comparer les fournisseurs. |
La banque de questions fixe (Étape 1) n’a besoin d’aucune clé LLM, tu n’en as besoin qu’une fois arrivé à la génération de questions par sujet de l’Étape 3.
Configurer le projet
uv init trivia-bot
cd trivia-bot
uv add discord.py openai python-dotenv
discord.py est la bibliothèque qui parle à Discord, se connectant à sa Gateway, enregistrant les commandes slash, et recevant/envoyant des messages. openai parle à l’endpoint compatible OpenAI de GitHub Models pour le fournisseur par défaut ci-dessus ; remplace-le par le paquet de ton propre fournisseur si tu en as choisi un autre. python-dotenv charge les secrets depuis un fichier .env local.
Crée un fichier .env dans le dossier du projet (ne le commite jamais) avec les deux secrets de cette section :
# .env
DISCORD_BOT_TOKEN=your-bot-token-here
GITHUB_TOKEN=your-llm-key-here
✅ Liste de vérification
- ✅ Une application Discord et un bot existent dans le Portail des Développeurs, et tu as copié son jeton.
- ✅ « Message Content » est activé sous Privileged Gateway Intents.
- ✅ Tu as une clé API LLM de niveau gratuit d’un fournisseur de ton choix.
- ✅
uv init/uv addse sont terminés sans erreur, et.enva à la foisDISCORD_BOT_TOKENet ta clé LLM configurés.
🤔 Question(s) socratique(s)
- Pourquoi Discord exige-t-il que tu actives explicitement « Message Content » comme une intention privilégiée, plutôt que de donner à chaque bot l’accès au texte des messages par défaut ?
- Le jeton du bot et la clé API LLM sont tous deux des secrets, mais ils s’authentifient auprès de deux services complètement différents. Qu’est-ce qui irait mal si tu échangeais accidentellement quelle variable d’environnement contient quelle valeur ?
Étape 1 : Une banque de questions fixe et une commande slash de base
1.1 Commence par la source de questions la plus simple possible, une liste Python plate de dict…
👟 Indice de départ :
Commence par la source de questions la plus simple possible, une liste Python plate de dicts, et juste assez de câblage Discord pour en poster une :
# questions.py
"""A small fixed bank of trivia questions. Every question, from this bank
or later generated by an LLM, is the same shape:
{"question": str, "options": list[str], "answer_index": int}."""
import random
QUESTION_BANK = [
{
"question": "What year was Python first released?",
"options": ["1989", "1991", "1995", "2000"],
"answer_index": 1,
},
{
"question": "Which planet is known as the Red Planet?",
"options": ["Venus", "Jupiter", "Mars", "Saturn"],
"answer_index": 2,
},
# ... a handful more, see examples/trivia-bot/questions.py for the full bank
]
def random_question() -> dict:
return random.choice(QUESTION_BANK)
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
1.2 L’interface moderne de discord.py pour cela est une commande slash : au lieu de survei…
👟 Indice de départ :
L’interface moderne de discord.py pour cela est une commande slash : au lieu de surveiller chaque message pour quelque chose qui ressemble à une commande, tu enregistres /trivia auprès de Discord lui-même, et Discord l’affiche dans l’interface avec autocomplétion. Cela nécessite un Client plus un app_commands.CommandTree qui lui est attaché :
# bot.py (Step 1 version — grows through the rest of this project)
import os
import discord
from discord import app_commands
from dotenv import load_dotenv
from questions import random_question
load_dotenv()
intents = discord.Intents.default()
intents.message_content = True # requires the portal toggle from Setup, too
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
@tree.command(name="trivia", description="Start a trivia round")
async def trivia_command(interaction: discord.Interaction) -> None:
question = random_question()
lines = [f"**{question['question']}**"]
for letter, option in zip("ABCD", question["options"]):
lines.append(f"{letter}) {option}")
await interaction.response.send_message("\n".join(lines))
@client.event
async def on_ready() -> None:
await tree.sync() # registers /trivia with Discord -- can take a minute the first time
print(f"Logged in as {client.user} -- ready in {len(client.guilds)} server(s).")
if __name__ == "__main__":
client.run(os.environ["DISCORD_BOT_TOKEN"])
tree.sync() est ce qui publie réellement /trivia sur Discord pour qu’il apparaisse quand quelqu’un tape / dans ton serveur, saute-le et la commande existe dans ton code mais nulle part où l’interface de Discord peut la trouver.
:::tip[Les commandes slash ont besoin d’un second scope OAuth2]
Une invitation de bot normale ne nécessite que le scope bot. Les commandes slash ont spécifiquement besoin aussi de applications.commands, si tu as généré ton URL d’invitation avant d’ajouter /trivia, régénère-la avec les deux scopes cochés (voir Configuration ci-dessus) ou la commande n’apparaîtra jamais en silence dans ton serveur.
:::
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
1.3 Vérifie
✅ Liste de vérification
- ✅
questions.pydéfinitQUESTION_BANKetrandom_question(). - ✅
bot.pyenregistre une commande slash/triviaviaapp_commands.CommandTree. - ✅
on_readyappelleawait tree.sync()avant d’imprimer son message de prêt.
🤔 Question(s) socratique(s)
tree.sync()ré-enregistre chaque commande slash auprès des serveurs de Discord, ce qui est limité en débit. Qu’est-ce qui irait mal si tu l’appelais à l’intérieur detrivia_commandau lieu d’une fois danson_ready?- Le
answer_indexdu dict de la question pointe versoptionspar position plutôt que de stocker le texte de la bonne réponse directement. Quel est un avantage de le stocker de cette façon ?
Étape 2 : Suivi des scores, persisté à travers les manches
2.1 Un classement ne veut dire quelque chose que s’il survit au redémarrage du bot, donc les sco…
👟 Indice de départ :
Un classement ne veut dire quelque chose que s’il survit au redémarrage du bot, donc les scores vont dans un petit fichier JSON plutôt que de vivre uniquement en mémoire :
# scores.py
"""Per-player score persistence in scores.json. Keyed by Discord user id
(not username), so a player's score survives a nickname change."""
import json
from pathlib import Path
SCORES_PATH = Path("scores.json")
def load_scores() -> dict:
if not SCORES_PATH.exists():
return {}
return json.loads(SCORES_PATH.read_text(encoding="utf-8"))
def save_scores(scores: dict) -> None:
SCORES_PATH.write_text(json.dumps(scores, indent=2), encoding="utf-8")
def award_point(scores: dict, user_id: int, display_name: str) -> dict:
key = str(user_id)
entry = scores.get(key, {"name": display_name, "score": 0})
entry["name"] = display_name
entry["score"] += 1
scores[key] = entry
save_scores(scores)
return scores
def leaderboard_text(scores: dict, top_n: int = 10) -> str:
if not scores:
return "No scores yet -- play a round with `/trivia`!"
ranked = sorted(scores.values(), key=lambda entry: entry["score"], reverse=True)
lines = [f"{i}. {entry['name']} — {entry['score']}" for i, entry in enumerate(ranked[:top_n], start=1)]
return "\n".join(lines)
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
2.2 Teste-le de manière autonome avant de le brancher dans bot.py du tout, le même motif « pr…
👟 Indice de départ :
Teste-le de manière autonome avant de le brancher dans bot.py du tout, le même motif « prouve que la pièce fonctionne seule d’abord » que n’importe quel projet en plusieurs parties :
uv run python -c "
from scores import award_point, leaderboard_text
s = {}
s = award_point(s, 111, 'Alice')
s = award_point(s, 222, 'Bob')
s = award_point(s, 111, 'Alice')
print(leaderboard_text(s))
"
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
2.3 Puis ajoute une seconde commande slash qui lit simplement le fichier :
👟 Indice de départ :
Puis ajoute une seconde commande slash qui lit simplement le fichier :
@tree.command(name="leaderboard", description="Show the trivia leaderboard")
async def leaderboard_command(interaction: discord.Interaction) -> None:
scores = load_scores()
await interaction.response.send_message(f"**Leaderboard:**\n{leaderboard_text(scores)}")
Rien n’attribue encore de point, trivia_command de l’Étape 1 ne vérifie pas du tout les réponses, c’est ce que la boucle de manche de l’Étape 4 ajoute. Cette étape n’est délibérément que la moitié stockage, testée et fonctionnant seule d’abord.
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
2.4 Vérifie
✅ Liste de vérification
- ✅
scores.pydéfinitload_scores(),award_point(), etleaderboard_text(). - ✅ Exécuter le test autonome de
scores.pyimprime un classement avec Alice au-dessus de Bob. - ✅
/leaderboardest enregistré dansbot.pyet répond avec le classement (encore vide).
🤔 Question(s) socratique(s)
- Les scores sont indexés par
str(user_id)plutôt que par le nom d’affichage du joueur. Quel scénario réel casserait un classement indexé par nom qu’un classement indexé par ID d’utilisateur survit ? save_scores()réécrit tout le fichier à chaque point individuel. Pour un petit bot mono-serveur, c’est très bien, à quel moment cela cesserait-il de l’être, et vers quoi te tournerais-tu à la place ?
Étape 3 : Générer une question inédite sur n’importe quel sujet avec un LLM
3.1 La banque fixe de l’Étape 1 ne puise toujours que dans la même poignée de questions. Cette é…
👟 Indice de départ :
La banque fixe de l’Étape 1 ne puise toujours que dans la même poignée de questions. Cette étape ajoute une seconde source de questions : donne un sujet au bot, et il demande à un LLM une toute nouvelle question à choix multiples sur ce sujet, à la volée.
# generate.py
"""Generates a fresh trivia question on a topic via a free-tier LLM.
Returns the exact same shape as questions.py's bank entries, so the rest
of the bot doesn't need to know or care where a question came from."""
import json
import os
from openai import OpenAI
llm_client = OpenAI(
api_key=os.environ["GITHUB_TOKEN"],
base_url="https://models.github.ai/inference",
)
PROMPT_TEMPLATE = """Write one multiple-choice trivia question about: {topic}
Respond with ONLY a JSON object, no other text, in exactly this shape:
{{"question": "...", "options": ["...", "...", "...", "..."], "answer_index": 0}}
Requirements:
- Exactly 4 options.
- Exactly one is correct; put its index (0-3) in answer_index.
- The wrong options must be plausible, not obviously silly.
- Keep the question and every option short enough to fit in a Discord message."""
def generate_question(topic: str) -> dict:
response = llm_client.chat.completions.create(
model="gpt-4o-mini", # confirm this still has a free tier before running
messages=[{"role": "user", "content": PROMPT_TEMPLATE.format(topic=topic)}],
response_format={"type": "json_object"},
)
question = json.loads(response.choices[0].message.content)
options = question.get("options")
answer_index = question.get("answer_index")
if not question.get("question") or not isinstance(options, list) or len(options) != 4:
raise ValueError(f"LLM returned a malformed question: {question!r}")
if not isinstance(answer_index, int) or not (0 <= answer_index < 4):
raise ValueError(f"LLM returned an invalid answer_index: {question!r}")
return question
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
3.2 La vérification explicite de la forme après l’analyse compte : `response_format={“type”: “js…
👟 Indice de départ :
La vérification explicite de la forme après l’analyse compte : response_format={"type": "json_object"} garantit que la sortie du LLM est du JSON valide, pas que c’est le bon JSON, il pourrait encore renvoyer trois options au lieu de quatre, ou omettre answer_index complètement. L’attraper ici, avec une erreur claire, vaut mieux que de le découvrir plus tard comme un message Discord déroutant avec une option D manquante.
Branche un paramètre topic dans /trivia pour qu’il puisse puiser dans l’une ou l’autre source :
from round import pick_question # combines random_question() and generate_question()
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
3.3 # round.py
👟 Indice de départ :
Exécutez le code ci-dessous et confirmez qu’il fonctionne.
# round.py
"""Non-Discord round logic shared by bot.py and the notebook."""
from generate import generate_question
from questions import random_question
def pick_question(topic: str | None = None) -> dict:
if topic:
return generate_question(topic)
return random_question()
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
3.4 @tree.command(name=“trivia”, description=“Start a trivia round, optionally on a topic”)
👟 Indice de départ :
Exécutez le code ci-dessous et confirmez qu’il fonctionne.
@tree.command(name="trivia", description="Start a trivia round, optionally on a topic")
@app_commands.describe(topic="Optional topic for a freshly generated question")
async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None:
question = pick_question(topic)
...
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
3.5 Teste les deux chemins depuis un terminal avant de leur faire confiance à l’intérieur de Dis…
👟 Indice de départ :
Teste les deux chemins depuis un terminal avant de leur faire confiance à l’intérieur de Discord :
uv run python -c "from round import pick_question; print(pick_question())"
uv run python -c "from round import pick_question; print(pick_question('classic video games'))"
Valide le contenu généré par LLM avant qu’il n’atteigne un canal en direct
Un LLM à qui l’on demande une question de trivia peut encore se tromper sur les faits, surtout sur des sujets obscurs, il n’y a pas de try/except qui attrape « incorrect avec confiance ». La validation de forme dans generate_question() ne protège que contre une structure malformée ; pour un serveur public, parcours une poignée de questions générées sur des sujets que tu connais vraiment avant de faire confiance au mode sur des sujets que tu ne connais pas.
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
3.6 Vérifie
✅ Liste de vérification
- ✅
generate_question(topic)degenerate.pyrenvoie un dict avec 4 options et unanswer_indexvalide, ou lève une erreur claire. - ✅
pick_question()deround.pyrenvoie une question de banque quandtopicest vide, et une générée sinon. - ✅
/triviaaccepte un argumenttopicoptionnel et l’utilise visiblement.
🤔 Question(s) socratique(s)
generate_question()valide queanswer_indexest un int dans0..3et qu’il y a exactement 4 options, mais il ne valide pas que le contenu est réellement de la bonne trivia. Où se trouve la ligne entre ce que le code peut raisonnablement vérifier et ce que seul un humain qui revoit la sortie peut faire ?- Si un joueur choisit un sujet délibérément offensant ou absurde, quelle est la pire chose plausible que
generate_question()pourrait renvoyer, et qu’ajouterais-tu pour t’en protéger ?
Étape 4 : Une boucle de manche de trivia complète
4.1 Jusqu’ici, tout était des pièces testées de manière isolée : une source de questions, le sto…
👟 Indice de départ :
Jusqu’ici, tout était des pièces testées de manière isolée : une source de questions, le stockage des scores, la génération. Cette étape les branche dans ce à quoi ressemble réellement une manche en direct, poste une question, attends la première bonne réponse dans un délai, révèle-la, mets à jour le classement :
# bot.py (relevant part -- see examples/trivia-bot/bot.py for the full file)
import asyncio
from round import OPTION_LETTERS, check_answer, format_question, pick_question
from scores import award_point, leaderboard_text, load_scores
ROUND_TIME_LIMIT = 30 # seconds
async def run_round(channel: discord.abc.Messageable, topic: str | None = None) -> None:
question = pick_question(topic)
valid_letters = OPTION_LETTERS[: len(question["options"])]
await channel.send(
f"{format_question(question)}\n\nYou have {ROUND_TIME_LIMIT}s -- "
f"reply with just the letter ({'/'.join(valid_letters)})."
)
def is_candidate_answer(message: discord.Message) -> bool:
return (
message.channel == channel
and not message.author.bot
and message.content.strip().upper() in valid_letters
)
loop = asyncio.get_event_loop()
deadline = loop.time() + ROUND_TIME_LIMIT
winner = None
while True:
remaining = deadline - loop.time()
if remaining <= 0:
break
try:
message = await client.wait_for("message", check=is_candidate_answer, timeout=remaining)
except asyncio.TimeoutError:
break
if check_answer(question, message.content):
winner = message.author
break
await message.add_reaction("❌")
correct_letter = OPTION_LETTERS[question["answer_index"]]
correct_text = question["options"][question["answer_index"]]
if winner is not None:
scores = award_point(load_scores(), winner.id, str(winner.display_name))
await channel.send(
f"✅ {winner.mention} got it! The answer was **{correct_letter}) {correct_text}**.\n\n"
f"**Leaderboard:**\n{leaderboard_text(scores)}"
)
else:
await channel.send(f"⏰ Time's up! Nobody got it. The answer was **{correct_letter}) {correct_text}**.")
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
4.2 client.wait_for("message", check=..., timeout=...) est la façon de discord.py de mettre …
👟 Indice de départ :
client.wait_for("message", check=..., timeout=...) est la façon de discord.py de mettre en pause une fonction async jusqu’à ce qu’un type d’événement spécifique se produise, ici, tout message dans le même canal dont le contenu est exactement l’une des lettres de réponse valides. La boucle while le rappelle avec un timeout remaining qui diminue, afin que le budget de temps total de la manche soit ROUND_TIME_LIMIT, pas ROUND_TIME_LIMIT par mauvaise réponse, sans recalculer remaining, un canal plein de mauvaises réponses enthousiastes pourrait garder la manche ouverte indéfiniment.
Seule la première bonne réponse marque ; fais break dès que winner est défini. Les mauvaises réponses reçoivent une réaction ❌ au lieu d’un message d’erreur, un retour gratuit sans inonder le canal de réponses.
Enfin, trivia_command de l’Étape 1 devient une fine enveloppe autour de run_round :
@tree.command(name="trivia", description="Start a trivia round, optionally on a topic")
@app_commands.describe(topic="Optional topic for a freshly generated question")
async def trivia_command(interaction: discord.Interaction, topic: str | None = None) -> None:
starting_text = f"🎲 Starting a round about **{topic}**..." if topic else "🎲 Starting a round..."
await interaction.response.send_message(starting_text)
try:
await run_round(interaction.channel, topic)
except Exception as error: # keep the bot alive even if one round fails
print(f"Error running trivia round: {error!r}")
await interaction.channel.send("Something went wrong running that round -- see the bot's console log.")
Teste d’abord le timing de la manche avec un ROUND_TIME_LIMIT court
Mets ROUND_TIME_LIMIT = 5 pendant que tu règles la boucle, pour ne pas attendre 30 secondes par cycle de test pour découvrir que check_answer a un bug. Remonte-le à quelque chose de raisonnable pour le vrai jeu une fois que la boucle elle-même fonctionne.
🎯 Résultat attendu :
Vous devriez voir le résultat attendu sans erreur.
🩹 Si ça ne marche pas :
Consultez la section ⚠️ Pièges courants pour les problèmes habituels.
4.3 Vérifie
✅ Liste de vérification
- ✅
/triviaposte une question, puis attend réellement une réponse au lieu de se résoudre instantanément. - ✅ La première bonne réponse dans le délai est annoncée comme gagnante et reçoit un point via
award_point(). - ✅ Laisser le minuteur expirer sans bonne réponse révèle la réponse sans planter ni se bloquer.
- ✅ Exécuter
/triviadeux fois de suite démarre une nouvelle manche à chaque fois, en utilisant le classement mis à jour.
🤔 Question(s) socratique(s)
is_candidate_answervérifiemessage.channel == channelpour que les réponses des autres canaux du serveur ne comptent pas. Qu’arriverait-il à une manche dans un serveur occupé si cette vérification manquait ?- Le
try/except Exceptionautour derun_round(...)attrape n’importe quelle exception et publie une erreur générique au lieu de planter. Quel est le compromis d’attraper aussi largement dans un bot de longue durée par rapport au fait de laisser un vrai bug faire planter le processus bruyamment ?
Invite le bot et joue une vraie manche
En utilisant l’URL OAuth2 que tu as générée dans Configuration (avec les deux scopes bot et applications.commands), ouvre-la dans un navigateur et choisis un serveur que tu contrôles, crée un serveur de test gratuit si tu n’en as pas déjà un.
uv run python bot.py
Tu devrais voir imprimé Logged in as trivia-bot#1234 -- ready in 1 server(s).. Dans le serveur de test, tape /trivia et choisis-le dans le menu d’autocomplétion de Discord, avec ou sans topic. En quelques secondes, tu devrais voir la question postée, et après avoir répondu correctement (ou laissé le minuteur expirer) la réponse révélée et le classement mis à jour. Exécute /leaderboard à tout moment pour vérifier les scores sans démarrer une nouvelle manche.
⚠️ Pièges courants
- Oublier l’intention privilégiée « Message Content ». Cela doit être activé à deux endroits,
intents.message_content = Truedans le code, et l’interrupteur sous Bot → Privileged Gateway Intents dans le Portail des Développeurs. Rate l’interrupteur du portail etmessage.contentest silencieusement une chaîne vide pour chaque message, doncis_candidate_answerne correspond jamais à aucune réponse, peu importe comment elle est tapée. - Confondre le jeton du bot avec le secret client OAuth2. Le Portail des Développeurs montre les deux sur des onglets différents. Le jeton du bot (onglet Bot) est ce dont
client.run(...)a besoin ; le secret client (onglet OAuth2) est pour un flux d’authentification complètement différent que ce projet n’utilise jamais. Coller le secret client dansDISCORD_BOT_TOKENéchoue à se connecter avec une erreur déroutante. /trivian’apparaît jamais dans l’interface de Discord. C’est généralement l’une des deux causes :tree.sync()n’a jamais été appelé (ou jamais attendu) danson_ready, ou l’URL d’invitation du bot a été générée avant d’ajouter le scopeapplications.commands. Régénère l’URL d’invitation avec les deux scopes et ré-invite le bot si c’est le second qui pose problème.- Limites de débit sur le niveau gratuit du LLM, pires avec plusieurs manches d’affilée. Chaque appel
/trivia <topic>est une requête LLM séparée contre le quota de niveau gratuit de ton fournisseur, et un serveur occupé qui enchaîne plusieurs manches peut l’atteindre plus vite que ce que tu attendrais des seuls tests. Une erreur 429 n’est pas un bug, ajoute une courte nouvelle tentative avec backoff autour degenerate_question(), ou retombe sur la banque fixe quand la génération échoue. - Une manche qui ne se termine jamais parce que
remainingn’est pas recalculé. Si tu copies la boucle de manche mais que tu appellesclient.wait_for(..., timeout=ROUND_TIME_LIMIT)(la constante fixe) au lieu de la valeurremainingqui diminue, chaque mauvaise réponse redémarre effectivement le chronomètre, la manche peut durer bien plus longtemps que ce queROUND_TIME_LIMITpromet réellement.
Ce que tu viens de construire
Un bot de trivia Discord en direct avec deux sources de questions, une banque fixe et la génération par LLM de niveau gratuit sur n’importe quel sujet, une boucle de manche complète avec un vrai timing, et un classement persistant par joueur qui survit aux redémarrages. La source de questions, le scoring, et la logique de manche (questions.py, generate.py, scores.py, round.py) sont tous du Python simple sans discord, testés indépendamment avant de toucher un canal en direct ; seul bot.py sait que Discord existe du tout. Cette séparation vaut la peine d’être gardée à l’esprit en général : les quatre mêmes modules pourraient se retrouver derrière un bot Slack, un formulaire web, ou un jeu CLI à la place, sans aucun changement dans aucun d’entre eux.
Où aller à partir d’ici
- Ajoute un mode de jeu multi-manches,
/trivia rounds:5qui joue plusieurs questions d’affilée et annonce un gagnant global à la fin, au lieu d’une question par commande. - Suis les balises de difficulté ou de catégorie sur les questions générées (demande au LLM d’en inclure une dans sa réponse JSON) et laisse les joueurs choisir une catégorie avec
/trivia topic:... difficulty:hard. - Ajoute un classement par serveur au lieu d’un
scores.jsonglobal, indexescores.jsonpar(guild_id, user_id)au lieu de seulementuser_id, pour que deux serveurs Discord différents qui exécutent ce bot ne partagent pas un classement. - Déploie le bot quelque part qui reste allumé sans que ton ordinateur portable tourne, une petite VM toujours active, ou un niveau gratuit sur une plateforme comme Railway ou Fly.io, pour qu’il continue d’héberger des soirées trivia même quand tu n’es pas devant ta machine.
Partage ton projet avec la classe
Tu as construit quelque chose dont tu es fier ? examples/student-projects/ est une galerie de projets soumis par d’autres étudiants, et son README a un parcours complet et adapté aux débutants pour ajouter le tien via une pull request, même si tu n’as jamais utilisé git auparavant : forker le dépôt, créer une branche, commiter tes fichiers, et ouvrir la PR, une étape à la fois. Aucune expérience préalable de git n’est supposée.
Bienvenue à l’écriture de Python hors du navigateur. 🎓
Terminez chaque étape puis marquez ce projet comme terminé pour gagner ses points.