Slack上の特定キーワードに反応してGoogleDocsの内容を返すBotnyanを作ったお話

# 概要

- Slackで特定のキーワードに反応してGoogleDocsの内容を返すBotnyanを作りました : https://github.com/supistar/Botnyan
- 文章中に含まれる改行もきちんと反映されるよ！
- Ingress のコミュニティから生まれました。RES/ENL 問わず使ってみてね！ :ghost: 

# 事の経緯

こんにちはすぴにゃんです。
横の人とか横の横の人が Python をよく書いているので、うちもちょっと書き始めたらハマってしまい Python + Flask で Slackbot をつくっちゃいました。

https://github.com/supistar/Botnyan

## Ingress と Slack

事の発端は、そう Ingress[^1] です（笑
Ingress はすでにご存じの方も多いと思うので詳細は割愛しますが、ゲームの特性上、特定の地域のプレイヤーがコミュニティを形成し、そのコミュニティを通じて交流・作戦展開をすることが重要になってきます。
このコミュニティですが、現在では Slack への移行が一部コミュニティで進んでおります。
＃Slackへの移行についてはココのBlog[^2] を見ていただければ。

## コミュニティ加入の第一歩が高い Slack

Slack への移行が進み、コミュニティを便利にするためいろいろな試行錯誤がありました。
その1つが、コミュニティ参加時の案内メッセージです。

Slack は特殊な側面があり、利用者にとっての1つの大きな壁が「アプリケーションが英語ベース」という点です。
エンジニアにとって英語は朝飯前の世界ですが、必ずしもエージェントさんがエンジニアでないように、みんなが英語に慣れているかと言えばそんなことはありません。

コミュニティ加入の障壁が高ければ全体としての参加率が下がり、コミュニティの形成を阻害するばかりか、そもそもの Slack への移行目的が台無しです。

そこでその障壁を下げるために、スクリーンショット付きのマニュアルやTipsを書いたドキュメントを用意し、新規加入の方への案内をする改善がなされました。
これによって初めての方へのチュートリアル・導線がつくられ、参加していただくことがより容易になってきました。

## 意外と難儀なSlackbot

しかしこの案内メッセージ、最初は人力でやっていましたが

- 文章の内容を忘れる
- そもそも文章のテンプレどこにあったっけ事案（検索しても出てこない）

などの問題が頻発したため、Slackで初めから用意されているSlackbotにその役割が移っていきました。
これによって案内メッセージの自動化がなされましたが、このBotなかなかの曲者で...

- 設定したキーワードは（句読点や記号などのデリミタを除いた部分で）完全一致
- 改行した文を出力できない
- URLを貼り付けてもいい感じに内容をすべて展開してくれる訳じゃない（Slackの仕様上、同一URLの場合キャッシュされて内容が展開されない）
- Slackbotに登録した文章の修正が思いの外、大変

という問題を抱えてしまい、「これどうにかして解決したいよね...」というところから出来上がったのが `Botnyan` です。

# Botnyanの登場

Botnyan は Python+Flask ベースで書かれており、Slack 上では Outgoing-Webhook を契機とした bot として動作します。

