DiscordチャットボットをPythonでつくる (7) - discord.pyの拡張機能を用いてリファクタリングする

今回は、discord.pyの拡張ライブラリdiscord.ext.commandsを使用して、
前回少し複雑化していたon_message関数のリファクタリングを行います。
前回までのまとめ
前回までのコードは以下のとおりです。
!omikujiコマンドと!quizコマンドが追加され、on_message関数が少し肥大化してきていました。
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.json'
        with open(quiz_file) as f:
            self.quiz_list = json.load(f)
    async def on_ready(self):
        """Discord接続時に実行される関数."""
        print(f'{self.user}として接続しました。')
    async def on_message(self, message: discord.Message):
        """メッセージ受信時に実行される関数."""
        # ボット自身のメッセージは無視する
        if message.author == self.user:
            return
        print(f'{message.author}よりメッセージを受信しました: {message.content}')
        if message.content == '!omikuji':
            choice = random.choice(['大吉', '吉', '小吉', '凶', '大凶'])
            await message.channel.send(f"あなたの今日の運勢は **{choice}** です!")
        if message.content == '!quiz':
            def quiz_reply_check(m: discord.Message):
                """クイズの返答を判定する関数."""
                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 = "😀 正解!"
                else:
                    result = f"😢 残念、正解は {quiz['answer_num']}でした。"
            except asyncio.TimeoutError:
                result = f"😢 時間切れです。正解は {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)
    拡張ライブラリdiscord.ext.commandsを使用する
前回までのようにコマンドを複数追加していくと、次第に同じような処理も増え、
on_messageが次第に肥大化していきます。
そのため、discord.pyにはコマンド追加を簡潔かつ容易にする
拡張ライブラリ discord.ext.commands が用意されています。
(参照: APIリファレンス, APIリファレンス2)
それでは、このコマンド拡張機能を使ってコードのリファクタリングを行っていきましょう。
    1. commands.Bot を継承する
はじめに、前回までは discord.Client を継承して独自のクライントMyClientを作成していましたが、
これを commands.Bot を継承するように変更します。
...
from discord.ext import commands
class MyClient(commands.Bot):
    ...
    2. __init__関数の引数を変更する
command.Botでは、__init__関数の引数にcommand_prefixという値が追加されているため、この調整を行います。
この値はコマンドの先頭に付ける記号を表すので、デフォルトで!を入れておくことにします。
class MyClient(commands.Bot):
    def __init__(self, command_prefix='!', *args, **kwargs):
        super().__init__(command_prefix, *args, **kwargs)
        ...3. 各コマンドの定義を分離する
次に、各コマンドの定義をon_messageから分離して関数化します。
分離した関数は、commands.Context型のctxを第一引数とした関数にします。
先に結論を書くと、!omikujiコマンドの場合は以下のようになります。
@commands.command(name='omikuji')
async def omikuji_command(ctx: commands.Context):
    """おみくじを引く."""
    choice = random.choice(['大吉', '吉', '小吉', '凶', '大凶'])
    await ctx.channel.send(f"あなたの今日の運勢は **{choice}** です!")- 関数には@commands.command()デコレータを付与します。nameでコマンド名を指定します。
- 関数の第一引数を ctx: commands.Contextとします。
 ctxは文脈(コンテキスト)を意味する変数であり、ctx.channel.sendで現在のチャンネルにメッセージを送信できます。
 他の属性に関しては、APIリファレンスを参照してください。
- docstringの内容がコマンドヘルプに表示されます。
同様に!quizコマンドも分離すると以下のようになります。
@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(ctx.bot.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)- ctx.authorでメッセージの送信者が取得できます。
- その他のselfであった部分をctx.botに変更しています。
    4. add_command でコマンドを追加する
分離したコマンドを以下のようにしてボットに追加します。
client = MyClient(intents=intents)
bot_commands = [omikuji_command, quiz_command]
for command in bot_commands:
    client.add_command(command)
client.run(token)
    5. on_message で継承元のon_messageを実行する
最後に、on_messageで継承元のcommands.Botのon_messageを実行するように、
以下のように変更します。
async def on_message(self, message: discord.Message):
    """メッセージ受信時に実行される関数."""
    # ボット自身のメッセージは無視する
    if message.author == self.user:
        return
    print(f'{message.author}よりメッセージを受信しました: {message.content}')
    await super().on_message(message)6. 調整後のコードまとめ
上記調整後のコードは以下のようになりました。
import asyncio
import json
import random
import discord
from discord.ext import commands
class MyClient(commands.Bot):
    def __init__(self, command_prefix='!', *args, **kwargs):
        super().__init__(command_prefix, *args, **kwargs)
        quiz_file = 'python_problems.json'
        with open(quiz_file) as f:
            self.quiz_list = json.load(f)
    async def on_ready(self):
        """Discord接続時に実行される関数."""
        print(f'{self.user}として接続しました。')
    async def on_message(self, message: discord.Message):
        """メッセージ受信時に実行される関数."""
        # ボット自身のメッセージは無視する
        if message.author == self.user:
            return
        print(f'{message.author}よりメッセージを受信しました: {message.content}')
        await super().on_message(message)
