Python3で書いたDiscord匿名質問botをHerokuで24時間戦わせる

# 概要
- 匿名質問用のbotがDiscordサーバに常駐する
- このbotはメッセージを拾ってオウム返しする
- 発言者がbotなので誰がオウム返しさせた文章なのかを隠せる
- この手のbotは一般的だと思われるが，公開botとしては見つからないので自力で作った

# 環境
- Discordサーバ (botが常駐する)
- Heroku (botを24時間働かせる)
- Github (ここの更新を引き金としてHerokuへ自動デプロイする)

Githubを使わずともHerokuへ直接デプロイできるが，筆者はGithubから勝手にデプロイしてくれるほうが楽なのでGithubを使う．
ちなみに，このやり方だとHeroku操作がGUIだけで完結する．

# 前準備パート
## Discord (bot作成 & Discordサーバへの参加)
[先駆者による解説](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#bot%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90%E3%81%A8%E7%99%BB%E9%8C%B2)に従う．

なお参考までに，Discord Developer Portalではアプリ名を決めてからbot名を追加するが，このアプリ名はbotの役職名となる．

例えばアプリ名を「会議bot」としてbot名を「質問箱」にした場合，Discord上では「質問箱」という名のbotに「会議bot」という名の役職が付与されている．


## Github
※既にアカウントがあり，リポジトリを作成できる前提とする．

- Herokuにデプロイする用のリポジトリを作る

ひとまずこれでおｋ．Githubへのpushなどはコマンドを頑張らずに[GitKraken](https://www.gitkraken.com/)とかでウマーする．


# Herokuパート：bot用アプリを用意する
## アカウント作成
[Heroku](https://jp.heroku.com/)でアカウントを作る．
[公式にも案内がある](https://devcenter.heroku.com/articles/getting-started-with-python)ものの，アカウント作成なので初見でもできる．

なお無料プランとする (無料でできるからHerokuを選んだ)

## Heroku CLIツールは使いません
[公式は「CLIツールをインストールしろ」と言っている](https://devcenter.heroku.com/articles/getting-started-with-python#set-up)が，別に無くてもできるのですっ飛ばす．

## bot用アプリ作成
HerokuはWeb上のダッシュボードでも操作できるので，そっちを使う．

- まずダッシュボードから「New」 => 「Create New App」を選択
![heroku_createnewapp.png](https://qiita-image-store.s3.amazonaws.com/0/201962/1aefcdb0-562d-c848-f4d9-65030592dfee.png)

- アプリの名前と場所を訊かれる．とりあえず名前は「sh141-discord-bot」とし，国はUnited Statesにする
- なおアプリ名は全て小文字にせねばならない模様．また他者と共有しているのか「sample1」などのありがちな名前を使えない．ひどい．
![heroku_appname.png](https://qiita-image-store.s3.amazonaws.com/0/201962/0e22511c-b85f-1ce6-68a9-5700d55b0f7f.png)

- アプリが作成され，Deploy設定画面に飛ばされる
- Githubと連携したいのでGithubをクリックし，[前準備](#前準備)で作ったリポジトリ名を入力して「Connect」
![heroku_github1.png](https://qiita-image-store.s3.amazonaws.com/0/201962/830b9443-1c43-7dcc-692a-3b5ea86b79c5.png)

- Githubとの接続に成功すると，下にAutomatic deploysの項目が現れる
- Choose a branch to deployというのは「どのブランチが更新されたらHerokuへ自動デプロイしますか」を訊かれている．初期設定であるmasterブランチのまま「Enable Automatic Deploys」をクリック
![heroku_github2.png](https://qiita-image-store.s3.amazonaws.com/0/201962/5f530ed0-e255-4cfe-1941-6a7f3181786f.png)

- これでGithubのmasterブランチへpushやmergeを行った時，自動でHerokuにもデプロイされるようになった
- 次にSettingsタブへ移動し，Buildpacksという項目で「Add buildpack」を押してpythonを選択 => 「Save changes」をクリック
- これでHerokuがpythonの存在を認識し，自動デプロイ時にエラーを吐かなくなる
![heroku_buildpack.png](https://qiita-image-store.s3.amazonaws.com/0/201962/c21c9f3c-918a-5af8-97a0-a5163ed7899b.png)


# Python3パート：botコードと設定ファイルをHerokuへデプロイ
## ファイル構成
- 以下のファイルをGithubにpushする (masterブランチにpushすればHerokuにも勝手にデプロイされる)
- 具体例をGithubにも置いた https://github.com/sh141/Discord-question-bot-template
- これらのファイルを順に説明する

```
.
├ question.py // 匿名質問bot本体のコード
├ requirements.txt // 使いたいライブラリをHerokuに認識させる
├ Procfile // どんなプロセスを使いたいのかHerokuに認識させる
...
```


## bot本体のコードを書く
### 背景と要件
- Discordサーバで次のようなチャンネル構成をしており，#議題に匿名質問を投下したい

```
.
├ 会議部屋 // カテゴリ
│    ├ #議題 // Textチャンネル
│    └ 🔊vc //Voiceチャンネル
...
```
- Textチャンネルのタイトル＝議題なので，チャンネル名やチャンネル自体がコロコロ変わる
- このままでは，Textチャンネルのidをいちいち書き直さねばならぬつらみコードとなる
- そこで，「会議部屋カテゴリにある最初のTextチャンネル」を投下対象にすることで，チャンネルが変わっても自動で対応し運用コストを省けるbotに仕上げたい

### コードを書く上で必要なライブラリ discord.py-ReWrite
botのコードはPython3で書き，discordライブラリだけを利用する．

なぜimport discordするだけの話で1枠取ったのかというと，
今回の要件を満たすためには<b>一般によく引き合いに出される[discord.py 0.16.12](https://github.com/Rapptz/discord.py/tree/async)では無理で，派生版の[discord.py-rw 1.0.0a0.post2](https://pypi.org/project/discord.py-rw/)のほうを利用した</b>という経緯があったから．

- 要件上，素直な処理としては次のような動きが必要となる
    1. 全てのTextチャンネルの中から，カテゴリが会議部屋であるチャンネルを全て取り出す
    2. 取り出したチャンネルのうち，カテゴリ内で最初に存在するチャンネルを特定する
    3. そのチャンネルは「会議部屋カテゴリで最初のTextチャンネル」なので，ほくほく顔でチャンネルidを取得する

- しかし従来の[discord.py 0.16.12の解説](https://discordpy.readthedocs.io/en/latest/api.html)では，<b>1.の「会議部屋カテゴリに属しているTextチャンネルを取得する」術が見つからなかった</b>
- 公式のGithubでも，[v0.16.12ではカテゴリをまともに使えない](https://github.com/Rapptz/discord.py/issues/1208)旨の記述が見られた

> The async (0.16.x) branch has no support for channel categories. In short: "No Way"
- 0.16.xブランチではチャンネルカテゴリをサポートしていません -

> You can set the target to any role or user. Those are just examples.
As for category channels, they are fully supported on the rewrite branch as CategoryChannels.
- カテゴリチャンネルはrewriteブランチでサポートされていますよ -

- そこで今回は，議論中にもあったrewriteブランチであるdiscord.py-rwを使うことにした (ちゃんとPyCharmでインストールできた)
- [公式のdiscord-rw解説](https://discordpy.readthedocs.io/en/rewrite/migrating.html)にもたくさん書かれているが，v1.0へ移行して書き直された代物でありチャンネルのカテゴリもしっかり使えた
- なおv0.16.12と比べて関数などの名前が変わっているので公式解説を要参照

### 要件を満たすbotのコード
- コード中では「import discord」だが，前述の通り使用しているのは一般のv0.16.xではなくrewriteブランチのdiscord.py-rw 1.0.0a0.post2である
- コード中にある以下の3つは自分の環境に置き換える
    - INT_ID_OF_YOUR_CATEGORY (カテゴリID)
    - INT_ID_OF_SERVER (サーバID)
    - TOKEN_OF_YOUR_BOT (botのToken - [前準備](#前準備)で作ったbot欄からコピー)
- カテゴリIDやサーバIDは[こうやって見つける](https://support.discordapp.com/hc/ja/articles/206346498-%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC-%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC-%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8ID%E3%81%AF%E3%81%A9%E3%81%93%E3%81%A7%E8%A6%8B%E3%81%A4%E3%81%91%E3%82%89%E3%82%8C%E3%82%8B-)
- なおbotのテストはわざわざHerokuに上げなくてもローカルPCで可能 (Tokenで接続するのがローカルだろうがHerokuだろうが関係はないということ)


```python:question.py
import discord

client = discord.Client()  # 接続に使用するオブジェクト


# 起動時
@client.event
async def on_ready():
    print('ログイン成功')


# メッセージを監視
@client.event
async def on_message(message):
    # 「/box」が頭についたメッセージならオウム返しする
    if message.content.startswith('/box'):
        # 文字から「/box」を抜く
        question = message.content[len('/box'):].strip()
        # 質問させたいチャンネルのid
        target_channel_id = getTargetChannelId()
        
        # id=0なら質問者にエラー報告DM
        # idが0以外なら匿名質問する
        if target_channel_id == 0:
            dm = await message.author.create_dm()  # 質問者へDM作成
            await dm.send(
                'Sorry, メッセージを送信できませんでした．'
                'もう1度試してみてください．\n'
                '【質問文】' + question)
        else:
            # 匿名質問させたいチャンネル
            target_channel = client.get_channel(target_channel_id)
            # チャンネルに質問メッセージ送信
            await target_channel.send(question)


# 匿名質問させたいチャンネルのidを取得
# 指定したカテゴリにある最初のTextチャンネル＝質問させたいチャンネルとみなす
# ただしカテゴリにチャンネルが無い時は0を返す
def getTargetChannelId() -> int:
    # 質問させたいチャンネル(対象チャンネル)
    target_channel = {'id': 0, 'position': 99999999}
    # ***********************************************************
    # 指定カテゴリ(対象チャンネルが含まれたカテゴリ)の名前
    
    category_id = INT_ID_OF_YOUR_CATEGORY  # カテゴリidを指定
    target_category_name = client.get_channel(category_id).name
    
    # ***********************************************************
    # 指定したサーバにある全てのTextチャンネル一覧
    all_channels = client.get_guild(INT_ID_OF_SERVER).text_channels
    
    # 全てのTextチャンネルから「指定カテゴリに属する最初のチャンネル」を探す
    for channel in all_channels:
        # 指定カテゴリに属する場合だけ対象チャンネル候補とみなす
        if str(channel.category) == target_category_name:
            # positionが小さいほうを「より対象チャンネルに近い」として交換
            # 初期値はpositionが大きい(99999999)ので，必ず入れ替わる想定
            # 繰り返せば，最後にはpositionが最も小さいチャンネルを代入できる
            if target_channel['position'] > int(channel.position):
                target_channel['id'] = int(channel.id)
                target_channel['position'] = int(channel.position)
    # 最終的に代入されたidを返す
    return target_channel['id']


# botとしてDiscordに接続(botのトークンを指定)
client.run('TOKEN_OF_YOUR_BOT')
```

## requirements.txtを書く
- Herokuはコマンドライン操作でライブラリのインストールができるが，インストールが反映されないので実質的にインストールできない (意味不明)

- つまり，このままではquestion.pyでdiscordライブラリが使えず詰む
- そこで，以下のようなrequirements.txtを置くと，Herokuが認識して勝手にライブラリを用意しておいてくれる

```text:requirements.txt
discord.py-rw==1.0.0a0.post2
```

- 拍子抜けだが1行でよい．ライブラリ名とバージョンを明記する

## Procfileを書く
- Herokuのコンソールから直接question.pyを実行してもまあ動くが，それを命令するこちらのPCをずっと起動しておかねばならない(それではHerokuにする意味がない)
- Herokuに24時間働いてもらうために，まずpython起動コマンドを登録する
- 以下のようなProcfileを書いておくと，Herokuが「実行するプロセス」として認識してくれる

```:Procfile
worker: python question.py
```

- 意味は「workerという種類のプロセスで"python question.py"を動かす」
- なおファイルは拡張子をつけない．大文字小文字も合わせないとHerokuが認識しない

## 全て終わったらGithubへpush
masterブランチへpushすればHerokuにも自動デプロイされる．
HerokuのOverviewタブやActivityタブでデプロイの様子と結果を確認できる．

# Herokuパート：botを24時間働かせる
- 24時間戦えますか？ (戦える模様)
- 冗談はさておき，デプロイ完了して少し待つとHerokuのResourcesタブにFree Dynosとしてworker項目が追加されている
- このworkerを有効にすると，Herokuが寸暇を惜しんでひたすら質問箱botを働かせ続けてくれる
- 逆に言えば，ここで有効にしたプロセスしか動かしてくれない
- なおUIがわかりにくい．編集ボタンを押す => トグルボタンを押す => 「Confirm」でworkerが有効になる
![heroku_dyno1.png](https://qiita-image-store.s3.amazonaws.com/0/201962/759cf5b3-c061-f374-5e5d-2d46b44eac30.png)

# Discordパート：botの使い方
※予めbotコード内でカテゴリを指定しておく必要がある

- botが閲覧できるTextチャンネルで「/box」を文頭につけたメッセージを送信する
- botがメッセージ部分だけ取り出して，指定カテゴリにある最初のTextチャンネルで発言する
- 例えば今回の場合，botにDMで「/box 24時間戦えますか？」を送りつけると，会議部屋カテゴリにある最初のTextチャンネル「#議題」で，botが「24時間戦えますか？」と発言する
- 実は/boxと質問文の間にスペースを空けなくても反応してくれる
- 会議部屋カテゴリにTextチャンネルが無かった時は「メッセージを送信できなかったよ」と質問者にDMを返してくる
- #議題で/boxコマンドを使ってしまうと，/box付きの質問者のメッセージまで送信されているので誰が質問したか丸見えになってしまう．こっそりbotへDMを飛ばすのがよい

# 終わりパート：余談
- Herokuはどうやらwebサービスを置く場所として意図されているようで，[Procfileの説明](https://devcenter.heroku.com/articles/getting-started-with-python#define-a-procfile)もwebが前提となっている
- Discord botをしかもPythonで作るのだから2時間もあれば終わると思ったら2日奪われて怒髪天を衝きそう