DiscordチャットボットをPythonでつくる (8) - ボットコマンドにオプションを追加する

今回は、前回に引き続き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を使用すると、単純に関数の引数を追加するだけで実現できます。

以下のように category 引数を追加します。

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

次に、カテゴリ毎のクイズファイルを用意しましょう。
好きなカテゴリで問題を作成してください。

各問は以下のようなフォーマットになります。

{
    "question": "Pythonの`lambda`は何のために使いますか?",
    "choices": ["モジュールのインポート", "一時的な無名関数の定義", "ループの開始"],
    "answer_num": 2
},

今回はChatGPTにC++とLinuxに関する問題を作ってもらいました。

次に、カテゴリと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"
    }
}

次に、この設定ファイルを読み取る関数を作成します。
汎用的な関数であるため、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]
    

そして、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])
        ...
    

chatbot.pyを実行して動作確認を行いましょう。
カテゴリを指定してクイズ機能が正常に動作することが確認できました。

次に、クイズの回答を待機する時間をオプションで変更できるようにしましょう。

先程と同じく、discord.ext.commands を使用せずに
on_message上でこれを実現させる方法を考えてみましょう。

この場合、「メッセージが!quiz <カテゴリ> <制限時間>という形の場合、…」のように、
正規表現を使って判定をすることになります。また、制限時間の部分を数値に変換する必要があります。

しかし、discord.ext.commandsを使用すると、引数と型ヒントを追加するだけでこの工程が自動化できます。

以下のように、回答の制限時間を表すtimeout引数を追加します。
また、timeout: int として、timeout引数には整数値が入る事がわかるように型ヒントを付けます。

このようにすることで、discord.pyが自動でstrからintへの変換を行ってくれます。

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

そして、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
)

それでは動作確認してみましょう。
以下のように制限時間を正しく変更できるようになっているはずです。

!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]**引数の指定が間違っています!")

上記関数を追加すると、コンソール上ではなくチャット上にエラーを出力するようになります。

Note
しかし、このような関数を追加するのであれば、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]**引数の指定が間違っています!")
    

関連記事