Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
3
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

@Shirataki2

Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)

はじめに

この記事は前回Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.pyの続きです.

本記事では,Botを大規模化するに必要となるであろうdiscord.ext.commands.BotCogについての説明やBotの応答を華やかにするEmbedに関する説明をいたします.

全7回を予定しており現在5記事まで執筆を終えています.

  1. Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py
  2. Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)
  3. Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携
  4. Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する
  5. 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モジュールを入れておきましょう.

__main__.py
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の基本的な機能で実装は行えましたが,エラーハンドリングを細かく行うとこのように少し長めのコードになってしまいます.

実際に実行してみて,ちゃんとした値や変な値を入れた際の応答を見てみることにします.

Image from Gyazo

問題なく動いていそうです.

High & Low機能を追加

次に$highlowを作成してみます.

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

完成したらこの処理を実行してみましょう.

Image from Gyazo

:tada:

さて,この処理の中で最も重要な箇所は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が投稿したメッセージ:arrow_up:,:arrow_down:,:left_right_arrow:のいずれかのリアクションを行った場合のみ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__.pyon_messageだけバカでかいコードにならない?」

そのとおりです.そこでdiscord.pyにはこれを防止するため便利な機能が実装されています.ここで使うのは以下の二つです.

  1. discord.ext.commands.Bot
  2. discord.ext.commands.Cog

前者は,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つは作成するだけで何も記入しません.

./src/app/dbot/core/bot.py
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()
./src/app/dbot/__main__.py
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に実装してみましょう.エラー処理はここでは省略してみます.

./src/app/dbot/cogs/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側で呼び出す必要があります.

./src/app/dbot/core/bot.py
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の読み込みが完了です.試しに前回同様にコマンドを打ってみましょう.

Image from Gyazo

エラーハンドリングしていませんので変な入力が来た際には何も返信されていない状態ですが,コンソールの方には以下のようなエラーログが表示されています.また,引数が余分に入力された場合でもエラーは吐いていません.これについては後述します.

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のようなプリミティブなもののほかにも多数のコンバータが定義されています.

このようなコマンド実行時に起きたエラーをハンドリングするためには以下のように書きます

./src/app/dbot/cogs/Game.py
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例外が発生するようになります.

Image from Gyazo

その他,Cogのコマンドには便利な機能が様々あります.なかでもよくつかわれるものが実行者のロールチェックとクールダウンタイムの設定あたりです.それぞれ,サーバー管理者しかさせたくないコマンドを定義したり,重い処理なのであまり連続して叩かれないようにしたいといった際に有効です.

例えば,$dice管理者権限がある人だけ実行可能で1分に2回までしか実行させたくないといった際は以下のようにデコレータを追加します.

./src/app/dbot/cogs/Game.py
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は名前の通り,指定したロールを持つユーザのみが実行できるようにするデコレータです.

これらはリファレンスのチェックの項にまとめられています.

Image from Gyazo

このように,Cog機能によりコマンドごとの処理のみに専念して処理を書くことが可能になります.

Embed

EmbedはBotユーザーが作成することのできるタイトルやヘッダーフッター,画像などの装飾がなされたチョットリッチな投稿のことを指します.例えばYouTubeのリンクを貼った際自動的に以下のようにサムネイル等がまとめられたものが投稿されますがそれがEmbedです.

Image from Gyazo

Embedはjson形式のため地で書くこともできますがdiscord.pyではEmbedの記述を容易にする機能があらかじめ備わっています.

Embedには修飾のため色々なフィールドが用意されていますが,それらを組み合わせる場合は以下のように書きます.新たにcogs/Embed.pyというファイルをここでは作っています.

./src/app/dbot/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))

内容は適当でかまいませんが,これを実行すると以下のようになります.

Image from Gyazo

鳥が映っている部分がThumbnail,ひまわりが映っている部分がImageで設定した部分です.

add_fieldは「名前-値」の対で表示する要素です.inlineを有効にすると横に並べて表示されるようになりますが無効にすると1列に1つのみが表示されるようになります.

そして作成したEmbedを送信するためにはsend関数にembed=embedのようにキーワード付きで渡すことにより送信されます.

Embedを活用することで華やかに情報を表示することができるようになりますが一つ注意が必要です.

Image from Gyazo

Image from Gyazo

このようにユーザーがリンクプレビューの機能を切っている場合は一切Embedが表示されなくなります.

おわりに

この記事ではBot,Cog,Embedの紹介をしました.だいぶ複雑な機能が簡単に作成ができるようなりました.

次回はより拡張してBotを扱うべく「コンフィグファイルの分離」「データベース機能の追加」について紹介します.


  1. と言っても厳密な型チェックはなされないですが 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
3
Help us understand the problem. What are the problem?