Cloud RunでSlackの分報をちょっとだけ楽しくしてくれるBotを作ってみた

本記事は、[NTTコミュニケーションズ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/nttcom) の9日目の記事です。
昨日は、 @mtskhs さんの [スポーツ解説アプリ SpoLive における分析基盤の構築](https://qiita.com/mtskhs/items/ca10f926900303f09bf8) でした。

# この記事の概要
最近、GCPの[Cloud Run](https://cloud.google.com/run/?hl=ja)が[正式リリース](https://cloud.google.com/blog/products/serverless/knative-based-cloud-run-services-are-ga)になったのでなんか作ってみたいなぁと思い、雑にSlackBotを作ってみました。
その作ったSlackBotの紹介をしようと思います。

# 作ったもの
その名も*haiku-man*です。
何が出来るかと言うと

<img width="405" alt="Slack___tweets_peter___WebCore___220_new_items.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/db0b9d7c-c684-354e-9538-2c78c6261298.png">

こんな感じで1日1回俳句を読んでくれます。

この俳句の文章は全て自分の分報チャンネルから取ってきています。


## 分報とは？

僕の所属するチームでは[分報](http://c16e.com/1511101558/)を取り入れていて、自分用のpublic channelに仕事の話とかコンビニで買ったお菓子の話とかを毎日色々書いています。
分報がどんな感じで役に立ってるかは以下の記事を読んでいただくとわかりやすいと思います。
https://developer.ntt.com/ja/blog/d55e2be0-255d-4321-9d45-8608ce9d9726

# システム構成図

![名称未設定ファイル_drawio_-_draw_io.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/83a9afe3-52d7-c4fb-4dd0-91f1afb77371.png)

処理の流れは以下の通りです
1.自分の分報チャンネルでの呟きを[Slack Event API](https://api.slack.com/events-api)を使って取得、サーバー（CloudRunで実装）に送信。
2.呟きを[COTOHA API](https://api.ce-cotoha.com/contents/index.html)で形態素解析にかける
3.COTOHAAPIの結果を受け取って、5音と7音とそれ以外に分ける
4.5音と7音の言葉は[BigQuery](https://cloud.google.com/bigquery/?hl=ja)に挿入してデータを貯める。
5.決まった時間に[Cloud Scheduler](https://cloud.google.com/scheduler/?hl=ja&authuser=2)を使ってCloudRunのエンドポイントを叩く
6.その日に溜まった5音と7音の言葉をランダムにBigQueryから取得
7[Incomingwebhook](https://slack.com/intl/ja-jp/help/articles/115005265063)を経由してSlackに投稿する

と言う感じです

ここから先は構築時のtips的なことを書いていきます

# Cloud RunでのSlackBotの構築方法
## Dockerfileの用意

公式ドキュメント
https://cloud.google.com/run/docs/quickstarts/build-and-deploy

Cloud RunはCloud Functionsとは違ってコンテナ上で動作します。
なので、Dockerfileを書く必要があります。

今回のhaiku-manのDockerfileはこんな感じです。

```Dockerfile
# Use the official Python image.
# https://hub.docker.com/_/python
FROM python:3.7

# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . .

# Install Packages
RUN pip install -r requirements.txt

# Run the web service on container startup. Here we use the gunicorn
# webserver, with one worker process and 8 threads.
# For environments with multiple CPU cores, increase the number of workers
# to be equal to the cores available.
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 app:app
```

`requirements.txt`の中に必要なパッケージを書いてます。

<details><summary>`requirements.txt`の中身はこんな感じです
</summary><div>

```
cachetools==3.1.1
certifi==2019.11.28
chardet==3.0.4
Click==7.0
Flask==1.1.1
google-api-core==1.14.3
google-auth==1.7.2
google-auth-oauthlib==0.4.1
google-cloud-bigquery==1.22.0
google-cloud-core==1.1.0
google-resumable-media==0.5.0
googleapis-common-protos==1.6.0
gunicorn==20.0.4
idna==2.8
itsdangerous==1.1.0
Jinja2==2.10.3
MarkupSafe==1.1.1
numpy==1.17.4
oauthlib==3.1.0
pandas==0.25.3
pandas-gbq==0.12.0
protobuf==3.11.1
pyasn1==0.4.8
pyasn1-modules==0.2.7
pydata-google-auth==0.1.3
pyee==6.0.0
python-dateutil==2.8.1
python-dotenv==0.10.3
pytz==2019.3
requests==2.22.0
requests-oauthlib==1.3.0
rsa==4.0
six==1.13.0
urllib3==1.25.7
Werkzeug==0.16.0
```
</div></details>

今回は結構いろいろ入れてますが、最低限SlackのEventAPIが使えたらいいとかであれば

```Dockerfile
RUN pip install Flask gunicorn
```

として、Flaskとgunicornだけ入れるとか特定のパッケージだけ入れることもできます。

## コードを書く
slack event apiの処理はこんな感じです（一部抜粋）


```python
app = Flask(__name__)

@app.route('/slack/events', methods=['POST'])
def slack_event():
    if request.headers['Content-Type'] != 'application/json':
        app.logger.debug(request.headers['Content-Type'])
        return jsonify(res='error'), 400

    # event apiの認証部分
    if request.json['type'] == 'url_verification':
        return jsonify({
            'status': 'OK',
            'data': request.json['challenge']
        }), 200

    if request.json['event']['type'] == 'message':
        # 受け取ったtextをcotohaapiに投げる
        cotoha(request.json['event']['text'])

        return jsonify(res='ok'), 200
```
event apiの認証については、Slackのダッシュボードで`Request URL`を指定する際に`url_verification`というtypeのイベントがくるので、そのイベントを受け取ったら、requestの`challenge`の値を返してあげるということをやっています。
![Slack_API__Applications___WebCore_Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/1d7c2571-33fe-fb0d-09cb-c23b931c521a.png)



## イメージをGCRへアップロードする

app.pyとかでとりあえず、アプリケーションを書いたら、コンテナイメージをGCR（Google Container Registry）にアップロードします。
この作業にはGCPのプロジェクトIDとかgcloudコマンドの設定とかは必要になります。

```bash
gcloud builds submit --tag gcr.io/{YOUE_GCP_PROJECT_ID}/{IMAGE_NAME} --project {YOUE_GCP_PROJECT_ID}
```

GCRのダッシュボードで指定した{IMAGE_NAME}のリポジトリがあれば成功です。


## Cloud Runの設定
使用を開始するから
![Cloud_Run___Marketplace_-_gikai-webcore_-_Google_Cloud_Platform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/10d4b1f4-6d35-76b3-4490-790f7a7204dc.png)

サービス作成を選択

コンテナイメージURLはGCRへの登録時に使用した　`gcr.io/{YOUE_GCP_PROJECT_ID}/{IMAGE_NAME}`を使います。サービス名はお好きな物を
![サービスの作成_–_Cloud_Run_–_gikai-webcore_–_Google_Cloud_Platform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/3462306a-bdf5-aafc-5d4a-e30e8b5adf31.png)

あとはロケーションなど設定すると`作成`を押すと完了です。

成功するとこんな感じでURLが発行されます。

![サービスの詳細_–_Cloud_Run_–_gikai-webcore_–_Google_Cloud_Platform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/caf4054f-0438-1d6a-4b23-8c1c55a3b3bb.png)

# その他開発中のtips
## Slack event APIのtimeoutについて
SlackのAPIは仕様で3秒以内にresponseを返さないとtimeoutになって、同じ内容を再送してきます。
今回は、event APIで受け取ったtextをCOTOHA APIで形態素解析にかけてるので、3秒以内にレスポンスを返すのは難しく、textが再送されて来ると同じ文字が何回もBigQueryに入る可能性があるため、対策する必要があります。

対策としては真っ先に200を返すのがいいらしいですが、Flaskで先に200だけ返して処理を続けるいい方法が思いつかなかったので、`header`の`X-Slack-Retry-Reason`を見て、`http_timeout`だった時はとりあえず、レスポンスだけ返すということをしています。

真っ先に200返す対策法についてはこちら
https://qiita.com/saken649/items/b70e462ae41614b72f77
 

ドキュメント
https://api.slack.com/events-api
>We'll tell you why we're retrying the request in the X-Slack-Retry-Reason HTTP header. These possible values describe their inciting events:

> `http_timeout` - Your server took longer than 3 seconds to respond to the previous event delivery attempt

```python

if "X-Slack-Retry-Reason" in request.headers:
        if request.headers['X-Slack-Retry-Reason'] == 'http_timeout':
            return jsonify({
                'status': 'OK'
            }), 200
```

## COTOHA APIでの形態素解析について
tipsかというと微妙ですが、
`kana`で漢字の読み方を出してくれるので、文字の数を取る際は、`kana`でカウントして、BigQueryには`form`の内容を入れるようにしています。
雑に実装する気しかなかったので、`kana`がなかったら漢字の文字数をカウントしてBigQueryに突っ込もうと思っていたのであってよかった。

```
 "tokens" : [ {
        "id" : 0,
        "form" : "犬",
        "kana" : "イヌ",
        "lemma" : "犬",
        "pos" : "名詞",
        "features" : [ ],
        "dependency_labels" : [ {
          "token_id" : 1,
          "label" : "case"
        } ],
```

## BigQuery周りについて
今回、BigQueryを使ったのは最近仕事でBigQueryを使うことがあって。多少は慣れているからというのが理由です。
個人的には、データ挿入の際にpandasを使って、dataframeで挿入することができるので、SQLとか書く手間がなくていいかなと思ってます。

```python
def insert_bgq(text):
    characters_record = [{
        'datetime': datetime.datetime.now(),
        'text': text
    }]
    characters_df = pd.DataFrame(characters__record)
    characters_df["datetime"] = pd.to_datetime(
        characters_df["datetime"], format='%Y-%m-%d %H:%M')
    characters_df.to_gbq(
        destination_table=f'{settings.BQ_DATA_SET}.{settings.BQ_TABLE_NAME}',
        project_id=settings.PROJECT_ID,
        if_exists='append',
        credentials=service_account.Credentials.from_service_account_file(
            './service-account.json',
        ))
```

こんな感じでBigQueryに入ります
![BigQuery_-_gikai-webcore_-_Google_Cloud_Platform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/76dc2933-5f1f-0554-a4ca-22982cffbfb3.png)


## Cloud Schedulerについて
Cloud FunctionsやCloudRunを定期的に実行させたい場合はCloud Schedulerが便利です。
こんな感じでGUIでcron的に設定することもできますし、gcloudコマンドを使ってJSONをPOSTすることもできます。

![slack-haiku_–_Cloud_Scheduler_–_gikai-webcore_–_Google_Cloud_Platform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150295/6834116f-ad53-57c5-c2cf-587a68ff3757.png)



# Cloud Runを使っての感想
かなり雑多な記事になりましたが、最後にCloudRunを使ってみての感想を書いておきます。

個人的には開発の段階からDockerのイメージ使っていて、完成したら、そのイメージをGCRに上げてCloudRunの設定をするだけで動いたので少し感動しました。
CloudFunctionsは何度か使ったことがあるのですが、ローカルで開発してからCloud Functionsで動かす時は結構改修した覚えがあるので、その改修が全くなく、すぐにDockerイメージがあればローカルと同様に動かせるのはすごいよかったと思います。

という感じで僕の記事は終わろうと思います。

明日は @koki-sato さんの記事です。