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.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)
クイズデータの読み込みを __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.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)