PythonでDiscordのBotを作る

### はじめに
開発の過程で得た経験と知識をまとめてみる。あくまで自分用の備忘録です　笑。全て筆者の独学で得た知識のため、実際の開発ではどのようになっているかはわかりません。どの記事にも言えますが必ずしも全てが正しいとは限らないので、あくまで参考程度に。


### discord.pyとPycord
discord.pyはDiscord APIのラッパーで、Pycordはdiscord.pyをフォークしたライブラリです。自分が両方のライブラリでBotを開発したときに感じたそれぞれの特徴を列挙したいと思います。


|   |discord.py      |Pycord  |
|:---|:----------------|:--------|
|コマンドの言語対応  |困難  |容易  |
|スラッシュコマンド  |低レベルからの実装  |直感的な実装  |
|テキストコマンド|相違なし|-|
|ハイブリッドコマンド|テキストコマンドとほぼ同様に実装可能。|貧弱|
|ドキュメント|日本語版も英語版もある。|英語版（しか見つからなかった）|
|UIの実装|ほぼ何でも作れるが、凝った仕様は少し面倒|discord.pyと変わらないが、ページ表示は簡単に作れる|
|総括|低レベルからの実装が必要だけど、自由度は大きい|コマンドごとの機能は豊富だけど、ハイブリッドコマンドはほぼ無いも同然|


Pycordはdiscord.pyの書き方をほぼそのまま使えるので、discord.pyで開発したことのある方ならそこまで苦労しないと思います。ただ、スラッシュコマンドの実装だけは少し違って、discord.pyでは主にデコレータで引数の詳細等を追加しましたが、Pycordでは独自のクラスが定義されているので、それを使う必要があります（細かいこと言い出すとキリがないので、ここまでにとどめておきます　笑）。以下はdiscord.pyとpycord共通です。


