Building a Discord Chatbot with Python (6) - Adding a 'Quiz' Feature
In this post, we’ll introduce a method to wait for user replies and leverage it to add a new ‘Quiz’ feature to our chatbot.
A Recap of What We’ve Covered
Previously, we delved into how to reply to user messages.
We then employed this mechanism to incorporate a ‘fortune-telling’ feature.
import discord
import random
class MyClient(discord.Client):
async def on_ready(self):
"""Triggered when connecting to Discord."""
print(f'Connected as {self.user}.')
async def on_message(self, message: discord.Message):
"""Triggered when receiving a message."""
# Ignore messages sent by the bot itself
if message.author == self.user:
return
print(f'Received a message from {message.author}: {message.content}')
if message.content == '!omikuji':
choice = random.choice([
"Great Blessing", "Blessing", "Small Blessing",
"Misfortune", "Great Misfortune"
])
await message.channel.send(
f"Your fortune for today is: **{choice}**!"
)
with open('.discord_token') as f:
token = f.read().strip()
intents = discord.Intents.default()
intents.message_content = True
client = MyClient(intents=intents)
client.run(token)
Awaiting a Reply from the User
Till now, our chatbot has been capable of a one-round communication, responding to a user’s message.
But what if we want to capture a user’s response to the chatbot’s message?
This can be achieved using the wait_for
method of discord.Client
.
Using wait_for
to Await a User’s Reply
To wait for a message, you can proceed as follows (Refer: Documentation):
reply_message = await client.wait_for('message', check=<Message Checking Function>, timeout=<Timeout in Seconds>)
- Replace
<Message Checking Function>
with a function that defines which message to consider as a reply. - For
<Timeout in Seconds>
, specify the maximum seconds you want to wait for a reply. - If a reply is received within the stipulated time, it’s assigned to the
reply_message
variable. - If it times out, an
asyncio.TimeoutError
error is raised.
Adding a “Quiz” Feature
Now, let’s use the wait_for
method to introduce a quiz feature.
Specifications
The quiz feature will follow these specifications:
- Activated by entering
!quiz
. - A random question will be posed from a pre-arranged set of three-choice quizzes.
- The user’s response will be awaited for a maximum of 30 seconds.
- Depending on the response, the bot will reply with “Correct” or “Incorrect”.
Implementation
Let’s get into the implementation.
Preparing the Three-choice Quiz Set
First, we’ll prepare the quizzes we’ll be using.
For this tutorial, each question will be structured in the following format in a JSON file:
{
"question": "What does the `enumerate` function return in Python?",
"choices": ["List of indices", "List of elements", "List of tuples containing index and element"],
"answer_num": 3
},
In this guide, we’ll use a set of Python questions created by ChatGPT, available here: Sample Data
Where to Load the Quiz Data
While the quiz data is recorded in a JSON file, reading it every time the !quiz
command is executed would slow down the bot’s operations.
Thus, it’s more efficient to load this data when the bot starts.
We’ll read the quiz data during the initialization of MyClient
as follows:
import json
...
class MyClient(discord.Client):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
quiz_file = 'python_problems.en.json'
with open(quiz_file) as f:
self.quiz_list = json.load(f)
Message Checking Function
Since our quiz is a three-choice format, we’ll accept digit inputs as valid.
The message should also come from the user who entered the !quiz
command.
The quiz_reply_check
function will look like this:
class MyClient(discord.Client):
...
async def on_message(self, message: discord.Message):
...
if message.content == '!quiz':
def quiz_reply_check(m: discord.Message):
"""Function to validate quiz replies."""
return m.author == message.author and m.content.isdigit()
Posing the Quiz Question
Let’s consider the posing question part.
The quiz data is stored in self.quiz_list
, from which we’ll randomly choose a question:
class MyClient(discord.Client):
...
async def on_message(self, message: discord.Message):
...
if message.content == '!quiz':
...
quiz = random.choice(self.quiz_list)
quiz_str = (
f'Q: {quiz["question"]}\n\n' +
'\n'.join([f'[{i+1}] {c}' for (i, c) in enumerate(quiz["choices"])])
)
await message.channel.send(quiz_str)
Awaiting the Answer
To wait for an answer, we’ll use the wait_for
method.
This method will raise an asyncio.TimeoutError
if the time limit is exceeded.
To handle this, we’ll use a try
statement:
import asyncio
...
class MyClient(discord.Client):
...
async def on_message(self, message: discord.Message):
...
if message.content == '!quiz':
...
await message.channel.send(quiz_str)
try:
reply_message = await self.wait_for('message', check=quiz_reply_check, timeout=30)
...
except asyncio.TimeoutError:
...
Responding Based on the Answer
Finally, the bot will send a message based on the received answer:
class MyClient(discord.Client):
...
async def on_message(self, message: discord.Message):
...
if message.content == '!quiz':
...
await message.channel.send(quiz_str)
try:
reply_message = await self.wait_for('message', check=quiz_reply_check, timeout=30)
if reply_message.content == str(quiz['answer_num']):
result = "😀 Correct!"
else:
result = f"😢 Sorry, the correct answer is {quiz['answer_num']}."
except asyncio.TimeoutError:
result = f"😢 Time's up! The correct answer is {quiz['answer_num']}."
await message.channel.send(result)
Execution
Now, let’s give it a go.
Upon executing the !quiz
command, a random question will be posed.
Try various patterns like answering correctly, answering incorrectly, waiting for a timeout, or entering non-numeric characters.
Conclusion
Today, we introduced the wait_for
function that waits for a response and used it to add a new quiz feature.
However, our quiz feature isn’t complete yet!
…But, before we proceed further, since our on_message
function is getting cluttered, we plan to refactor it in the next article.
Last Code
import asyncio
import json
import random
import discord
class MyClient(discord.Client):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
quiz_file = 'python_problems.en.json'
with open(quiz_file) as f:
self.quiz_list = json.load(f)
async def on_ready(self):
"""Triggered when connecting to Discord."""
print(f'Connected as {self.user}.')
async def on_message(self, message: discord.Message):
"""Triggered when receiving a message."""
# Ignore messages sent by the bot itself
if message.author == self.user:
return
print(f'Received a message from {message.author}: {message.content}')
if message.content == '!omikuji':
choice = random.choice([
"Great Blessing", "Blessing", "Small Blessing",
"Misfortune", "Great Misfortune"
])
await message.channel.send(
f"Your fortune for today is: **{choice}**!"
)
if message.content == '!quiz':
def quiz_reply_check(m: discord.Message):
"""Function to validate quiz replies."""
return m.author == message.author and m.content.isdigit()
quiz = random.choice(self.quiz_list)
quiz_str = (
f'Q: {quiz["question"]}\n\n' +
'\n'.join([
f'[{i+1}] {c}' for (i, c) in enumerate(quiz["choices"])
])
)
await message.channel.send(quiz_str)
try:
reply_message = await self.wait_for(
'message', check=quiz_reply_check, timeout=30
)
if reply_message.content == str(quiz['answer_num']):
result = "😀 Correct!"
else:
result = \
f"😢 Sorry, the correct answer is {quiz['answer_num']}."
except asyncio.TimeoutError:
result = \
f"😢 Time's up! The correct answer is {quiz['answer_num']}."
await message.channel.send(result)
with open('.discord_token') as f:
token = f.read().strip()
intents = discord.Intents.default()
intents.message_content = True
client = MyClient(intents=intents)
client.run(token)