はじめに
この記事は前回Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.pyの続きです.
本記事では,Botを大規模化するに必要となるであろうdiscord.ext.commands.BotとCogについての説明やBotの応答を華やかにするEmbedに関する説明をいたします.
全7回を予定しており現在5記事まで執筆を終えています.
- Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py
- Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)
- Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携
- Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する
- Pythonで始める録音機能付きDiscord Bot: (5) Discord APIを直接操作する
機能追加を色々試す
試しに,前回作成したBotに対して様々な機能を追加してみましょう.
既存のBotは何かしらの接頭文字/
や>
にコマンド名を追加した>help
のような文字列で命令を行う場合が多いです.
ここでもそれに倣ってすべて$
で始まる文字列に対してコマンドを設定していきます.
-
$yay
- 前回作ったものと同様,単に
You're on discord.py
と返信するだけの機能です.
- 前回作ったものと同様,単に
-
$dice A B
- B面体サイコロをA回振った結果を返します.
- TRPGなどでよく使用される1D100, 3D6などに応用可能です
-
$highlow
- 2~10,J,Q,K,Aから2つランダムに選択し片方だけを表示します
- このコマンドを打ったユーザーはもう片方がすでに表示されているカードに比べてより大きい値か小さい値かはたまた同じ値かを予想しリアクションで返答します
- 返答後,もう片方のカードを表示させ,当たっていれば
おめでとう!
,外れていれば残念!
と返答します.
Dice機能を追加
まずは,$dice
機能ですが少し文字列の解析が必要となりますが,ほとんど$yay
と変わらず単純な返答だけで済みそうです.また,ランダムに選択するため,random
モジュールを入れておきましょう.
import discord
import random
TOKEN = "..."
class Bot(discord.Client):
async def on_ready(self):
print("起動しました")
async def on_message(self, message):
if message.author.bot:
return
if message.content == '$yay':
await message.channel.send('You\'re on discord.py')
elif message.content[:5] == '$dice':
# $dice, 3, 6 のように分割されます
argv = message.content.split()
argc = len(argv)
if argc != 3:
return await message.channel.send('引数の数が不正です.')
try:
a, b = int(argv[1]), int(argv[2])
result = random.choices(range(1, b + 1), k=a)
return await message.channel.send(
f'{a}D{b}の結果は{sum(result)}です.\n内訳{result}'
)
except ValueError:
return await message.channel.send('引数は整数である必要があります')
Bot().run(TOKEN)
Pythonの基本的な機能で実装は行えましたが,エラーハンドリングを細かく行うとこのように少し長めのコードになってしまいます.
実際に実行してみて,ちゃんとした値や変な値を入れた際の応答を見てみることにします.
問題なく動いていそうです.
High & Low機能を追加
次に$highlow
を作成してみます.
import asyncio
import discord
import random
TOKEN = "..."
class Bot(discord.Client):
async def on_ready(self):
print("起動しました")
async def on_message(self, message):
if message.author.bot:
return
if message.content == '$yay':
await message.channel.send('You\'re on discord.py')
elif message.content[:5] == '$dice':
# 中略
elif message.content == '$highlow':
card1 = Card.choice() # このコードの後半に定義してあります
card2 = Card.choice()
bot_message = await message.channel.send(
f'1枚目のカードは{card1}です.\n' +
'2枚目のカードはこれより大きいでしょうか\n' +
'30秒以内に回答してください!\n' +
'大きい: ⬆ 小さい: ⬇ 同じ: ↔'
)
emojis = ('⬆', '⬇', '↔')
# 送信したメッセージにリアクションを追加
for emoji in emojis:
await bot_message.add_reaction(emoji)
def check_reaction(reaction: discord.Reaction, member: discord.Member):
return all([
member.id == message.author.id,
reaction.emoji in emojis,
reaction.message.id == bot_message.id
])
try:
# 入力待ちはこのように書くことができます(後述)
reaction, member = await self.wait_for(
'reaction_add', check=check_reaction, timeout=30
)
except asyncio.TimeoutError:
return await message.channel.send('時間切れです!')
emoji = reaction.emoji
# 正解判定
if any([
emoji == emojis[0] and card1 < card2,
emoji == emojis[1] and card1 > card2,
emoji == emojis[2] and card1 == card2
]):
await message.channel.send('おめでとうございます!')
else:
await message.channel.send('残念!')
await message.channel.send(f'2枚目は{card2}です!')
# コードが複雑にならないように,カードはカードで別でクラスとして定義する
# 演算子をオーバーロードすればコードを簡潔に書くことが可能
class Card:
nums = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
def __init__(self, num):
if not num in self.nums:
raise ValueError
self.num = num
@classmethod
def choice(cls):
return cls(random.choice(cls.nums))
def __str__(self):
return self.num
def __lt__(self, other):
my_card = self.nums.index(self.num)
other_card = self.nums.index(other.num)
return my_card < other_card
def __gt__(self, other):
my_card = self.nums.index(self.num)
other_card = self.nums.index(other.num)
return my_card > other_card
def __eq__(self, other):
my_card = self.nums.index(self.num)
other_card = self.nums.index(other.num)
return my_card == other_card
Bot().run(TOKEN)
完成したらこの処理を実行してみましょう.
さて,この処理の中で最も重要な箇所はself.wait_for
です.
この関数を用いることで,ユーザーの入力を待ってそれに対して,処理内容を変えるといった複雑なコマンドを実装することができます.
await Client.wait_for(event: str, check, timeout: int)
という形で引数を取りますが,第一引数はイベント名に相当します.
例えば,message
ならユーザーからメッセージが送られてくるまで待ち続け,reaction_add
なら何かしらのメッセージにリアクションが追加
されるまで待ち続けます.
これらのイベント名は,前回イベントが発火した際に呼び出されるコルーチン(イベントハンドラ)を定義する際にon_ready
,on_message
といった関数名にしたとおもいますが,その関数名からon_
を取り除いた文字列をこの第一引数に指定します.
引数checkには,イベント名が呼び出されたときに,イベントに渡された値が待ちを解除するか適するかのチェックを行う関数を渡します.具体的には,イベントハンドラに渡される値を引数とし,条件を満たせばTrue
を返す関数オブジェクトを渡します.今回の場合は待機しているイベントがリアクションの追加(=reaction_add
)なので以下の表やリファレンスを参照すると,第一引数に付けられたリアクション,第二引数にリアクションを付けたユーザーが渡されます.
今回の例ではコマンドを投稿した本人がBotが投稿したメッセージに**,,のいずれかのリアクションを行った**場合のみTrue
を返すように設定しています.
最後にtimeout
ですが,これはユーザーに対する待ち時間です.None
を渡すなど,永遠に応答を待つこともできますがサーバーリソースを逼迫させないためにもある程度の待ち時間を設定しておくといいでしょう.指定した秒数をオーバーすると例外が発生します.その例外がasyncio.TimeoutError
です.このエラーをハンドリングするために1行目にasyncio
(標準パッケージです)をインポートしています.
[備考] 主なイベント
リファレンスにもある通りすべてコルーチンで定義する必要があります
イベント名 | イベントハンドラに渡される引数の型 | 説明 |
---|---|---|
on_ready | - | クライアントの準備が完了した |
on_message | (message: discord.Message) | メッセージが送信された |
on_message_edit | (before: discord.Message, after: discord.Message) | メッセージが変更された |
on_message_delete | (message: discord.Message) | メッセージが削除された |
on_reaction_add | (reaction: discord.Reaction, member: discord.Member) | リアクションが追加された |
on_reaction_remove | (reaction: discord.Reaction, member: discord.Member) | リアクションが削除された |
on_member_join | (member: discord.Member) | メンバーが加入した |
on_member_leave | (member: discord.Member) | メンバーが脱退した |
on_guild_join | (guild: discord.Guild) | サーバーがボットを導入した |
on_guild_leave | (guild: discord.Guild) | サーバーがボットを追放した |
「あれ?
これどんどん__main__.py
のon_message
だけバカでかいコードにならない?」
そのとおりです.そこでdiscord.pyにはこれを防止するため便利な機能が実装されています.ここで使うのは以下の二つです.
前者は,discord.Clientでできることはすべて実行することができ,Bot構築に便利な関数や文法を提供するもので,後者は複数のコマンドやイベントリスナーをクラスでグルーピングし機能ごとにサービスを提供することができるようになるものです.
例えば今回の場合は$yay
は挨拶なのでGreeting
,$dice
,$highlow
はゲームっぽいのでGame
といった名前でCogを作り,BotにそのCogを追加することでその機能を利用することができるようになります.
まずはこれまでこれまで使っていたClientをBotとして使えるようにします,その後Cogを使ってここまでで作成した機能を移植することにします.
Botを使ってみよう
Botの作成
Botを使うといった際Clientと同様,直接インスタンス化するかBotを継承するかのどちらでも実装可能ですがここでは機能拡張が容易な継承する方法で実装していくことにします.また,いつまでも__main__.py
に書き続けるのもよくないのでフォルダ分けを行います.
以下の5つのファイルを作成/編集します.
./src/app/dbot/__init__.py
./src/app/dbot/cogs/__init__.py
./src/app/dbot/core/__init__.py
./src/app/dbot/core/bot.py
-
./src/app/dbot/__main__.py
(編集)
上3つは作成するだけで何も記入しません.
import discord
from discord.ext import commands
import traceback
class DBot(commands.Bot):
def __init__(self, token):
self.token = token
super().__init__(command_prefix="$")
def load_cogs(self):
cogs = ["dbot.cogs.Game"]
for cog in cogs:
self.remove_cog(cog)
async def on_ready(self):
print('起動しました')
# 起動用の補助関数です
def run(self):
try:
self.loop.run_until_complete(self.start(self.token))
except discord.LoginFailure:
print("Discord Tokenが不正です")
except KeyboardInterrupt:
print("終了します")
self.loop.run_until_complete(self.logout())
except:
traceback.print_exc()
from dbot.core.bot import DBot
TOKEN = "..."
DBot(TOKEN).run()
まずはBotの継承部分ですが,親クラスのコンストラクタを呼び出す際に引数にcommand_prefix
を与えています.これをあらかじめ渡すことにより,このBotが定義するコマンドはすべて$
から始まるということを全コマンドに書かなくとも良くなります.
次に起動時に呼び出されるrun
関数はDiscordに接続するための処理や,Botを切断する際の後処理が書かれています.Botの場合はstart
コルーチンにBot Tokenを渡すことでBotが起動します.
その前についているself.loop.run_until_complete()
を理解するためにはPythonのasyncio
をはじめとする非同期処理に関する知識が必要ですが,ここではとりあえず名が体を示しているように,startコルーチンを終わるまで実行し続けるという認識でよいと思います.startコルーチンは基本的に終了しないので明示的に終了させるまで実行し続けることになります.
Cogの作成
Cogを作成するためには,Botとは別ファイルにcommends.Cog
を継承したクラスを作成してBotに登録する必要があります.
試しに./src/app/dbot/cogs/Game.py
というファイルを作成します.Cogを利用すると直接Bot(Client)の機能を叩くほかに,コマンドを呼び出された際の文脈(Context)をもとに様々な処理を行うことが可能になります.
例えば$dice
の処理をGame.pyに実装してみましょう.エラー処理はここでは省略してみます.
import discord
from discord.ext import commands
import random
from dbot.core.bot import DBot
class Game(commands.Cog):
def __init__(self, bot: DBot):
self.bot = bot
@commands.command()
async def dice(self, ctx: commands.Context, a: int, b: int):
result = random.choices(range(1, b + 1), k=a)
return await ctx.send(
f'{a}D{b}の結果は{sum(result)}です.\n内訳{result}'
)
def setup(bot):
return bot.add_cog(Game(bot))
Pythonは3.6以降引数や変数の後に: 型名
とすることで型を定義1できるようになったのですが,discord.ext.commands
を利用した場合その真価を発揮します.
Cogのコマンドの特徴は以下のようなものがあります
- コルーチンに対して
@commands.command
デコレータで修飾する - コルーチン名がそのままコマンド名になる
- 第二引数はコマンドが実行された文脈(
commands.Context
)を受け取る - 第三引数以降はコマンドの引数
-
$dice 3 6
と入力すれば自動的にaに3, bに6が代入される
-
-
: 型
と書くことで自動的に型変換が行われる- 何も指定されなければ
str
だが,int
と明示的に書いているので"3"
は3
に自動で変換される
- 何も指定されなければ
- メッセージへの返信は簡潔に
await ctx.send
で済む - セットアップ用の関数を定義する必要がある
これでCogの作成は完了ですが,これだけではまだコマンドは動きません.セットアップ用の関数をBot側で呼び出す必要があります.
import discord
from discord.ext import commands
import traceback
class DBot(commands.Bot):
def __init__(self, token):
self.token = token
super().__init__(command_prefix="$")
self.load_cogs()
def load_cogs(self):
cogs = ["dbot.cogs.Game"]
for cog in cogs:
self.load_extension(cog)
print(cog + "をロードしました")
# 省略
self.load_extension
がCogをロードする関数です.Cogの名前はファイルへのパスではなくPythonのインポートのようにパッケージ名.フォルダ名.ファイル名
のように書く必要があります.今回の場合はBot自体を一つのPythonパッケージとして扱っているためパッケージのルートであるdbot
からのパスを.
でつなぐようにして書けばOKです.
これによりCogが呼びこまれ,コンソールには以下のように出力されます.
dbot_1 | [nodemon] still waiting for 1 sub-process to finish...
dbot_1 | [nodemon] starting `python -m dbot`
dbot_1 | dbot.cogs.Gameをロードしました
dbot_1 | 起動しました
これでCogの読み込みが完了です.試しに前回同様にコマンドを打ってみましょう.
エラーハンドリングしていませんので変な入力が来た際には何も返信されていない状態ですが,コンソールの方には以下のようなエラーログが表示されています.また,引数が余分に入力された場合でもエラーは吐いていません.これについては後述します.
dbot_1 | Ignoring exception in command dice:
dbot_1 | Traceback (most recent call last):
dbot_1 | File "/usr/local/lib/python3.8/site-packages/discord/ext/commands/core.py", line 460, in _actual_conversion
dbot_1 | return converter(argument)
dbot_1 | ValueError: invalid literal for int() with base 10: 'あ'
dbot_1 |
dbot_1 | The above exception was the direct cause of the following exception:
... 中略
dbot_1 | File "/usr/local/lib/python3.8/site-packages/discord/ext/commands/core.py", line 469, in _actual_conversion
dbot_1 | raise BadArgument('Converting to "{}" failed for parameter "{}".'.format(name, param.name)) from exc
dbot_1 | discord.ext.commands.errors.BadArgument: Converting to "int" failed for parameter "b".
このログを見ればわかるように内部的にconverterというものが呼ばれ,引数を要求する型(ここではint)に変換しようと試みています.そしてそれに失敗した場合,discord.ext.commands.errors.BadArgument
を発生させています.
コンバータの一覧はこちらにあります.str→intのようなプリミティブなもののほかにも多数のコンバータが定義されています.
このようなコマンド実行時に起きたエラーをハンドリングするためには以下のように書きます
import discord
from discord.ext import commands
from discord.ext.commands.errors import (
BadArgument,
TooManyArguments,
MissingRequiredArgument
)
import random
from dbot.core.bot import DBot
class Game(commands.Cog):
def __init__(self, bot: DBot):
self.bot = bot
@commands.command(ignore_extra=False)
async def dice(self, ctx: commands.Context, a: int, b: int):
result = random.choices(range(1, b + 1), k=a)
return await ctx.send(
f'{a}D{b}の結果は{sum(result)}です.\n内訳{result}'
)
@dice.error
async def on_dice_error(self, ctx: commands.Context, error):
if isinstance(error, BadArgument):
return await ctx.send('引数はいずれも整数です')
if isinstance(error, MissingRequiredArgument):
return await ctx.send('引数は2つ必要です')
if isinstance(error, TooManyArguments):
return await ctx.send('必要な引数は2つのみです')
@dice.error
のようにコルーチン名.error
というデコレータでコルーチンを修飾し第二引数にContext,第三引数に例外を受け取るようにします.
discord.pyの例外はこちらに定義されているように状況に応じてerror
は様々な例外を渡してきます.そのerror
がどのインスタンスかを調べることによりエラーハンドリングが行えるようになります.処理とエラーハンドリングを分割して書けるので非常に見やすい形でコマンドを実装することができます.
また,前述のとおり余分に引数がある場合でもdiscord.pyはエラーを吐きませんがこれを吐くようにするためには@commands.comannd
デコレータにignore_extra=False
と書く必要があります.これを書くことで引数が多すぎる場合にTooManyArguments
例外が発生するようになります.
その他,Cogのコマンドには便利な機能が様々あります.なかでもよくつかわれるものが実行者のロールチェックとクールダウンタイムの設定あたりです.それぞれ,サーバー管理者しかさせたくないコマンドを定義したり,重い処理なのであまり連続して叩かれないようにしたいといった際に有効です.
例えば,$dice
を管理者
権限がある人だけ実行可能で1分に2回までしか実行させたくないといった際は以下のようにデコレータを追加します.
import discord
from discord.ext import commands
from discord.ext.commands.errors import (
BadArgument,
TooManyArguments,
MissingRequiredArgument,
MissingPermissions,
CommandOnCooldown
)
import random
from dbot.core.bot import DBot
class Game(commands.Cog):
def __init__(self, bot: DBot):
self.bot = bot
@commands.command(ignore_extra=False)
@commands.cooldown(rate=2, per=60, type=commands.BucketType.guild)
@commands.has_permissions(administrator=True)
async def dice(self, ctx: commands.Context, a: int, b: int):
result = random.choices(range(1, b + 1), k=a)
return await ctx.send(
f'{a}D{b}の結果は{sum(result)}です.\n内訳{result}'
)
@dice.error
async def on_dice_error(self, ctx: commands.Context, error):
if isinstance(error, BadArgument):
return await ctx.send('引数はいずれも整数です')
if isinstance(error, MissingRequiredArgument):
return await ctx.send('引数は2つ必要です')
if isinstance(error, TooManyArguments):
return await ctx.send('必要な引数は2つのみです')
if isinstance(error, MissingPermissions):
return await ctx.send('管理者のみが実行可能です')
if isinstance(error, CommandOnCooldown):
return await ctx.send('1分間に2回まで実行可能です')
def setup(bot):
return bot.add_cog(Game(bot))
@commands.cooldown
は秒数当たりの最大発生可能回数を指定します.type
引数からもわかるようにサーバー単位やユーザー単位で制御することも可能です.
@commands.has_permission
は名前の通り,指定したロールを持つユーザのみが実行できるようにするデコレータです.
これらはリファレンスのチェックの項にまとめられています.
このように,Cog機能によりコマンドごとの処理のみに専念して処理を書くことが可能になります.
Embed
EmbedはBotユーザーが作成することのできるタイトルやヘッダーフッター,画像などの装飾がなされたチョットリッチな投稿のことを指します.例えばYouTubeのリンクを貼った際自動的に以下のようにサムネイル等がまとめられたものが投稿されますがそれがEmbedです.
Embedはjson形式のため地で書くこともできますがdiscord.pyではEmbedの記述を容易にする機能があらかじめ備わっています.
Embedには修飾のため色々なフィールドが用意されていますが,それらを組み合わせる場合は以下のように書きます.新たにcogs/Embed.py
というファイルをここでは作っています.
import discord
from discord.ext import commands
from dbot.core.bot import DBot
class Embed(commands.Cog):
def __init__(self, bot: DBot):
self.bot = bot
@commands.command()
async def embed(self, ctx: commands.Context):
embed = discord.Embed()
embed.color = 0xff0000
embed.title = '埋め込み要素'
embed.description = 'ここに説明'
embed.set_author(name='書いた人', url='https://google.com',
icon_url=ctx.author.avatar_url)
embed.set_thumbnail(
url='https://cdn.pixabay.com/photo/2020/08/10/14/17/hummingbird-5477966_960_720.jpg'
)
embed.set_image(
url='https://cdn.pixabay.com/photo/2020/08/07/09/23/flower-5470156_960_720.jpg'
)
embed.add_field(name='犬', value='従順')
embed.add_field(name='猫', value='気まま')
embed.add_field(name='牛', value='おいしい')
embed.add_field(name='馬', value='とてもおいしい')
embed.add_field(name='羊', value='すごくおいしい')
embed.add_field(name='🍣', value='おいしい!')
embed.add_field(name='インライン表示', value='無効にする', inline=False)
embed.add_field(name='value部分は',
value='`Markdown`が**使える**', inline=False)
embed.set_footer(text="フッターテキスト", icon_url=ctx.guild.icon_url)
await ctx.send("Embedの例", embed=embed)
def setup(bot):
return bot.add_cog(Embed(bot))
内容は適当でかまいませんが,これを実行すると以下のようになります.
鳥が映っている部分がThumbnail
,ひまわりが映っている部分がImage
で設定した部分です.
add_field
は「名前-値」の対で表示する要素です.inline
を有効にすると横に並べて表示されるようになりますが無効にすると1列に1つのみが表示されるようになります.
そして作成したEmbedを送信するためにはsend
関数にembed=embed
のようにキーワード付きで渡すことにより送信されます.
Embedを活用することで華やかに情報を表示することができるようになりますが一つ注意が必要です.
このようにユーザーがリンクプレビューの機能を切っている場合は一切Embedが表示されなくなります.
おわりに
この記事ではBot,Cog,Embedの紹介をしました.だいぶ複雑な機能が簡単に作成ができるようなりました.
次回はより拡張してBotを扱うべく「コンフィグファイルの分離」「データベース機能の追加」について紹介します.
-
と言っても厳密な型チェックはなされないですが ↩