Building a Discord Chatbot with Python (8) - Adding Options to Bot Commands

Continuing from our last session, we’ll be using discord.ext.commands to add options to our chatbot commands.
(Reference: Documentation)

We’ll add a new ‘category’ option to the ‘quiz’ feature, allowing for quizzes in various categories.
Additionally, we’ll introduce a ’timeout’ option to adjust the time limit for answering questions.

In our previous article, we refactored the code using discord.py’s extension, discord.ext.commands.

The code for the ‘quiz’ feature, found in commands/quiz.py, is as follows:

import asyncio
import json
import random

import discord
from discord.ext import commands


quiz_file = 'python_problems.en.json'
with open(quiz_file) as f:
    quiz_list = json.load(f)


@commands.command(name='quiz')
async def quiz_command(ctx: commands.Context):
    """Pose a three-choice quiz"""

    def quiz_reply_check(m: discord.Message):
        """Function to validate quiz replies."""
        return m.author == ctx.author and m.content.isdigit()

    quiz = random.choice(quiz_list)
    quiz_str = (
        f'Q: {quiz["question"]}\n\n' +
        '\n'.join([
            f'[{i+1}] {c}' for (i, c) in enumerate(quiz["choices"])
        ])
    )
    await ctx.channel.send(quiz_str)
    try:
        reply_message = await ctx.bot.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 ctx.channel.send(result)

Let’s dive right in and implement the ‘category’ option for our ‘quiz’ feature.

Attempting this on on_message without using discord.ext.commands would require recognizing messages like !quiz <category>, and then using regular expressions to process the <category> part.

However, with discord.ext.commands, this can be achieved just by adding an argument to the quiz_command function.

Let’s add the category argument to the quiz_command.

@commands.command(name='quiz')
async def quiz_command(ctx: commands.Context, category: str):
    ...

Next, create quiz files for each category of your choosing.

The format of each question is as follows:

{
    "question": "What is `lambda` used for in Python?",
    "choices": ["Importing modules", "Defining a temporary anonymous function", "Starting a loop"],
    "answer_num": 2
}

For this tutorial, I used ChatGPT to generate questions related to C++ and Linux:

Then, map categories to their respective JSON files.

To ease future modifications, create chatbot_config.json in our working directory and write the following JSON data.
(I created data directory and move quiz files into it.)

{
    "quiz": {
        "categories": {
            "python": {
                "path": "data/python_problems.en.json"
            },
            "c++": {
                "path": "data/cpp_problems.en.json"
            },
            "linux": {
                "path": "data/linux_problems.en.json"
            }
        },
        "default_category": "python"
    }
}

Next, design a function to read this configuration file.
Because the function can be used anywhere, let’s add it to a new file named utils.py.

  • utils.py

    import json
    
    
    def load_config(key, config_file='chatbot_config.json'):
        """Get the setting for the given key."""
        with open(config_file) as f:
            data = json.load(f)
        return data[key]
    

Now, modify quiz.py as follows:

  • commands/quiz.py

    ...
    from utils import load_config
    
    
    # Initially load quiz data.
    # `config` is a dict of the following format:
    # {
    #     "categories": {
    #         "<category>": {
    #             "path": "<path>"
    #         }
    #     },
    #     "default_category": "<category>"
    # }
    config = load_config('quiz')
    categories = config['categories']
    quiz_lists = {}
    for key in categories.keys():
        with open(categories[key]["path"]) as f:
            quiz_lists[key] = json.load(f)
    
    
    @commands.command(name='quiz')
    async def quiz_command(ctx: commands.Context, category: str = config['default_category']):
        if category not in quiz_lists.keys():
            await ctx.channel.send(f"**[ERROR]** Wrong category!: {category}")
            return
        ...
        quiz = random.choice(quiz_lists[category])
        ...
    

Run chatbot.py to test if the quiz feature works correctly with category specification.

Next, let’s allow users to adjust the time limit for answering questions.

As before, if you think of implementing this on on_message without discord.ext.commands, you would use regular expressions to interpret messages like !quiz <category> <time limit>. Also, converting the time limit from string to number would be needed.

However, by using discord.ext.commands, simply add an argument and a type hint.

Let’s add the timeout argument as shown below.
By providing a type hint with timeout: int, it indicates that the timeout argument should be an integer.

