DiscordチャットボットをPythonでつくる (8) - ボットコマンドにオプションを追加する
![今回は、前回に引き続きdiscord.ext.commandsを使用し、チャットボットのコマンドにオプションを追加します。「クイズ」機能に新たに「カテゴリ」オプションを追加し、様々なカテゴリのクイズを出題できるようにします。さらに「タイムアウト」オプションを追加し、制限時間を調整できるようにします。 今回は、前回に引き続きdiscord.ext.commandsを使用し、チャットボットのコマンドにオプションを追加します。「クイズ」機能に新たに「カテゴリ」オプションを追加し、様々なカテゴリのクイズを出題できるようにします。さらに「タイムアウト」オプションを追加し、制限時間を調整できるようにします。](/images/chatbot.webp)
今回は、前回に引き続きdiscord.ext.commands
を使用し、チャットボットのコマンドにオプションを追加します。
(参照: ドキュメント)
「クイズ」機能に新たに「カテゴリ」オプションを追加し、様々なカテゴリのクイズを出題できるようにします。
さらに「タイムアウト」オプションを追加し、制限時間を調整できるようにします。
前回までのまとめ
前回は、discord.py
の拡張機能であるdiscord.ext.commands
を使用して、
コードのリファクタリングを行いました。
「クイズ」機能のコードcommands/quiz.py
は以下のようになっています。
import asyncio
import json
import random
import discord
from discord.ext import commands
quiz_file = 'python_problems.json'
with open(quiz_file) as f:
quiz_list = json.load(f)
@commands.command(name='quiz')
async def quiz_command(ctx: commands.Context):
"""3択クイズを出題する."""
def quiz_reply_check(m: discord.Message):
"""クイズの返答を判定する関数."""
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 = "😀 正解!"
else:
result = f"😢 残念、正解は {quiz['answer_num']}でした。"
except asyncio.TimeoutError:
result = f"😢 時間切れです。正解は {quiz['answer_num']}でした。"
await ctx.channel.send(result)
「クイズ」機能に「カテゴリ」オプションを追加する
早速、「クイズ」機能に「カテゴリ」オプションを追加しましょう。
discord.ext.commands
を使用せずにon_message
上でこれを実現しようとすると、
「メッセージが!quiz <カテゴリ>
という形の場合、…」のように、
<カテゴリ>
部分の文字列を正規表現を使って取得するなどして処理を行う必要があります。
しかし、discord.ext.commands
を使用すると、単純に関数の引数を追加するだけで実現できます。
1. category
引数を追加する
以下のように category
引数を追加します。
@commands.command(name='quiz')
async def quiz_command(ctx: commands.Context, category: str):
...
2. カテゴリ毎のクイズファイルを用意する
次に、カテゴリ毎のクイズファイルを用意しましょう。
好きなカテゴリで問題を作成してください。
各問は以下のようなフォーマットになります。
{
"question": "Pythonの`lambda`は何のために使いますか?",
"choices": ["モジュールのインポート", "一時的な無名関数の定義", "ループの開始"],
"answer_num": 2
},
今回はChatGPTにC++とLinuxに関する問題を作ってもらいました。
3. 設定ファイルを用意する
次に、カテゴリとJSONファイルの対応付けを行います。
後ほど追加や削除をしやすくなるように、コードに直接書くのではなく、
設定ファイルを用意してそれを読み込むような仕組みにします。
chatbot_config.json
という名前のファイルを作業ディレクトリに作成し、以下のように記載します。
(クイズファイルはdata
ディレクトリを作成して整理しました。)
{
"quiz": {
"categories": {
"python": {
"path": "data/python_problems.json"
},
"c++": {
"path": "data/cpp_problems.json"
},
"linux": {
"path": "data/linux_problems.json"
}
},
"default_category": "python"
}
}
4. 設定ファイルを読み取る関数を作成する
次に、この設定ファイルを読み取る関数を作成します。
汎用的な関数であるため、utils.py
というファイルを作成してそこに追加することにします。
-
utils.py
import json def load_config(key, config_file='chatbot_config.json'): """設定ファイルから指定されたキーの設定を取得する.""" with open(config_file) as f: data = json.load(f) return data[key]
5. quiz.py
を調整する
そして、quiz.py
を以下のように変更します。
-
commands/quiz.py
... from utils import load_config # クイズデータを先に読み込む. # `config`は以下構造のdict: # { # "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]** カテゴリの指定が間違っています!: {category}") return ... quiz = random.choice(quiz_lists[category]) ...
6. 動作確認
chatbot.py
を実行して動作確認を行いましょう。
カテゴリを指定してクイズ機能が正常に動作することが確認できました。
![](images/bot-quiz-with-category.webp)
「クイズ」機能に「タイムアウト」オプションを追加する
次に、クイズの回答を待機する時間をオプションで変更できるようにしましょう。
先程と同じく、discord.ext.commands
を使用せずに
on_message
上でこれを実現させる方法を考えてみましょう。
この場合、「メッセージが!quiz <カテゴリ> <制限時間>
という形の場合、…」のように、
正規表現を使って判定をすることになります。また、制限時間の部分を数値に変換する必要があります。
しかし、discord.ext.commands
を使用すると、引数と型ヒントを追加するだけでこの工程が自動化できます。
1. timeout
引数を追加する
以下のように、回答の制限時間を表すtimeout
引数を追加します。
また、timeout: int
として、timeout
引数には整数値が入る事がわかるように型ヒントを付けます。
このようにすることで、discord.py
が自動でstr
からint
への変換を行ってくれます。
async def quiz_command(
ctx: commands.Context,
category: str = config['default_category'],
timeout: int = 30
):
2. 制限時間の部分をtimeout
に置き換える
そして、30秒と指定していた制限時間をtimeout
変数に置き換えます。
また、問題文にも制限時間を表示するように変更しておきます。
...
quiz_str = (
f'Q: {quiz["question"]} (制限時間: {timeout}秒)\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. 動作確認
それでは動作確認してみましょう。
以下のように制限時間を正しく変更できるようになっているはずです。
![](images/bot-quiz-with-timeout.webp)
4. おまけ (エラーハンドリング)
!quiz
コマンドの第2引数に数値でない文字列を入力した場合、
コンソール上に以下のようなエラーが出力されます。
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'
このエラー出力は、quiz.py
にエラー処理用の関数を追加すると防ぐことが出来ます。
@quiz_command.error
async def quiz_command_error(
ctx: commands.Context, error: commands.CommandError
):
"""コマンドquiz_commandのエラー処理."""
if isinstance(error, commands.BadArgument):
await ctx.channel.send("**[ERROR]**引数の指定が間違っています!")
上記関数を追加すると、コンソール上ではなくチャット上にエラーを出力するようになります。
![](images/bot-quiz-with-timeout-error.webp)
timeout
引数をstr
型で受け取って内部で処理したほうが良さそうです。まとめ
今回は、discord.py
の拡張機能であるdiscord.ext.commands
の機能を利用して、
「クイズ」機能に「カテゴリ」オプションと「タイムアウト」オプションを追加しました。
また、この拡張機能を利用することでオプションの追加が簡単にできることがわかりました。
次回は、クイズの回答をより直感的にできるように、
解答を選択する「ボタン」をメッセージに追加する方法を紹介します。
今回のコード
-
chatbot_config.json
(クイズのJSONデータはdata
ディレクトリにまとめました){ "quiz": { "categories": { "python": { "path": "data/python_problems.json" }, "c++": { "path": "data/cpp_problems.json" }, "linux": { "path": "data/linux_problems.json" } }, "default_category": "python" } }
-
utils.py
import json def load_config(key, config_file='chatbot_config.json'): """設定ファイルから指定されたキーの設定を取得する.""" 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 # `config`は以下構造のdict: # { # "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 ): """3択クイズを出題する.""" def quiz_reply_check(m: discord.Message): """クイズの返答を判定する関数.""" return m.author == ctx.author and m.content.isdigit() if category not in quiz_lists.keys(): await ctx.channel.send(f"**[ERROR]** カテゴリの指定が間違っています!: {category}") return quiz = random.choice(quiz_lists[category]) quiz_str = ( f'Q: {quiz["question"]} (制限時間: {timeout}秒)\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 = "😀 正解!" else: result = f"😢 残念、正解は {quiz['answer_num']}でした。" except asyncio.TimeoutError: result = f"😢 時間切れです。正解は {quiz['answer_num']}でした。" await ctx.channel.send(result) @quiz_command.error async def quiz_command_error( ctx: commands.Context, error: commands.CommandError ): """コマンドquiz_commandのエラー処理.""" if isinstance(error, commands.BadArgument): await ctx.channel.send("**[ERROR]**引数の指定が間違っています!")