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.
Recap
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)
Adding a ‘Category’ Option to the ‘Quiz’ Feature
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.
1. Add the category
argument
Let’s add the category
argument to the quiz_command
.
@commands.command(name='quiz')
async def quiz_command(ctx: commands.Context, category: str):
...
2. Prepare quiz files for each category
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:
3. Set up a configuration file
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"
}
}
4. Create a function for reading the configuration file
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]
5. Adjust quiz.py
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]) ...
6. Testing
Run chatbot.py
to test if the quiz feature works correctly with category specification.
Adding a ‘Timeout’ Option to the ‘Quiz’ Feature
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.
1. Add the timeout
argument
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
):
2. Replace the time limit with timeout
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
)
3. Testing
Let’s test. The time limit should adjust correctly:
4. Bonus (Error Handling)
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.
timeout
argument as a string and process it internally.Conclusion
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.
Code for this session:
-
chatbot_config.json
(Quiz JSON files are moved to thedata
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!")