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.

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)

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.

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.

Now, let’s use the wait_for method to introduce a quiz feature.

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”.

Let’s get into the implementation.

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

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)

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()

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)

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:
                ...

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)

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.

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.

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)

Related Content