### Cog
Cogというのは、Botの機能ごとにファイルを分けて実装するときに使うクラスです。開発しやすくなる他にも、例えばヘルプコマンドを動的に実装する際とかは割と役に立った印象です。([ドキュメント](https://discordpy.readthedocs.io/ja/latest/ext/commands/api.html?highlight=commands%20cog#discord.ext.commands.Cog))

### 沼りやすい場所

#### スラッシュコマンド
スラッシュコマンドやボタン等のUIを使ったときのコールバックはDiscord APIの仕様で3秒以内にレスポンスを返す必要があります。3秒以上レスポンスを返さずに処理を続けると、コマンドが失敗したと見做されてセッションを切断されてしまいます。したがって、時間のかかりそうな処理をするときは、処理の最初で一旦レスポンスを返すことでこの問題を回避できます。具体的には、Cogを使わないでdiscord.pyで書くなら以下のような実装になると思います。
```python
@bot.tree.command()
async def sample(interaction: Interaction, name: str) -> None:
    await interaction.response.defer(thinking=True)
    ...
    await interaction.followup.send(f'Hey! {name}')
```
ポイントは`await interaction.response.defer(thinking=True)`という部分で、一度応答を返している点です。botの処理で「〇〇が考え中」というのを見たことがあるかもしれませんが、それです。これによって一度応答を返しているので、3秒ルールからは回避できます。このままだと延々と「考え中」という状態が続いたままなので、更に何かしらの応答を返す（埋め込みやメッセージを送るとか）必要があります。一度`defer()`メソッドのように応答を返した場合は`interaction.followup.send()`メソッドで二度目以降のレスポンスを返さないと失敗します。これは本来、応答が一度きりであることを想定した処理だからです(単に一度目のレスポンスでメッセージ等を送りたいなら`interaction.response.send_message()`を使う)。Google Spreadsheetや他のAPIを使う場合は、一旦`defer()`でタイムアウトから逃れるのが無難だと思います。

#### UIのタイムアウト
discord.pyやPycordではボタンやセレクトメニュー等のUIも提供されていますが、　ビューのタイムアウトを`None`としてもbotを一度再起動するとUIが反応しなくなってしまいます。ビューを永続的なもの（botが再起動しても有効）にするためには、以下のすべてを満たす必要があるからです。

1. ビュークラス本体のタイムアウトを`None`へする。 
2. 各アイテム（ボタンやセレクトメニュー等）のカスタムIDを全て設定する。
3. Botへビューを読ませる。 (`commands.Bot.add_view()`)

[サンプルコード](https://github.com/Rapptz/discord.py/blob/master/examples/views/persistent.py)


#### ローカルの画像を埋め込み(Embed)へ添付する
`Embed`クラスには`set_thumbnail`メソッドを用いてURLを登録することで、ネット上の画像を埋め込みのサムネイルに添付できます。しかし、ローカルでの画像（例えば、matplotlibで各々のために作ったグラフ）を登録するには少し手順が変則的です。結論から言うと、ローカル画像を送るためには、`set_thumbnail`メソッドの引数を`url='attachment://(送るファイル名)'`とし、ファイルを埋め込みと同時に送信すればOKです。しかし、`discord.File`クラスのオブジェクトを生成するときに、もとから用意した画像であればファイルのパスを渡せば済みますが、その都度ファイルを生成して送るには、バッファーに読ませれば解決します。

```python
from io import BytesIO
from discord import Embed, File
import matplotlib.pyplot as plt

...
buffer = BytesIO()
plt.savefig(buffer, format='png') #バッファーに保存する
buffer.seek(0) #これを忘れると正常に動作しません！
plt.clf() # 設定をクリア
plt.close()
file = File(fp=buffer, filename="image.png")
embed = Embed()
embed.set_image(url="attachment://image.png")
await channel.send(file=file, embed=embed)
```
バッファーに読ませることのメリットはファイルを生成せずに済むことです。例えば、Herokuにデプロイして実行するときに効果抜群です。


### Tips

以下、`bot`のタイプは`discord.ext.commands.Bot`とします。

#### check

特定のサーバーやチャンネルではコマンドを無効化したり、または管理人専用コマンドを作る時などは、コマンドのコールバックに条件式をその都度書いて実装するのは少し面倒です。そこで、デコレータとエラーをうまく利用すれば簡単に実装できます。以下は管理人専用コマンドを、if文で実装した例と、デコレータを使って実装した例です。

```python
OWNER_ID = 123456789123456789

@bot.command()
async def sample1(ctx: commands.Context, name: str) -> None:
    if ctx.author.id != OWNER_ID:
        return
    ...

# -------------


@bot.command() 
@commands.is_owner()
async def sample2(ctx: commands.Context, name: str) -> None:
    ...

```

自分で条件を自由に作ることもできます。

```python
IGNORE_CHANNELS = [12345, 67890]

def is_allowed_channel() -> Callable[[T], T]:

    async def predicate(ctx: commands.Context) -> bool:
        return ctx.channel.id not in IGNORE_CHANNELS

    return commands.check(predicate)

@bot.command()
@is_allowed_channel()
async def allowed_channel_command(ctx: commands.Context, ...):
       ...

```

`predicate`は`commands.Context`のみを位置限定引数として取らなければいけません。ちなみに、predicateがFalseを返した場合は`commands.CheckFailure`というエラーが出され、コマンドは実行されません。これと次のセクションで述べるイベントリスナーを組み合わせることで、同じエラー処理をコマンドに書かずに済みます。メンテナンスも簡単になるかと思います。


#### イベントリスナー
まずイベントリスナーというのは、Botに関するイベント（新しいメッセージを検知、サーバーに加入、リアクションを追加したとき、エラーを吐かれたとき等）に関する処理を**追加する**ものです。例えば、`@bot.event()`でイベントに関する処理を登録する際は、それぞれのイベントに関して1つずつしか登録できません。しかし、Cogを使うならCogの中で`@commands.Cog.listener('イベント名')`、Cogを使わないなら`@bot.listen('イベント名')`デコレータを使うことで、他の非同期的な処理を追加できます。リスナーとして登録することのメリットは、それぞれの処理が独立していることです。例えば、テキストコマンドとメッセージイベントに関する処理を同時に実装できます。以下は例です。

```python
# これだとコマンドが動かない

@bot.event()
async def on_message(message: Message) -> None:
    """メッセージをおうむ返しする"""
    await message.reply(message.content)

@bot.command()
async def respond(
        ctx: commands.Context,
        *,
        content: str # contentをキーワード限定引数にすると空白込みの内容も一つの引数として受け取れる
    ) -> None:
    """contentをおうむ返しする"""
    await ctx.send(content)

# ---------------------------

# これは動く
@bot.listen('on_message')
async def message_listener(message: Message) -> None:
    ...

@bot.command()
async def respond(
        ctx: commands.Context,
        *,
        content: str
    ) -> None:
    ...

```
これは、疑似的なコマンドを作るときとかに割と便利だったりします。エラー処理も`@bot.listen('on_command_error')`、(Pycordは`on_application_command_error`もある)のように、コマンドごとのエラー処理をリスナーとして登録すると管理しやすいです（エラーが発生したら、詳細をログチャンネルへ送信させることができたり...）。補足ですが、コマンドエラーを自分で定義してそれぞれのエラーハンドラへ渡したいなら、テキストコマンドなら`commands.CommandError`、スラッシュコマンドなら`AppCommandError`(Pycordは`ApplicationCommandError`)を継承する必要があります。


##### 注意
`on_error`イベントは特殊で、これはリスナーに登録できません。イベントとして登録すれば実装できます。

```python
@bot.event
async def on_error(event: str, *args, **kwargs):
    """
    event: (str) -- 例外を発生させたイベントの名前
    args         -- 例外を発生させたイベントの位置引数
    kwargs       --  例外を発生させたイベントのキーワード引数
    """
   ...
```




#### コンバーター
コンバーターというのは、ざっくり言うならばコマンドの引数の型や読み込む方法を指定するものです。例えば以下のようにすると、コマンドを呼び出すときはユーザーネームやメンションを渡すことで、`Member`クラスのオブジェクトへ変換してくれます。

```python
@bot.command()
async def dm(ctx: commands.Context, member: Member, *, content: str) -> None:
    """指定したユーザーのDMへメッセージを送信"""
    await member.send(f'{content} by {ctx.author.name}')

```