![slack.png](https://qiita-image-store.s3.amazonaws.com/0/11364/e9b538ca-dd29-d29f-7634-c14dbcb164b8.png)

動作はいたってシンプルで：

- Outgoing-Webhooks に指定したキーワードが投稿文に含まれていた場合、指定されたRESTのEndpoint (Botnyan) へアクセス
- Botnyanは指定されたキーワードに紐付いたGoogle Docsにアクセス
- Botnyan <-> GoogleDocs はサービスアカウントを通じて接続。ドキュメントの内容をテキストファイル形式で取得
- 取得した内容をキーワード反応したチャンネルにて発言

となっています。

これを導入してから、仲間のエージェントに大変好評をいただいています。

- GoogleDocs上で内容が更新できるため、権限を持っている人なら編集できる＋履歴管理ができる
- 改行を含んだメッセージを送信できる**（これ重要）**

作成後、近隣のコミュニティでも同様の問題を抱えていると聞き、Private リポジトリで管理していたものをPublicソースとして公開することにしました。
最初は Apache+WSGI な環境で動くようにしていたのですが、Heroku でも動かせるようにオリジナルから少し改造を加えています。

導入方法については README-jp を用意しているのでそちらもご覧いただければ！
https://github.com/supistar/Botnyan/blob/master/README-jp.md

# 技術的なお話

ここでは簡単に、Botnyanで使った技術的な内容を記しておきます。

## 1. Flask で公開した Endpoint に制限をつける

Flask ではいくつかのデコレーターを利用することで、Endpoint にアクセスする際の制限をつけることができます。

Slack の Outgoing-Webhooks では `POST` + `application/x-www-form-urlencoded` で指定した Endpoint にアクセスをしてきます。
そのためこれらだけに制限したい場合は下記のようにします。ここでは Cross-Origin も許容するため `@cross_origin()` も追加します。

```python:
@slack.route("/webhook", methods=['POST'])
@cross_origin()
@consumes('application/x-www-form-urlencoded')
def webhook():
    ~~~
```

## 2. Outgoing-Webhooks で利用する Endpoint のセキュリティをちょっと強化

Outgoing-Webhooks では Public にアクセス可能な Endpoint を用意しなくてはいけません。
Basic 認証は掛けられないため、そのあたりを意識しないで Endpoint を作成した場合、

1. Heroku のアプリケーション名が判明
2. 適当なキーワードを入力
3. キーワードが合っていた場合ドキュメントの内容が漏れてしまう！相手に重要な情報が！！！

ということになりかねません (´･ω･`)

しかし、Slack の Outgoing-Webhooks でリクエストにトークンを付与してくれています。
まずは Outgoing-Webhooks で付与してくれるトークンを確認しましょう。Slack の Integrations を見てみると下のほうにあります。

![01.png](https://qiita-image-store.s3.amazonaws.com/0/11364/89bb980c-18bb-7ce2-68f3-9d81959f8964.png)

このトークンを使えば完璧ですね。
そしてこのトークンをアプリケーション側にストアしておき、実際のリクエストに含まれるトークンとの比較を行います。もしアプリケーション側にトークンが設定されていない場合や、リクエスト中とのトークンと異なる場合は `abort(401)` でエラーを返しましょう。

```py
form = request.form
request_token = Utils().parse_dic(form, 'token', 400)
token = os.environ.get('SLACK_WEBHOOK_TOKEN')
if not token or token != request_token:
    abort(401)
```

## 3. サービスアカウントの秘密鍵を管理する方法

正直、これに一番悩みました :fearful:
p12 ファイルを直接リポジトリに入れてしまえば楽ではあるのですが、Public にしているのもあるため、即座に却下。
代わりに使った方法は「秘密鍵を `Config Variables`」に入れる方法です。

まずはp12ファイルから秘密鍵を取り出し

```bash
cd path/to/p12directory
openssl pkcs12 -passin pass:notasecret -in privatekey.p12 -nocerts -passout pass:notasecret -out key.pem
openssl pkcs8 -nocrypt -in key.pem -passin pass:notasecret -topk8 -out google-services-private-key.pem
rm key.pem
# google-services-private-key.pem が秘密鍵だよ！やったね！
```

これを `Config Variables` に設定します。

```bash
# heroku-toolbelt を使って... こうじゃ
heroku config:add GOOGLE_PRIVATE_KEY=`cat path/to/p12directory/google-services-private-key.pem`
```

Pythonのコード側からは `os.environ` でアクセスします。後は p12 ファイルから読み込むのと同じ方法でいけます。
もし秘密鍵が設定されていない場合は `abort()` をコールして、特定のステータスコードを返すようにするとわかりやすいですね。

```py
private_key = os.environ['GOOGLE_PRIVATE_KEY']
if not private_key:
    abort(401)
credentials = SignedJwtAssertionCredentials(os.environ['GOOGLE_CLIENT_EMAIL'],
                                            private_key,
                                            'https://www.googleapis.com/auth/drive',
                                            sub=os.environ['GOOGLE_OWNER_EMAIL'])
http = httplib2.Http()
credentials.authorize(http)
service = build('drive', 'v2', http=http)
```

## 4. GoogleDocs の Document ID から ドキュメントの内容を取得する

Botnyan のコアともいえるところですね。
(3) で作成した service インスタンスと Document ID を使ってファイルを取得します。

ただ、単純に取得した場合はオフィス形式のファイルが降ってくるので使いにくいです。
そこで `text/plain` なファイル形式で取得するようにしましょう。下記のようにすると `content` にドキュメントの内容が string で格納されます。

```py
f = service.files().get(fileId=doc_id).execute()
if 'exportLinks' in f and 'text/plain' in f['exportLinks']:
    download = f['exportLinks']['text/plain']
    resp, content = service._http.request(download)
else:
    content = '読み込みに失敗したにゃー'
```

## 5. 取得した内容を Bot に発言させる

これはとても簡単です。Outgoing-Webhooks のリクエストに対して下記のような JSON レスポンスを返すだけです。

```
{"text": "ドキュメントの内容だよー！∧＿∧"}
```

(4) で取得した内容を使って JSON を返すだけですね。レスポンスの Content-Type には `application/json` を指定しておきましょう。

```py
dic = {"text": content}
return Response(Utils().dump_json(dic), mimetype='application/json')
```

# 残る課題

とはいっても、これで全てが解決した訳ではありません。

- Outgoing-Webhook は現状で PrivateRoom の発言を拾えない
  - どうやら hubot の slack adaptor（RTM）使えば拾えるみたいですが、これを使うとドキュメントの改行が効かない...
- hubot のプラグインとして公開しないのー？
  - どうしても Python で書きたかっただけです！反省はしていない！（笑）

# ということで

私がどっち側のエージェントか・・・というのはココでは無しにして :ghost: 
コミュニケーションに関する問題は Ingress のチームだけでなく、お仕事などにもあると思いますので、お気に召しましたら是非使ってみてください。

お役に立てればとてもうれしいですにゃん！ということで！ :cat2: 

# 参考サイト
[^1]: https://www.ingress.com/
[^2]: http://chezou.hatenablog.com/entry/2015/01/16/011049