By doing this, discord.py will automatically handle the conversion from str to int.

async def quiz_command(
    ctx: commands.Context,
    category: str = config['default_category'],
    timeout: int = 30
):

Replace the preset 30-second time limit with the timeout variable. And adjust the question prompt to display the time limit.

...
quiz_str = (
    f'Q: {quiz["question"]} (Time limit: {timeout} seconds)\n\n' +
    '\n'.join(
        [f'[{i+1}] {c}' for (i, c) in enumerate(quiz["choices"])]
    )
)
...
reply_message = await ctx.bot.wait_for(
    'message', check=quiz_reply_check, timeout=timeout
)

Let’s test. The time limit should adjust correctly:

If a non-numeric string is input as the second argument of the !quiz command, an error is displayed on the console:

2023-08-28 06:15:23 ERROR    discord.ext.commands.bot Ignoring exception in command quiz
Traceback (most recent call last):
  File ".../discord-chatbot/.venv/lib/python3.11/site-packages/discord/ext/commands/converter.py", line 1246, in _actual_conversion
    return converter(argument)
           ^^^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'a'

By adding the following error-handling function to quiz.py, this can be prevented:

@quiz_command.error
async def quiz_command_error(
    ctx: commands.Context, error: commands.CommandError
):
    """Error handling for quiz_command."""
    if isinstance(error, commands.BadArgument):
        await ctx.channel.send("**[ERROR]**Wrong argument!")

With the above function, errors are displayed in the chat rather than on the console.

Note
If you want to add this kind of functions, it might be easier to receive the timeout argument as a string and process it internally.

In this article, using discord.py’s discord.ext.commands, we enhanced the ‘quiz’ feature with ‘category’ and ’timeout’ options.
And we saw this extension simplify the addition of options.

In our next installment, we’ll introduce how to add “buttons” to messages, allowing for more intuitive answering of quiz questions.

  • chatbot_config.json
    (Quiz JSON files are moved to the data directory)

    {
        "quiz": {
            "categories": {
                "python": {
                    "path": "data/python_problems.en.json"
                },
                "c++": {
                    "path": "data/cpp_problems.en.json"
                },
                "linux": {
                    "path": "data/linux_problems.en.json"
                }
            },
            "default_category": "python"
        }
    }
    
  • utils.py

    import json
    
    
    def load_config(key, config_file='chatbot_config.json'):
        """Get the setting for the given key."""
        with open(config_file) as f:
            data = json.load(f)
        return data[key]
    
  • commands/quiz.py

    import asyncio
    import json
    import random
    
    import discord
    from discord.ext import commands
    from utils import load_config
    
    
    # Initially load quiz data.
    # `config` is a dict of the following format:
    # {
    #     "categories": {
    #         "<category>": {
    #             "path": "<path>"
    #         }
    #     },
    #     "default_category": "<category>"
    # }
    config = load_config('quiz')
    categories = config['categories']
    quiz_lists = {}
    for key in categories.keys():
        with open(categories[key]["path"]) as f:
            quiz_lists[key] = json.load(f)
    
    
    @commands.command(name='quiz')
    async def quiz_command(
        ctx: commands.Context,
        category: str = config['default_category'],
        timeout: int = 30
    ):
        """Pose a three-choice quiz"""
    
        def quiz_reply_check(m: discord.Message):
            """Function to validate quiz replies."""
            return m.author == ctx.author and m.content.isdigit()
    
        if category not in quiz_lists.keys():
            await ctx.channel.send(f"**[ERROR]** Wrong category!: {category}")
            return
    
        quiz = random.choice(quiz_lists[category])
        quiz_str = (
            f'Q: {quiz["question"]} (Time limit: {timeout} seconds)\n\n' +
            '\n'.join(
                [f'[{i+1}] {c}' for (i, c) in enumerate(quiz["choices"])]
            )
        )
        await ctx.channel.send(quiz_str)
        try:
            reply_message = await ctx.bot.wait_for(
                'message', check=quiz_reply_check, timeout=timeout
            )
            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 ctx.channel.send(result)
    
    
    @quiz_command.error
    async def quiz_command_error(
        ctx: commands.Context, error: commands.CommandError
    ):
        """Error handling for quiz_command."""
        if isinstance(error, commands.BadArgument):
            await ctx.channel.send("**[ERROR]**Wrong argument!")
    

Related Content