discord.pyでbotに一定時間ごとに発言させる【async版】

初投稿です。ご意見大歓迎です。質問等ありましたらコメント頂ければお受けできる範囲でお受けします。

※2019年12月12日追記
>本記事は `discord.py ver0.16.12`を前提に書いています。
>現行の`discord.py`の最新版は`ver 1.3.0`とメジャーアップデートがなされており、大きく仕様が異なります。
>最新版でのコードについては、以下の記事に追記予定です。
>[【最新版】discord.pyでbotに一定時間ごとに発言させる](https://qiita.com/rareshana/items/2917b42016557e27229b)

# 前提・この記事でできるようになること
## 前提
* discord.py 0.16.12
* Python 3.6

discord.pyはDiscordのAPIを簡単に叩けるようにするためのPythonのライブラリ。discord.pyを利用したbotの導入は既に済んでいるものとする[^1]。
[^1]: discord.pyとbotの導入については、[こちら](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)とかが詳しい
また、筆者は`async`とか`await`とかに疎く、なんかつけないといけないやつ程度の認識しかないことをご承知ください。(勉強したくはありますが難しい……)

## この記事でできるようになること
discord.pyを利用して、botに一定時間ごとに特定の処理を行わせることができるようになる[^2]。
[^2]: 記事タイトルよりも範囲が広いですが、具体的なワードにしたほうが困っている人の役に立つと思い敢えてタイトルはこうしています

# 動機
元々やりたかったのは、ミリオンライブシアターデイズのTwitter公式アカウントのツイートを、プレイヤーが集まるDiscordのサーバーに連携すること。
Google検索すると**「Webhook使ってIFTTTで連携すれば簡単」**という記事で溢れている。しかし私の場合、以下の2つの事情からその手法はとりづらかった[^3]。

+ 私自身に権限がなかったため、WebhookのURLを取得するには管理者さんにお願いする必要がある
+ Webhookを利用する場合それ専用のBotが生成されるが、連携には既に導入しているbotを利用したい

こうした事情から既存のdiscord.pyを用いて作成しているbotにコードを追加して、TwitterとDiscordを連携する手段を模索することにした。

方法としては、「TwitterのAPIを叩きミリシタ公式アカウントのツイートを取得し、新しいツイートがあればそれをチャンネルに発言する」というのを数秒ごとに繰り返すことになる。この中で、**数秒ごとに繰り返す処理**で躓いたので、以下にまとめます。
[^3]: 逆に以下が気にならない方であれば、[こちら](https://blog.k-san.info/discord-twitter-webhook)のIFTTTとWebhookを用いて連携する記事が参考になるかもしれません

# 動くコード例
いろいろすっ飛ばして、10秒毎に「おはよう」と発言するコードは以下。「『おはよう』と発言する」部分を適当なコードに置き換えれば、例えば上記のような「数秒ごとにTwitterのAPIを叩き差分をDiscordに投稿する」こともできる。

```python:main.py
import discord
import asyncio

client = discord.Client()

@client.event
async def on_ready():
    asyncio.ensure_future(greeting_gm())

async def greeting_gm():
    await client.send_message(channel, 'おはよう')
    await asyncio.sleep(10)

client.run(token)
```
注意点としては、処理を`async def on_ready():`内に記述することと、`time.sleep(10)`ではなく`await asyncio.sleep(10)`と書くこと、`asyncio.ensure_future`でTask化(?)すること。

1点目は、`async def on_ready():`内に記述したコードはbotがサーバでオンラインになった直後に自動的に実行されるため、botに定期的に行わせたい処理とマッチする。
2点目は、discord.pyのv1.0.0のリファレンスにもあることだが、単に`time.sleep(10)`とするとその10秒間は全てのコードの実行が停止する(?)ため。
3点目は、このようにせずに単に`await greeting('おはよう', 3)`などとすると、それ以下の記述が実行されないため。例えば以下のようなコードでは、botが「おやすみ」と言ってくれることはない。

```python:main_bad.py
import discord
import asyncio

client = discord.Client()

@client.event
async def on_ready():
    await greeting_gm() #この中のwhile Trueを延々とループするために
    await greeting_gn() #この行に到達できない
    
async def greeting_gm():
    while True:
        await client.send_message(channel, 'おはよう')
        await client.sleep(10)

async def greeting_gn():
    while True:
        await client.send_message(channel, 'おやすみ')
        await client.sleep(10)

client.run(token)
```
起動後からずっと複数の処理を定期的に行わせたいなら`asyncio.ensure_future`を用いて書く必要がある[^4]。
[^4]: 率直に言って、この辺りは理解が不十分である。[こちら](https://qiita.com/icoxfog417/items/07cbf5110ca82629aca0)を見る限りでは、```loop=asyncio.get_event_loop()```と```loop.run_forever()```とか```loop.run_until_complete(asyncio.gather(greeting_gm(), greeting_gn()))```みたいなのを書いてやる必要がありそうなのだが、そのように記述すると *This eventloop is already running.* というエラーが出る。どうもdiscord.pyの中でイベントループを生成しているということのようだが、discord.pyを読むには至っていない。(仮にdiscord.pyの方でイベントループを生成しているなら、`asyncio.ensure_future`だけで動くのも理解できる気はする)

# 動かなかったコード
以下は特に読む必要はなし？躓きを記録することで何か意見がもらえるかもしれないし、他の躓いている人の何か助けになるかもしれないので、上記コードに至る失敗例をいくつか。
## その1
元々このbotには、botを起動するmain.pyと同じ階層に"kill"という名前のファイルが生成されるとbotを終了する処理が記述されていた。これは、1秒毎にディレクトリを見て、もし"kill"という名前のファイルがあれば終了処理を行うという形で記述されている。まさに、今やろうとしている処理と近い。このメソッドの名前を仮に`watcher`とすると、`threading`ライブラリを用いて以下のようなコードになっていた。

```python:main_ex.py
import threading
import discord
client = discord.Client()

# (ログイン後のbotの処理を記述するコードもろもろ)

def watcher():
    while True:
        if find_kill: # 'kill'というファイルがあれば
              break
        else:
              time.sleep(1)
    terminate_bot()

t = threading.Thread(target=watcher)
t.start()
client.run(token)
```
並列処理することで、ディレクトリの監視とbotの処理を分けている。そのため、```watcher```内の```time.sleep```の影響をbotは受けない。
そこでこのコードを参考にできないかと考え以下のコードを書いた。

```python:main_bad1.py
import threading
import discord
client = discord.Client()

# (ログイン後のbotの処理を記述するコードもろもろ)

def watcher():
    foo()

def greeting_routine():
    while True:
        client.send_message(channel, 'おはよう')
        time.sleep(10)

t = threading.Thread(target=watcher)
t.start()
t2 = threading.Thread(target=greeting_routine)
t2.start()
client.run(token)
```

これはうまくいかない。そもそも`send_message`は`await`なしでは動かないようになっているからだ。

## その2
それでは、と、以下のように書き換える。これもうまく動かない。(この辺りのコードはいろいろ試行錯誤していてうろ覚えなので、間違っているかもしれない)

```python:main_bad2.py
import threading
import discord
client = discord.Client()

# (ログイン後のbotの処理を記述するコードもろもろ)

def watcher():
    foo()

async def greeting_routine():
    while True:
        await client.send_message(channel, 'おはよう')
        time.sleep(10)

t = threading.(target=watcher)
t.start()
t2 = threading.(target=greeting_routine)
t2.start()
client.run(token)
```
これもうまくいかない。`time.sleep(10)`では全体の処理が止まってしまうとか以前に、`client.run(token)`以前に`greeting_routine()`が実行されるため、当然ながら発言先チャンネルの情報が取得できずエラーになる。
これを回避するために、サーバにログイン済みかどうかを調べるメソッドを用いて分岐させることも考えたが、リファレンスを読んでもそうしたメソッドが見当たらなかった上[^5]に、そもそも`client.run(token)`より前で、サーバにログインしていることを前提としたコードを実行するのはセンスがなさすぎると思い他の実装を模索する。
[^5]: これは私の読解力が低いだけで、存在するのかもしれない。存在しないほうがおかしいとも思う。

## その3
`client.run(token)`以下で実行しようとする。`client.run(token)`はbotが落ちるまで抜けないので、当然所望の動作は得られない。
※これに限らず`client.run(token)`以下に記述したコードは基本実行されないと心得るべき。

```python:main_bad3.py
import threading
import discord
client = discord.Client()

# (ログイン後のbotの処理を記述するコードもろもろ)

def watcher():
    foo()

async def greeting_routine():
    while True:
        await client.send_message(channel, 'おはよう')
        time.sleep(10)

t = threading.Thread(target=watcher)
t.start()
client.run(token)

t2 = threading.Thread(target=greeting_routine)
t2.start()
```
## その4
この辺りで、botに何かさせるのだから、`client.run(token)`したあとに実行される部分にコードを追加するしかないと悟る。
[リファレンス](https://discordpy.readthedocs.io/en/latest/api.html#event-reference)を読み直すうちに、`on_ready`以下に記述すればログイン直後にコードが確実に実行されることに気づき以下のコードを書く。

```python:main_bad4.py
import threading
import discord
client = discord.Client()

@client.event
async def on_ready():
    while True:
        await client.send_message(channel, 'おはよう')
        time.sleep(10)

client.run(token)
```

無事「おはよう」が10秒毎に発言されるようになったものの、今度は他のコマンドを受け付けない事態になった。これは先にも触れた`time.sleep(10)`が原因。`await asyncio.sleep(10)`に直すことで事なきを得た。