@commands.command(name='omikuji')
async def omikuji_command(ctx: commands.Context):
    """おみくじを引く."""
    choice = random.choice(['大吉', '吉', '小吉', '凶', '大凶'])
    await ctx.channel.send(f"あなたの今日の運勢は **{choice}** です!")
@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(ctx.bot.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)
with open('.discord_token') as f:
    token = f.read().strip()
intents = discord.Intents.default()
intents.message_content = True
client = MyClient(intents=intents)
bot_commands = [omikuji_command, quiz_command]
for command in bot_commands:
    client.add_command(command)
client.run(token)コマンド毎にファイルを分ける
次に、分離した各コマンド用の関数を、管理を容易にするため別ファイルに分けましょう。
commandsディレクトリを作成し、そこにコマンド別のファイルを作成して分離します。
- 
commands/omikuji.pyimport random from discord.ext import commands @commands.command(name='omikuji') async def omikuji_command(ctx: commands.Context): """おみくじを引く.""" choice = random.choice(['大吉', '吉', '小吉', '凶', '大凶']) await ctx.channel.send(f"あなたの今日の運勢は **{choice}** です!")
- 
commands/quiz.pyimport asyncio import random import discord from discord.ext import commands @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(ctx.bot.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)
- 
chatbot.py(カスタマイズしやすいようにbot_commandsを上に持ってきました。)import json import discord from discord.ext import commands from commands.omikuji import omikuji_command from commands.quiz import quiz_command BOT_COMMANDS = [omikuji_command, quiz_command] class MyClient(commands.Bot): def __init__(self, command_prefix='!', *args, **kwargs): super().__init__(command_prefix, *args, **kwargs) quiz_file = 'python_problems.json' with open(quiz_file) as f: self.quiz_list = json.load(f) async def on_ready(self): """Discord接続時に実行される関数.""" print(f'{self.user}として接続しました。') async def on_message(self, message: discord.Message): """メッセージ受信時に実行される関数.""" # ボット自身のメッセージは無視する if message.author == self.user: return print(f'{message.author}よりメッセージを受信しました: {message.content}') await super().on_message(message) with open('.discord_token') as f: token = f.read().strip() intents = discord.Intents.default() intents.message_content = True client = MyClient(intents=intents) for command in BOT_COMMANDS: client.add_command(command) client.run(token)
    クイズデータの読み込みを __init__ から取り除きたい
ここまで、機能別にファイルを分離してリファクタリングを行ってきました。
しかし現状のファイルを見たところ、MyClientの __init__ でクイズデータを読み込んでおり、
この部分だけ機能別に分離できていないのが気になります。
いくつかの方法が考えられますが、今回は__init__のクイズファイル読み込み部分を取り除き、
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)実行してみる
最後に、ちゃんとコマンドが機能するか確認しましょう。
 
また、!helpコマンドでコマンド一覧が表示されます。
 
まとめ
今回は、discord.pyの拡張機能であるdiscord.ext.commandsを使用して、
コードのリファクタリングを行いました。
機能別にファイルを分けることで、コードが少しスッキリして管理もしやすくなりました。
次回は、discord.ext.commandsの機能についてより掘り下げを行い、クイズ機能の強化を行います。
今回のコード
- 
chatbot.pyimport discord from discord.ext import commands from commands.omikuji import omikuji_command from commands.quiz import quiz_command BOT_COMMANDS = [omikuji_command, quiz_command] class MyClient(commands.Bot): def __init__(self, command_prefix='!', *args, **kwargs): super().__init__(command_prefix, *args, **kwargs) async def on_ready(self): """Discord接続時に実行される関数.""" print(f'{self.user}として接続しました。') async def on_message(self, message: discord.Message): """メッセージ受信時に実行される関数.""" # ボット自身のメッセージは無視する if message.author == self.user: return print(f'{message.author}よりメッセージを受信しました: {message.content}') await super().on_message(message) with open('.discord_token') as f: token = f.read().strip() intents = discord.Intents.default() intents.message_content = True client = MyClient(intents=intents) for command in BOT_COMMANDS: client.add_command(command) client.run(token)
- 
commands/omikuji.pyimport random from discord.ext import commands @commands.command(name='omikuji') async def omikuji_command(ctx: commands.Context): """おみくじを引く.""" choice = random.choice(['大吉', '吉', '小吉', '凶', '大凶']) await ctx.channel.send(f"あなたの今日の運勢は **{choice}** です!")
- 
commands/quiz.pyimport 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)