Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py

# はじめに

この記事では，数回にわたってゼロから録音機能を備えたDiscord BotをPythonで作成します．

discord.pyはDiscordのAPIのラッパーライブラリです．本記事では数回に分けてdiscord.pyでのBotの作成から自作の機能の追加まで一通り行います．順序としては環境構築から始めて，一通りの機能を実装したのち録音機能を実装します．

色々なものに関する説明を加えていくうちにとんでもなく長い記事になってしまったため分割して投稿することにしました.

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

1. [Pythonで始める録音機能付きDiscord Bot: (1) 入門 discord.py](https://qiita.com/Shirataki2/items/3b9f9766bc25bb204ed3)
2. [Pythonで始める録音機能付きDiscord Bot: (2) 便利機能(Bot拡張,Cog,Embed)](https://qiita.com/Shirataki2/items/9bdc62548e7b24d3c803)
3. [Pythonで始める録音機能付きDiscord Bot: (3) Databaseとの連携](https://qiita.com/Shirataki2/items/37d48868cd7b999a13b2)
4. [Pythonで始める録音機能付きDiscord Bot: (4) 音楽ファイルを再生する](https://qiita.com/Shirataki2/items/f4ea533d5baf55c4b1d3)
5. [Pythonで始める録音機能付きDiscord Bot: (5) Discord APIを直接操作する](https://qiita.com/Shirataki2/items/58480517365a46922f45)

なお，この記事での実装方法には誤りがあるかもしれません．その際にはコメント欄でどうぞご指摘ください．

# この記事を書くに至った経緯

discord.pyには痒い所に手が届く機能は一通り実装されています．例えば音楽**再生**Botを作りたいといった際には，本来は「WebSocketで認証を行う→Voice Channel用のWebSocketに接続し音声を送信する準備を行う→UDP接続を行いRTPでOpusエンコードされた20msの音楽データを暗号化し送信する」[^1]などといった煩雑な処理を行わなければなりませんが，このdiscord.pyではそういった機能が一通り実装されており容易に利用できます．

[^1]: この鍵括弧の中は今は読まなくていいです．難しいことやってるんですねくらいの認識でOKです．

しかしながら**録音**機能(音声データの受信)に関しては，

https://github.com/Rapptz/discord.py/issues/1094

このIssueにも挙がっている通り数年前から実装要望の声が上がっておりますがいまだ本家へのPRはおこなわれておりません．一応，録音機能自体はJS版のラッパーであるDiscord.jsでは実装されているようです．不可能でないなら自力で実装してみたので，それの紹介とともにdiscord.pyの入門記事も書いてしまおうと思い書き始めました．


# 注意事項

- Python自体の初歩的な解説はしません．
- 動作環境
    - Ubuntu 20.04
    - Python 3.8.2
    - Docker 19.03
    - docker-compose 1.26
    - discord.py 1.4.1
    - VSCode 1.47.3
    - Pipenv 2018.11.26
- Python,Pipenv,Docker,docker-compose,VSCodeのインストールについては説明しません．

Pipenvに関しては[こちら](https://qiita.com/y-tsutsu/items/54c10e0b2c6b565c887a)をご覧ください．

docker-composeに関しては[こちら](https://qiita.com/TsutomuNakamura/items/7e90e5efb36601c5bc8a)をご覧ください

# 環境構築

## Botの作成

まずはDiscordの[開発者向けポータル](https://discord.com/developers/applications)からBotを作成します．New Applicationから適当に名付けて作成します．(Botの名前はここではDBotとします)

[![Image from Gyazo](https://i.gyazo.com/3cea5cf041f93d9c97c309495dadf37a.png)](https://gyazo.com/3cea5cf041f93d9c97c309495dadf37a)

作成が終わるとこのような画面になります．アイコン画像の変更も行うことができます．

次に，Botを作成する際にあたって必要となるトークンを発行します．このトークンは流出してしまうと**例えば管理者権限を持つボットであればそのトークンが流出した際，ボットを追加したすべてのサーバに関して第三者がその管理者権限でサーバーを操作することが可能となってしまいます．決して流出しないように細心の注意をもって管理することが必要です．**

トークンの発行はBotメニューから[Add Bot]を選択します．

[![Image from Gyazo](https://i.gyazo.com/7b28a1ca9d496c2d368c0f826d44986a.png)](https://gyazo.com/7b28a1ca9d496c2d368c0f826d44986a)

BotのトークンはBot画面作成後の画面から以下のボタンをクリックします．

[![Image from Gyazo](https://i.gyazo.com/89f8f28100484acc35705c42969cf047.png)](https://gyazo.com/89f8f28100484acc35705c42969cf047)

これでBotの作成は完了ですが，Botをサーバに追加するための認証URLが発行されていません．認証URLの発行はOAuth2メニューから行えます．

[![Image from Gyazo](https://i.gyazo.com/c2442252f9f5f0ffbdc8e6370e052bf1.png)](https://gyazo.com/c2442252f9f5f0ffbdc8e6370e052bf1)

SCOPEではbotを選択し，BOT PERMISSIONSではBotの権限を選択します．開発段階では「Administrator」権限を入れておけば問題ないです．既存のBotでもAdministratorを要求するものが多く存在しますが，どうしても管理者権限を要求するBotというだけで少し危険な印象を受ける場合があるので一般公開する際には見直すべきかもしれません．

画面中央に出てきたURLをクリックすることでBotをサーバーに招待できます．**Botを導入するためには，導入させたいサーバーの管理権限を有している必要があります．**[^2]

[^2]: サーバーのオーナーまたは「管理者」，「サーバーの管理者」のいずれかの権限があるロールを持ったユーザー

## ディレクトリ構成

本プロジェクトでは以下のようなディレクトリ構成をもとに開発していくことにします．順番に作成していくのでまだ作成する必要はありません．

```python
.
|- src                      # サービスに関するソースコードすべてのルート
|  |- app                   # アプリケーションに関するソースコード
|  |  |- [bot name]         # Botの名前はお好きに決めてください．
|  |  |  |- cogs            # discord.pyに機能を追加するためのファイルをこのフォルダにまとめる
|  |  |  |- core            # Bot自体の定義や補助関数，拡張機能などはここにまとめる
|  |  |  |- __init__.py     # モジュールとして扱います
|  |  |  |- __main__.py     # 起動用のファイル
|  |  |
|  |  |- config.ini         # さまざまな設定はモジュール外で行います
|  |  |- Pipfile            # (自動生成されるので作らなくてOK)
|  |  |- entrypoint.dev.sh
|  |
|  |- dev.dockerfile
|
|- docker-compose.dev.yml
```

単に動かすだけであればファイル一つで済みますが，ここではより規模の大きなサービスを動かす場合を想定して少し複雑なディレクトリ構成にしています．

まずはPythonを動かします．Pipenvで仮想環境を作り，その環境でBotを動かします．

```shell
$ mkdir src
$ cd src
$ mkdir app
$ cd app
$ mkdir dbot
$ echo 'print("Yay")' > ./dbot/__main__.py
$ pipenv --python 3.8.2
```

これでPythonの環境は完成しました．`__main__.py`という[特殊なファイル名](https://docs.python.org/ja/3/library/__main__.html)を使っていますが，このファイルにPythonのコードを書くことで`__main__.py`の呼び出しが

```sh
# もともとPythonがPCに入っている場合
$ python -m dbot
Yay

# 作成したPipenv環境で走らせる場合
$ pipenv run python -m dbot
```

で行うことができます．自作モジュールの実行などの場合はこっちの方がすっきりしていて良さげです．

## Botを動かそう！

Pythonのコードを動かせるようになったので次はdiscord.pyを実際にインストールしてBotを機能させるようにします．

前節で細かくディレクトリを分けましたが，本記事では動作を確認することが目的ですのですべて`__main__.py`にコードを書いていくことにします．

```shell
$ pipenv install discord.py[voice]
```

インストールが終わったらエディタで`__main__.py`を編集していきます．

```python:__main__.py
import discord

# こんな感じの文字列がトークンです(下のトークンは適当です)
TOKEN = "MTE0NTE0MzY0MzY0ODEwOTMx.Adiade.Oosak0_Majide_Hampana1tteee"

# Botを動かすためのオブジェクトの作成
client = discord.Client()


@client.event
async def on_ready():
    # この関数はBotの起動準備が終わった際に呼び出されます
    print("起動しました")


@client.event
async def on_message(message):
    # この関数はメッセージが送信された際に表示されます
    # Messageにはユーザから送られてきたメッセージに関する様々な情報が格納されています

    # Botからのメッセージには応答しない
    if message.author.bot:
        return

    print("メッセージが送られました")
    print("送信者", message.author.display_name)
    print("内容", message.content)

    # Yayという内容の文が送られたら...
    if message.author != client and message.content == 'Yay':
        # メッセージが送られたチャンネルにメッセージを送り返す
        await message.channel.send("You're on discord.py!")

client.run(TOKEN)
```

ここまで入力できたら保存し，再度実行します．

起動し，コンソールに`起動しました`と表示されたら，実際にBotを導入したサーバーにYayという文字を打ってみましょう．

[![Image from Gyazo](https://i.gyazo.com/653d44436190a839986afeb158eeaeb8.png)](https://gyazo.com/653d44436190a839986afeb158eeaeb8)

コンソールには以下のように表示されます．

```sh
$ pipenv run python -m dbot
起動しました
メッセージが送られました
送信者 Admin
内容 Yay
```

**Yay! You're on discord.py!**

ごく短いコードですが，これだけで簡単なBotが動くようになりました．以下いくつか補足です．

このコードでは`async def on_なんちゃら`という関数[^3]の上に`@client.event`というものがくっついている個所がいくつかあります．これらはPythonの機能を利用したものであり，それぞれ


[^3]: 厳密には`async def ~~`はコルーチン関数です

- `async` ~ `await`
    - 非同期処理を書く際に使う文法
- `@client.event`
    - `@`はデコレータと呼ばれる機能
    - これを事前に書くことでその下の関数がBotに対するイベントが起こった際に発火されます

という機能/意味があります．初学者でしたら，いまのところとりあえずこう書いておけばいいという認識で大丈夫です．

`on_ready`,`on_message`のように`on_`から始まる関数に`@client.event`デコレータを付けることで**特定のイベントが起きた**際に**特定の処理をする**といったことが簡単に記述できます．対応しているイベントの一覧はAPIリファレンスページにあります(結構な量があります)．

https://discordpy.readthedocs.io/ja/latest/api.html#event-reference

また，ところどころ関数の呼び出しに`await`を使用している個所がありますが，`await`を必要とする関数かどうかの判別方法は[リファレンスページ](https://discordpy.readthedocs.io/ja/latest/api.html)の関数に関する説明の中で，

> This function is a coroutine.

と書かれている個所があったらその関数は`await`を前につける必要があります．

また，discord.pyでは，メッセージの内容や，メッセージの送信者などといった情報をクラスのプロパティとして取得可能です．なので，コードの実態が分からなくともコードを見ただけでどのような処理が行われているかが分かりやすくなります．その反面，慣れるまではどのクラスにどのプロパティやメソッドがあるか逐一リファレンスとにらめっこする必要があります．(一応[補足](#1-%E3%82%88%E3%81%8F%E4%BD%BF%E3%81%86%E3%82%AF%E3%83%A9%E3%82%B9%E3%81%A8%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88)にてdiscord.pyのクラスの一部を紹介します．)

まあ，要するに困ったらリファレンスを見ましょう．答えは95割そこにあります．**RTFM!!!**

また，ここでは`discord.Client`を直接インスタンス化してイベントの定義をしましたが`discord.Client`を継承した独自のクラスを定義しても同等の処理が可能です．(リファレンスページにはこちらの書き方が使われています．)

```python:__main__.py
import discord

TOKEN = "..."


class Bot(discord.Client):
    
    def __init__(self):
        super().__init__()

    async def on_ready(self):
        print("起動しました")

    async def on_message(self, message):
        if message.author.bot:
            return
        print("メッセージが送られました")
        print("送信者", message.author.display_name)
        print("内容", message.content)
        if message.content == 'Yay':
            await message.channel.send("You're on discord.py!")


Bot().run(TOKEN)
```

ひとまず，Botの機能追加は後程やるとして，このサービスをDockerコンテナ上で動かしてみましょう．

## Dockerコンテナの作成

冒頭のディレクトリ構成例でみたようにDocker関係のファイルを3種類作成します．`dev`と名前の付いているように開発時と本番実行時に環境を分けてることを視野に入れています．

- dev.dockerfile
    - ベースとなるDockerイメージの作成
- entrypoint.dev.sh
    - 起動時に動かすスクリプト
- docker-compose.dev.yml
    - Dockerコンテナを容易に制御できるようにするための設計書

`Dockerfile`の書き方は[こちらの記事](https://qiita.com/Aruneko/items/796d7eeb61e1f36ae4a0#dockerfile%E3%81%AE%E6%9B%B8%E3%81%8D%E6%96%B9)に倣って，builderでPythonパッケージのインストールを行った後に実行用のイメージを作成します．

```dockerfile:dev.dockerfile
FROM python:3.8 as builder
WORKDIR /bot
RUN apt update -y && \
    apt upgrade -y
COPY ./app/Pipfile ./app/Pipfile.lock /bot/
RUN pip install pipenv && \
    pipenv install --system

FROM python:3.8-slim
WORKDIR /bot
RUN apt update -y && \
    apt upgrade -y && \
    apt install -y nodejs npm curl && \
    npm install -g n && \
    n stable && \
    apt purge -y nodejs npm && \
    apt install -y ffmpeg && \
    apt autoremove -y
RUN npm install -g nodemon

ENV PYTHONBUFFERED=1
COPY --from=builder /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages

COPY . /bot
```

実行用のイメージのビルド部分ではnodemonとffmpegのインストールを途中で行っています．nodemonは開発時にわざわざBotを止めて再起動しなくとも，ファイルの変更を感知して再起動するために使用し，ffmpegは音楽の再生等メディアに関する処理を行うために使用します．

`entrypoint.dev.sh`はnodemonを利用して起動を行います．

```shell:entrypoint.dev.sh
nodemon --signal SIGINT -e py,ini --exec python -m dbot
```

最後に`docker-compose.dev.yml`を以下のように書きます

```yml:docker-compose.dev.yml
version: "3.8"
services: 
  dbot:
    build:
      context: ./src
      dockerfile: dev.dockerfile
    tty: true
    working_dir: /bot/app
    entrypoint: bash ./entrypoint.dev.sh
    volumes:
      - ./src:/bot
```

これらの設定により`src`以下がコンテナの`bot`にマウントされ，ホスト上でスクリプトを更新した際にその変更を感知しBotの再起動が行われるようになります．docker環境にすることで，後にデータベースやWebフロントなどを追加したいといった際に容易に拡張が可能となります．

ここまで書き終えたら，プロジェクトルートに戻って以下のコマンドを実行します．

```shell
$ chmod +x ./src/app/entrypoint.dev.sh
$ docker-compose -f docker-compose.dev.yml -p dev build # イメージの作成
$ docker-compose -f docker-compose.dev.yml -p dev up # 起動
```

`docker-compose -f docker-compose.dev.yml -p dev`と長々と付いていますが環境分けしているためこのように記述する必要があります．面倒であれば以下のようなスクリプトを作っておけば幸せになれるでしょう

```sh:run.sh
#!/bin/bash
cmd="docker-compose -f docker-compose.$1.yml -p $1 ${@:2}"
echo $cmd
eval $cmd
```

```shell
$ ./run.sh dev build
$ ./run.sh dev up
```

起動を確認した後，試しに`__main__.py`を編集してみましょう．

```
dbot_1  | [nodemon] 2.0.4
dbot_1  | [nodemon] to restart at any time, enter `rs`
dbot_1  | [nodemon] watching path(s): *.*
dbot_1  | [nodemon] watching extensions: py,ini
dbot_1  | [nodemon] starting `python -m dbot`
dbot_1  | 起動しました
dbot_1  | [nodemon] restarting due to changes...
dbot_1  | [nodemon] starting `python -m dbot`
dbot_1  | 起動しましたよ
```

保存を感知して，Botの再起動が行われました．これで開発効率を大幅に上げることが可能となります．

# 終わりに

この記事では基本的なBotの構築と開発に適した環境の設定を行いました．

次回では，より規模の大きいBotを作るための設計や，この記事で紹介していない埋め込み(Embed)要素などの説明を行います．

# 補足

## 1. よく使うクラス

- **記法**
    - List[type]
        - 要素の型がtypeであるリスト
    - Optional[type]
        - 型はtypeもしくはNone
    - Union[type1, type2, ...]
        - 型はtype1, type2, ...のいずれか
    - *args
        - 可変引数

関数の引数はすべて記載しているわけではなくよく使うものだけピックアップしています．


#### discord.Message

<details>
<summary>クリックして展開</summary>
<div>

https://discordpy.readthedocs.io/ja/latest/api.html#message

|プロパティ名|型|説明|
|:--|:--|:--|
|id|int|一意な識別子|
|author|[discord.Member]|メッセージを投稿した人|
|content|str|メッセージの内容|
|guild|[discord.Guild]|メッセージが投稿されたサーバー(ギルド)|
|channel|サーバー内だったら[discord.TextChannel]|メッセージが投稿されたチャンネル(DMなどはまた別のクラスとなるがここでは述べない)|
|mentions|List[[discord.Member]]|メンションを飛ばした相手のリスト|
|reactions|List[[discord.Reaction]]|メッセージに対する絵文字のリアクション|
|created_at|datetime.datetime|投稿日時|
|edited_at|Optional[datetime.datetime]|編集日時(未編集であれば`None`)|
|jump_url|str|そのメッセージに飛ぶためのリンク|

<dl>
<dt><code>await delete()</code></dt>
<dd>メッセージの削除．</dd>
<dd>Botがメッセージ管理の権限を思っている必要あり．</dd>
</dl>

<dl>
<dt><code>await edit(content: str, ...)</code></dt>
<dd>メッセージの編集．</dd>
<dd>キーワード付きで<code>edit(content="hoge")</code>のように指定する．</dd>
<dd>Bot自身の投稿したメッセージでないといけない．</dd>
</dl>

<dl>
<dt><code>await add_reaction(emoji: strなど)</code></dt>
<dd>メッセージにリアクションを追加</dd>
<dd>Unicodeの絵文字であればそのまま入力可能:ramen:</dd>
</dl>

<dl>
<dt><code>await remove_reaction(emoji: strなど, member: discord.Member)</code></dt>
<dd>指定したメンバーの指定した絵文字リアクションを削除</dd>
</dl>

<dl>
<dt><code>await clear_reaction(emoji: strなど)</code></dt>
<dd>指定した絵文字リアクションを削除</dd>
</dl>


</div>
</details>

#### discord.Member

<details>
<summary>クリックして展開</summary>
<div>

|プロパティ名|型|説明|
|:--|:--|:--|
|id|int|一意な識別子|
|name|str|ユーザー名|
|nick|Optional[str]|サーバーで設定している渾名|
|display_name|str|nickがあればnick,なければname|
|mention|str|メンバーにメンションするための文字列|
|guild|[discord.Guild]|メンバーの属するサーバー|
|roles|List[[discord.Role]]|メンバーの全ロールのリスト|
|top_role|[discord.Role]|メンバーの最高ロール|
|avatar_url|str|アバター画像のURL|
|joined_at|datetime.datetime|メンバーがサーバーに参加した日時|
|created_at|datetime.datetime|メンバーがDiscordアカウントを登録した日|

以下のメソッドのreasonは監査ログに表示されます．

<dl>
<dt><code>await ban(reason: Optional[str], ...)</code></dt>
<dd>メンバーをサーバーからBANします．</dd>
<dd>BAN権限が必要で，そのユーザーはunbanするまでサーバーに参加できなくなります</dd>
</dl>

<dl>
<dt><code>await unban(reason: Optional[str], ...)</code></dt>
<dd>サーバーからBANしたメンバーのBANを解除します．</dd>
<dd>BAN権限が必要で，その処理を実行後ユーザーはサーバーに参加可能になります</dd>
</dl>

<dl>
<dt><code>await kick(reason: Optional[str], ...)</code></dt>
<dd>ユーザーをサーバーから追放します</dd>
<dd>Kick権限が必要です．</dd>
</dl>

<dl>
<dt><code>await add_roles(*roles: discord.Role)</code></dt>
<dd>ユーザーにロールを与えます</dd>
<dd>付与できるロールは以下の制約があります．</dd>
<dd>1. ロール管理の権限がない場合は実行できません</dd>
<dd>2. BotやServer Booster等のロールは付与できません</dd>
<dd>3. <strong>Botが有しているロールのうち最高ロール以上の位置にあるロールはつけることができません</strong></dd>
</dl>

<dl>
<dt><code>await remove_roles(*roles: discord.Role)</code></dt>
<dd>ユーザーからロールをはく奪します</dd>
</dl>

</div>
</details>


#### discord.TextChannel

<details>
<summary>クリックして展開</summary>
<div>

|プロパティ名|型|説明|
|:--|:--|:--|
|id|int|一意な識別子|
|name|str|チャンネル名|
|guild|[discord.Guild]|チャンネルがあるサーバー|
|members|List[[discord.Member]]|チャンネル閲覧権限のあるメンバーのリスト|
|mention|str|チャンネルにメンションするための文字列|
|created_at|datetime.datetime|作成日時|

<dl>
<dt><code>await send(content: str, embed: discord.Embed, file: discord.File)</code></dt>
<dd>メッセージの送信を行います</dd>
</dl>

<dl>
<dt><code>await purge(limit: int, check)</code></dt>
<dd>メッセージの削除を行います</dd>
<dd>limit個のメッセージを探索し，checkに該当するメッセージを削除します</dd>
<dd>引数checkはNoneの場合には問答無用で全メッセージを削除しますが，discord.Messageを引数にとり，削除対象のメッセージに対してTrueを返す関数を与えることで条件を指定して削除できます．</dd>
</dl>

<dl>
<dt><code>history(limit: int = 100) -> AsyncIterator</code></dt>
<dd>メッセージの履歴を取得します</dd>
<dd>文法が独特であるため注意が必要です</dd>
</dl>

以下のように`async for ... in ~~`という呼び出し方をします．

```python
async for message in channel.history():
    print(messane.author.name)
```

</div>
</details>

#### discord.Guild

<details>
<summary>クリックして展開</summary>
<div>

|プロパティ名|型|説明|
|:--|:--|:--|
|id|int|一意な識別子|
|name|str|サーバー名|
|icon_url|str|サーバーアイコンのURL|
|owner|discord.Member|サーバーオーナー|
|member_count|int|メンバー数|
|text_channels|List[[discord.TextChannel]]|サーバー内の全テキストチャンネル|
|members|List[[discord.Member]]|サーバー内の全メンバー|
|roles|List[[discord.Role]]|サーバー内の全ロール|
|emojis|List[[discord.Emoji]]|サーバーで独自に作成した絵文字のリスト|
|created_at|datetime.datetime|作成日時|

メソッドは様々ありますが追々紹介することにします．

</div>
</details>

<!--
<dl>
<dt>await</dt>
<dd></dd>
</dl>
-->

<!-- 以下リンク添付欄 -->

[discord.Member]: https://discordpy.readthedocs.io/ja/latest/api.html#member
[discord.Guild]: https://discordpy.readthedocs.io/ja/latest/api.html#guild
[discord.Message]: https://discordpy.readthedocs.io/ja/latest/api.html#message
[discord.TextChannel]: https://discordpy.readthedocs.io/ja/latest/api.html#textchannel
[discord.Reaction]: https://discordpy.readthedocs.io/ja/latest/api.html#discord.Reaction
[discord.Role]: https://discordpy.readthedocs.io/ja/latest/api.html#discord.Reaction
[discord.Emoji]: https://discordpy.readthedocs.io/ja/latest/api.html#discord.Emoji