Ubuntu(WSL)からPythonでdiscord botを作成し、Herokuで動かす

# 概要
　僕はひょんなことからdiscordのbotを作成しようと思い立ったのですが、その時にかなりたくさんのトラブルに見舞われたので、ここに記録しておきます。
　環境はWSLのUbuntuです。

# 行程
　では実際に工程を追っていきます。
## 序
### wsl導入
http://www.atmarkit.co.jp/ait/articles/1806/28/news043.html
　まずはこの記事に沿ってすんなりとwslの日本語環境を。通常なら標準で沢山のソフトが内包されているUbuntuですが、このwslではインストールを容易にするためにほぼない状態なので、必要なものは適当にinstallしましょう。とりあえず何らかのEditorくらいは(Emacsやvimなど)。そのeditorの環境設定はご自由に。

https://qiita.com/funafuna/items/c3bb78a546cf2605205d
　wslのhome変更。sudo使わないといけない場面が増えますし、他にもリスキーなので[ln]( http://www.atmarkit.co.jp/ait/articles/1605/30/news022.html)を使って簡単にwindows上のwork spaceへ飛ぶ方がいいとは思います。なんにせよ`/mnt/c/`という表記や`ln`は重宝しますね。Tabによる補完はlnにも対応しています。

https://qiita.com/makky0620/items/e31edc90f22340d791ff
windows上でterminalが別windowを作るにはXmingが必要です。また、windows立ち上げごとにXmingの起動が必要なので、キーバインドを設定しておくと楽かもしれません。(__今回は必須ではない__)

http://wadap.hatenablog.com/entry/20080114/1200288402
terminalにtabを導入し、`C-j+<number>`のみでtab移動できるscreenもあれば便利かも。(__今回は必須ではない__)

### python導入
　pythonはwslのUbuntuにはdefaultには入っていないので新しいpython3をinstall。(必要なコマンドはterminalにpython3と打つと教えてくれます)
　Pythonどころかオブジェクト指向型のプログラム自体初めて触れたのですが、[aidemy](https://aidemy.net/courses/3010/exercises/S1-tEYgwxf)というページはオンライン上だけで説明を読んだり適宜ソースを書いたりしながらPythonを習得できるのでとても参考になりました。

### discord bot準備
https://qiita.com/PinappleHunter/items/af4ccdbb04727437477f
https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f
　このあたりのサイトがとても参考になるので見ながら進めていくといいのですが、僕の場合はpipの挙動がおかしく(egg_infoエラー)discord.pyがinstallできなくてかなり苦しめられました。かなりいろんなサイトを見たのですが、最終的に解決してくれたのは<u>[このページ](https://qiita.com/n_kats_/items/b8f770491e7489549cc8)</u>でした。discordのbotのscriptについて参考になるページは他にもたくさんあるので適当に検索してもらえばいいですが[このページ](https://starnak.hatenablog.com/entry/2018/07/28/230500)くらいは。
　さて、ここまでですでにbotを動かすこと自体はできているはずですが、PCを立ち上げてterminal上でPythonを動かしているときしかbotを使えません。というわけで[さっきのページの後半](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#heroku%E3%81%B8%E3%81%AE%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4)にもある通りにHerokuを利用します。

### Tokenは環境変数で隠す(超重要)
　順調に進んでいれば上記の時点でbotのtokenを取得して、localで動かしているはずです。**このtokenはいわばpasswordで、決して外部に漏らしてはなりません。仮に知られればbotが乗っ取られ、エログロ画像をbotの活動できる全範囲でばらまかれます。** もちろん普通にしていればtokenを教えるような場面はないのですが、実は1つ大きな罠があります。
　以下ではHerokuを用いてbotを自分のPCを用いずとも動かせるようにするのですが、通常のgithubアカウントではすべてのrepository(同期しているフォルダ)は公開されます。そこに含まれるbot用のsourceも公開されるわけで、見る人が見れば最後の列にあるtokenも把握されてしまいます。そう、少なくとも愚直に作業を進めていると(botをはじめて作った僕は実際、思い至りませんでした)**必然的にtokenを世界中に公開することになるのです。**
 そこでos内の環境変数にtokenを覚えさせて、それをbot用のsourceで利用します。herokuへのpushやdeployはPCから(localに)行われるので、tokenを外部に知られることが完全になくなります。
https://note.nkmk.me/python-os-environ-getenv/
osの環境変数の利用について。linuxでの環境変数の設定は~/.bashrc内に`export DISCORD_TOKEN='XXXXX'`と記載してください。これで`import os`とした上で`os.environ.get("DISCORD_TOKEN")`をtokenとして使用できます。


## Heroku
### Heroku導入
　ここで新たに問題発生です。なぜかわかりませんがherokuのinstallに用いられるsnapdの挙動が怪しいです。(snapdを入れなおしても同様。`snap --version`を使うとsnapdの欄はunavailableになっていました……。)さてsnapdの利用を諦めるまでにまた大量の時間を消費しましたが、結局は[Other installation methodsのStandalone installation](https://devcenter.heroku.com/articles/heroku-cli#other-installation-methods)で解決しました。

　また、このときに併せてGitのinstallも必要です。([このページ](https://devcenter.heroku.com/articles/getting-started-with-python#set-up)の上部にあります。)後々のことも考えると[GitHubのDesktop appもinstall](x-github-client://openRepo/https://github.com/Tomohir0/discordbot1026)しておくといいかもしれません。

### HerokuでDeploy
　[先ほどのページ](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f#heroku%E3%81%B8%E3%81%AE%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4)に従います。Procileやruntime.txt、requirements.txtはbot用のpythonファイルと同じディレクトリに置きましょう。サイトによってはrequirements.txtには`pip freeze > requirements.txt`とするようにと書いてあることもありますが、herokuが対応していないものもあるらしく(errorの原因になります)、必要最低限の`discord.py==0.16.12`でいいと思います。(そもそも僕はpipの調子が悪かったせいか、`pip freeze`にはdiscord.pyが含まれていませんでした。)
　そしてあまり考えずにterminalから指示されてるコマンドを入力します。この段階でerrorが出た場合は設定ファイルかpythonソースに誤りがあります。また、ここは僕が一番ハマったポイントなのですが、僕のやった限りでは[一度Herokuのページからボタンをずらしてdynoをonにしないといけない](https://github.com/ginbear/dotfiles/issues/1#issuecomment-322219729)ようです。まだ`Heroku logs --tail`では微妙にerrorが残っているのですが、とりあえずうまくいっています(？)

```
2018-10-27T13:51:03.820767+00:00 heroku[router]: at=error code=H14 desc="No web processes running" method=GET path="/" host=sino-shinma.herokuapp.com request_id=119b0e08-885c-444b-8c2c-5081975d256a fwd="126.224.83.208" dyno= connect= service= status=503 bytes= protocol=https
2018-10-27T13:51:05.015322+00:00 heroku[router]: at=error code=H14 desc="No web processes running" method=GET path="/favicon.ico" host=sino-shinma.herokuapp.com request_id=abca14b3-1b2f-4826-8e43-d75613c2882a fwd="126.224.83.208" dyno= connect= service= status=503 bytes= protocol=https
```

### 実際のbot作成
https://note.nkmk.me/python-pprint-pretty-print/
　`pprint`は多用します。

https://github.com/Rapptz/discord.py/issues/186
`@bot.event`と`@bot.command`が共にある場合、`@bot.event`内にeventの中に`await bot.process_commands(message)`とないと`@bot.command`は無視されます。

https://graffitinote.hatenadiary.jp/entry/2017/12/26/004657
これも見つけるのにかなり苦労したのですが、VCチャンネル内のメンバー一覧リストの取得方法です。

``` VC.py
@bot.command(description='「?vc」で「コロシアムVC」の参加メンバーの(ニックネームではない)名前一覧が得られます。')
async def vc():
    "「コロシアムVC」の参加メンバーの名前一覧を表示します。"
    channel = bot.get_channel("413951021891452932")
    if len([member.name for member in channel.voice_members]) == 0:
        await bot.say("今、" + channel.name + "には一人もいない……一人も……")
    else:
        member_list = pprint.pformat(
            [member.name for member in channel.voice_members])
        await bot.say(channel.name + "にいるのは\n" + member_list.replace(",", "\n") + "\nだよ！") # replaceで改行して見やすく
```

https://ogapsan.com/archives/1167
`pickle`の利用により、プログラムの完全に外側に変数を保管できます。

``` note.py
@bot.command(description='「?note 楽器神魔の場面で魔書ばかり引きました」と記録しても、他の人に読み出されることはありません。')
async def note(ctx: commands.Context,memo: str):
    "ユーザーごとにメモを記録します。「?call」で呼び出します。"
    f_name = "/tmp/memo_" + ctx.message.server.id + ".pkl"
    with open(f_name, 'wb') as f:
        pickle.dump(memo,f)
    await bot.say("覚えました！！")
```

https://discordpy.readthedocs.io/ja/latest/faq.html?highlight=ctx#how-do-i-get-the-original-message
command中で入力を伴うようなsub commnadをもつものについては@bot.groupを用いる。ここのQ&Aは必読。

https://discordpy.readthedocs.io/ja/latest/ext/commands/api.html#discord.ext.commands.Context
ctxはよく使う情報を携えているのでとても便利。`@boddy.commnad(pass_context=True)`と`pass_context=True`を忘れると使えないので注意。



https://discordpy.readthedocs.io/ja/latest/api.html#discord.utils.get
discord内の様々なobjectをidから得ることができる。

http://www.iam4a.ml/141.p
役職もbotで扱うことが可能。その場合、はじめにdiscord側でbot用の上位役職を用意する。

``` Absent.py
@bot.command(description='',pass_context=True)
async def absent(ctx: commands.Context):
    "役職をAbsentに変更して遅刻しそうないし欠席の可能性があることを明確にできます。「?role_reset」で全員のAbsentをもとに戻せます。"
    user = ctx.message.author
    role = discord.utils.get(user.server.roles, name="Absent")
    await bot.add_roles(user, role)
```

http://ncastar.hatenablog.com/entry/2016/01/20/001858
https://blog.mah-lab.com/2013/05/16/heroku-commons-16/
これはかなり重要。herokuにおいてファイルの保存は/tmp/以下にのみ可能であり、しかしserver再起動時に消滅する。結局はglobal変数と同じだけしか生き延びられない。dynoの停止から一定時間でもtmp内は消えるが、常に動いているdiscord botの場合は関係ない。

## S3
### S3との連携
特にここでは文字データをS3に保存することでserverの再起動におけるデータの消失を防ぐことを目的とする。

https://devcenter.heroku.com/articles/s3-upload-python#direct-uploading
herokuとawsの設定。

https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html
boto3のinstallとsetupについて。~/.aws/credentialsと/.aws/configだけは必要。

https://stackoverflow.com/questions/33388555/unable-to-install-boto3
boto3のinstallにはpipの代わりにpip3を使う必要があることも。

https://www.sejuku.net/blog/41293
json形式の取り扱い。boto3はjsonと親和性が高い。

https://www.yoheim.net/blog.php?q=20160608
dict型から要素を取り出す際にはgetを使うと例外処理もできてよい。

https://dev.classmethod.jp/cloud/aws/upload-json-directry-to-s3-with-python-boto3/
実際にuploadする。ここではjsonデータをファイルとして保存せず、直接s3へアップロードする手法を取る。

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#object
上記でも使われているObjectについて。s3のAPIなので、困ったらここを見る。Object.getやObject.putは最低限使うことに。

``` json.py
@bot.command(description='serverのみんなでmemoを共有できます。', pass_context=True)
async def notes(ctx: commands.Context, label: str, memo: str):
    "「?notes secret ギルマスは実は高校生」とすれば、secretラベルで「ギルマスは実は高校生」を記録できます。スペースが区切りとみなされます"
    json_key = "memo_" + ctx.message.author.server.id + ".json"  # 読み出し
    obj = s3.Object(bucket_name, json_key)
    if obj.delete_marker == None:
        memos = json.loads(obj.get()['Body'].read())  # s3からjson => dict
    else:
        memos = {}
    memos[label] = memo  # 追加
    obj.put(Body=json.dumps(memos))
    await bot.say("覚えました！！")
```


### tmpとs3の併用
　s3と直接ファイルをやり取りすることに成功したものの、外部とファイルをやり取りしているために微妙に遅い。応答が遅れるためか、一部では存在するはずのデータがないものとして扱われることも……。実用に耐えるか微妙だったため、方針を変更。通常時は今まで通りtmpにpickle形式で変数を保存して利用。bot起動時にs3からtmpにdownload、定期的にtmpからs3にuploadすることでデータを安全に保持する。これにより従来のスピードを保ったまま、botの再起動を差し支えなく行えることとなった。

https://qiita.com/ikai/items/38c52e0c459792a4da46
list_objectによりs3内のファイルなどの一覧を得ることができる。結果はdict型で帰ってくるので注意。(様々なデータを包含している。)

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.list_objects
list_objectsについて。Client関連では役に立つものも多い。

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Object.upload_file
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Object.download_file
upload_fileとdownload_fileについて。

``` .py
import discord
from discord.ext import commands

import os
import glob

import boto3
bucket_name = "XXXX"
s3 = boto3.resource('s3')
# s3連携

error_count = 0
# async外で保存するためにGlobal変数を用いる

def func_tmp_up():
    "tmpフォルダ内のfileをs3に避難させます(upload)。"
    for file_name in glob.glob("/tmp/*.*"):
        # await bot.say(file_name)
        # "/tmp/"のままではs3においては""(空欄)ディレクトリ内のtmpディレクトリにアクセスしてしまう
        s3.Object(bucket_name, file_name[1:]).upload_file(file_name)
    # await bot.say("Upload's Finished")

def func_tmp_dl():
    "s3からtmpフォルダにfileを復帰させます。(download)"
    client = boto3.client('s3')
    # "/tmp/"のままではs3においては""(空欄)ディレクトリ内のtmpディレクトリにアクセスしてしまう
    response = client.list_objects(Bucket=bucket_name, Prefix="tmp/")
    file_list = [content['Key'] for content in response['Contents']]
    for file_name in file_list:
        # await bot.say(file_name)
        s3.Object(bucket_name, file_name).download_file("/"+file_name)
    # await bot.say("Download's Finished")

@bot.event  # server加入時の処理
async def on_ready():
    print('Logged in as')
    print(bot.user.name)
    print(bot.user.id)
    print('------')
    func_tmp_dl()  # まずdl

@bot.event  # error時に定期的にupload
async def on_command_error(exception: Exception, ctx: commands.Context):
    global error_count
    channel = bot.get_channel("XXXX")
    error_count += 1
    if error_count % 10 == 1 and ctx.message.author.id == "XXX":  # 毎回はさすがに多い。他の人のerrorは無視
        func_tmp_up()
        await bot.send_message(channel, "up")  # 一人serverに報告
```

 **また、"/tmp/a.pkl"がheroku内においては`~/tmp/a.pkl`を表すのに対し、s3においては`~//tmp/a.pkl`(""というディレクトリが存在する)となることに注意。**

## 実際のbotの作成(続)
　日本語でのdiscord botに関するページは一見多いが、そのほとんどの内容はたかだかbot導入くらいまでであり、botのまともな内容に言及しているものは少ない。(Bot command frameworkにすら入っていない時点でお察しである。)
　ならば英語を読めばいいかというとそうでもない。[discord.pyのAPI Reference](https://discordpy.readthedocs.io/en/latest/api.html)を見ればかなり助けになるのは確かだが、それでも実際の使い方については分からないことも多い。英語版の質問サイトの回答を修正して初めてうまくいったことも多い。情報が錯綜しているのだ。

### sub command

### cog
https://gist.github.com/leovoel/46cd89ed6a8f41fd09c5