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)

前回までのようにコマンドを複数追加していくと、次第に同じような処理も増え、
on_messageが次第に肥大化していきます。

そのため、discord.pyにはコマンド追加を簡潔かつ容易にする
拡張ライブラリ discord.ext.commands が用意されています。
(参照: APIリファレンス, APIリファレンス2)

それでは、このコマンド拡張機能を使ってコードのリファクタリングを行っていきましょう。

はじめに、前回までは discord.Client を継承して独自のクライントMyClientを作成していましたが、
これを commands.Bot を継承するように変更します。

...
from discord.ext import commands


class MyClient(commands.Bot):
    ...

command.Botでは、__init__関数の引数にcommand_prefixという値が追加されているため、この調整を行います。
この値はコマンドの先頭に付ける記号を表すので、デフォルトで!を入れておくことにします。

class MyClient(commands.Bot):
    def __init__(self, command_prefix='!', *args, **kwargs):
        super().__init__(command_prefix, *args, **kwargs)
        ...

次に、各コマンドの定義を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に変更しています。

分離したコマンドを以下のようにしてボットに追加します。

client = MyClient(intents=intents)
bot_commands = [omikuji_command, quiz_command]
for command in bot_commands:
    client.add_command(command)
client.run(token)

最後に、on_messageで継承元のcommands.Boton_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)

上記調整後のコードは以下のようになりました。

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.py

    import 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.py

    import 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)
    

ここまで、機能別にファイルを分離してリファクタリングを行ってきました。
しかし現状のファイルを見たところ、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.py

    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)
    
        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.py

    import 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.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)
    

関連記事