日程調整？せっかく Slack 導入してるなら，Slackでパパッと済ませよう！

この記事は [**DSL Advent Calendar 2019** (Presented by 室蘭工業大学 データサイエンス研究室 - 岸上研)](https://qiita.com/advent-calendar/2019/muroran-it-dsl) **18日目** の記事です。

日程調整を Slack を使ってもっとスマートにするためのツールを作ったので，使った技術とサービスを紹介します。

[&toru (アンドとる) - 幹事も参加者も面倒な日程調整のお手伝いをします！](https://and-toru.herokuapp.com/) 

ちなみに日程調整の他，アンケートをとることもできます。

|日程調整|アンケート|
|:-:|:-:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/8821c685-da1b-0aa5-e39b-63665f8cca3f.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/98b198ad-906d-0448-6c2c-f168a0e8def5.png)|


# 使い方
## ログイン
1. [&toru (アンドとる) - 幹事も参加者も面倒な日程調整のお手伝いをします！](https://and-toru.herokuapp.com/) にアクセス
2. 右上の「Login」ボタンをクリック
3. 連携したい Slack アカウント（投稿したいワークスペースのアカウント）で Slack 上でログインして「Authorize」/「Allow」をクリック  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/4f736262-c85b-b376-bb6f-948891081f60.png)
4. こんなページに飛びます  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/7e0afe84-55ba-bb39-b030-a7cbcd13f59f.png)

## イベントの作成
1. [ログイン後のページ](https://and-toru.herokuapp.com/home) の上部にある「新規イベント作成」をクリック
2. イベント作成のページが表示されるので，「イベント名」と「メッセージ」を入力してください。
メッセージにはイベントの概要や〆切などを記載すると良いと思います。
「候補」には実際にアンケートを取りたいことを入力します。
ドラッグしたり，横の矢印で順番が変えられます。
最後にアンケートを送信するチャンネルを選択したら，イベント作成ボタンをクリックしてください。
チャンネルは複数選択可能です。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/92227c25-aa06-8902-eb90-3f7c48a6fd5a.png)
3. 実際に送信されるメッセージはこんな感じです。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/614d082b-21b5-8d91-62ce-b85e540d6baf.png)

## アンケートに回答する
1. 受け取ったメッセージの「回答する」ボタンをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/614d082b-21b5-8d91-62ce-b85e540d6baf.png)
2. 下のようなダイアログが表示されるので，「○」「×」「△」でお気持ちを示しましょう。
コメントも残せます。　  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/8e34562f-07b5-4cba-181a-2f1985f937f8.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/bb9360a3-b83d-df76-6e1e-c871fd5e83d1.png)
3. 最後に送信をクリックします

## 現在の回答状況を確認する
1. 受け取ったメッセージの「結果を見る」ボタンをクリックします。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/614d082b-21b5-8d91-62ce-b85e540d6baf.png)
2. 下のようなページが表示され，全員の回答結果が確認できます  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/98b198ad-906d-0448-6c2c-f168a0e8def5.png)


# モチベーション
今まで，研究室内では [調整さん](https://chouseisan.com/) や Slack のスタンプで日時を決めたり，参加人数を把握していました。
しかし，調整さんでは他の人の名前が変えられたり，みんな自由に名前を付けれる都合上，誰なのか一瞬わからないことがありました。
あと，回答が少しめんどくさい。
また，Slack のスタンプでは回答は手軽ですが，参加なのか不参加なのかよくわからないスタンプだったり，1人で複数スタンプが押せるので純粋に足すだけでは参加人数を把握できませんでした（Python で API 叩けば，把握できます）。

<details><summary>実際に使っていた Python スクリプト</summary><div>

```python
import requests
import json
import itertools
from pprint import pprint

token = "API Token"
channel = "Channel ID"
timestamp = 0.0

r = requests.get(
    f'https://slack.com/api/users.list?token={token}&pretty=1'
)
users = {i['id']: f"{i['name']} ({i['profile']['real_name']})" for i in json.loads(r.text)['members']}

r = requests.get(
    f'https://slack.com/api/reactions.get?token={token}&channel={channel}&timestamp={timestamp}&pretty=1'
)

obj = json.loads(r.text)

reaction_users = [users.get(i) for i in set(itertools.chain.from_iterable([i['users'] for i in obj['message']['reactions']]))]
print(len(reaction_users))
pprint(reaction_users)

for i in obj['message']['reactions']:
    print(f'{i["name"]}: {[users.get(j) for j in i["users"]]}')
```
</div>
</details>

適当にスタンプ押したり，複数押すのは楽しいですし，そういうのが Slack の random の文化だと思っているので，「\:+1: つけてください」とか「手あげてください」とか書いてない限りはそれで良いと思っています。

「悪いのはツール側なのだ！」と言うことで作り始めました。


# 要素技術など
このままだとただの宣伝記事になってしまうので，どのように実現したかなどを紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/f84e2b68-af3a-cff8-f790-6bea623730ca.png)

## Heroku
Web サーバ (兼 API サーバ) は [Heroku](https://heroku.com/) のアプリとして構築されています。
言語やフレームワークは以下の通り:

* 言語: Python3
* フレームワーク: responder
* ORM: peewee
* ライブラリ管理: pipenv

Heroku 向けのファイルは以下の2つです:

* `runtime.txt`
* `Procfile`

それぞれ以下の内容で書き込みます：

```plaintext:runtime.txt
python-3.7.2

```

```plaintext:Procfile
web: python app.py

```

あとは，`app.py` 上を中心に普段通りアプリケーションを開発します。
なお，後ほど Heroku のアプリ上にデータベースサーバを構築しますが，データベースへのアクセス情報は環境変数の `DATABASE_URL` で取得できます。

次に Heroku のダッシュボードに移動して作業します。
まずは Heroku 上でデバッグ用と本番用にそれぞれ一つずつアプリを作成しました。
どちらのアプリにも Add-ons の「Heroku Postgres」の Free のものを追加しました。
また，Deploy 設定から，GitHub リポジトリと紐付けを行いました。
develop ブランチに関しては push されるとデベロップ環境にデプロイされ，master ブランチは Heroku の設定画面から「Deploy Branch」ボタンをクリックすることで本番環境にデプロイされる仕様にしました。

その後，Pipeline を作成してデバッグ用を Development に，本番用を Production に追加しました。

なお，Heroku の Dyno と呼ばれるサーバは 30 分間アクセスがないと，スリープする仕様になっています。
しかし，インターネット上にはスリープしないようにして運用している方々がいるので，私も今回は知恵を絞ってスリープしないようにしました。
正直，もう利用規約などは覚えておらず，規約違反かもしれませんが自己責任でお願いします。

### スリープ回避
[Google Apps Script (GAS)](https://script.google.com/home) を用います。

まずは，自分のアプリにアクセスするスクリプトを作成します。
「新しいプロジェクト」をクリックして，以下を貼り付けてください。

```javascript
function myFunction() {
  var url = "http://and-toru.herokuapp.com/";
  var response = UrlFetchApp.fetch(url);
  console.log(response);
}
```

スクリプトを作成したら，その画面の「編集」に「現在のプロジェクトのトリガー」と言う項目があるので選択します。
すると，トリガーが設定できます。
このトリガーというのは，どのような条件の時にスクリプトのどの関数を実行するかを指定できます。
トリガーの設定の中で「時間主導型」と言う経過時間ごとに実行できるトリガーがあります。
このトリガーを 30分未満にし，`myFunction` を実行するように設定することでスリープ回避を実現しました。

その他のスリープ回避方法は他のサイトを参照してください: [Casual Developers Note: Herokuの無料dynoをスリープさせないで24時間稼働させる4つの方法](https://casualdevelopers.com/tech-tips/how-to-prevent-idling-of-free-dyno-on-heroku/)


## Slack Apps
次は Slack 側で Slack App として開発を進めます。
まずは [slack api: Your Apps](https://api.slack.com/apps) にアクセスして，「Create New App」をクリックします。

アプリ名と開発ワークスペースを選んだら作成します。
App の設定ページに移動したら，必要な機能をオンにします。
ボタン付きのリッチなメッセージやダイアログを使うため，「Interactive Components」をオンにしましょう。
「Request URL」はボタンなどが押された際のリクエストの送信先を選択します。

また，Slack アカウントを用いたログインとメッセージ投稿先一覧の表示のため，「OAuth & Permissions」の「Scope」では以下のように設定しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/5e8a18f6-78c6-5a34-9734-5d07130f0986.png)

最後に，「User ID Translation」の「Translate Global IDs」はオンに設定しています。

### Slack のメッセージのボタンはどうやって実現してるの？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59163/614d082b-21b5-8d91-62ce-b85e540d6baf.png)

この “回答する”，“結果を見る” は [Interactive components](https://api.slack.com/reference/block-kit/interactive-components) の [Button element](https://api.slack.com/reference/block-kit/block-elements#button) を用いています。

その部分をソースコードから抜粋するとこんな感じです:

```python
{
    "type": "actions",
    "elements": [
        {
            "type": "button",
            "text": {
                "type": "plain_text",
                "text": "回答する",
                "emoji": True
            },
            "action_id": f"request_answer_dialog/{str(event.id)}",
            "value": "request_answer_dialog",
            "style": "primary"
        },
        {
            "type": "button",
            "text": {
                "type": "plain_text",
                "text": "結果を見る",
                "emoji": True
            },
            "url": f'{os.getenv("URL_SCHEME")}://{os.getenv("URL_HOST")}/events/{str(event.id)}'
        }
    ]
}
```

button が押されると Interactive Components の Request URL にリクエストが送信されます。
その中に，`action_id` と `value` が含まれています。

今回は `value` が要求されている内容で，`action_id` が要求されている内容とイベント ID が渡されるようにしました。
その `value` を元に分岐する部分のソースコードを抜粋します:

```python
@api.route("~~")
async def slack_interactive(req, resp):
    if req.method == "post":
        media = await req.media()
        payload = json.loads(media['payload'])
        if payload['type'] == 'block_actions':
            if payload['actions'][0]['value'] == 'request_answer_dialog':
                pass  # ここにダイアログを送る処理を書く
```

なお，`url` が含まれている button はその URL をユーザのブラウザがロードしますが，同時に Interactive Components の Request URL にもリクエストが送信されるので注意が必要です。

## どうやって Slack 内に Dialog を表示するの？
[Interacting with users through dialogs](https://api.slack.com/dialogs) に方法が書いてあります。
なお，Slack としては今後は dialog から modal へ移行を進めたいようなので ([Slack: Upgrading outmoded dialogs to modal](https://api.slack.com/block-kit/dialogs-to-modals))，新しく実装する場合は modal を用いるほうが良さそうです。
[Slack: Using modals in Slack apps](https://api.slack.com/surfaces/modals/using)

Dialog のリクエストを抜粋して載せます。
ソースコード中の `payload` は一つ前のソースコードの `payload` です。

```python
r = requests.post("https://slack.com/api/dialog.open", {
    "token": token,
    "trigger_id": payload['trigger_id'],
    "dialog": json.dumps({
        "callback_id": "submit_answer_dialog",
        "title": event.name,
        "submit_label": "送信",
        "notify_on_cancel": False,
        "state": payload['actions'][0]['action_id'].split('/')[1],
        "elements": [
            {
                "type": "select",
                "label": f"{q.label}",
                "name": q.key,
                "options": list(map(
                    lambda x: {"label": x[0],"value": x[1]},
                    [["○", "o"],["×", "x"],["△", "other"]]
                ))
            }
            for q in event.questions.order_by(Question.order)
        ] + [
            {
                "type": "text",
                "label": "コメント",
                "optional": True,
                "name": "comment",
                "placeholder": "連絡しておきたいことなど"
            }
        ]
    })
})
```

## Dialog から送信されたデータはどうやって取り出すの？
ユーザが dialog の「送信」ボタンが押されるとまた Interactive Components の Request URL にリクエストが送られます。
そのリクエストが正規なものかどうかは署名をチェックすることで判断できます。
リクエストの受け取りから署名チェックの部分を抜粋して掲載します:

```python
import hmac
import hashlib

@api.route("~~")
async def slack_interactive_callback(req, resp):
    if req.method == "post":
        media = await req.media()
        payload = json.loads(media['payload'])
        logger.debug(payload)
        if payload['type'] == 'block_actions':
            pass  # ここにダイアログを送る処理を書く
        elif payload['type'] == 'dialog_submission':
            if payload['callback_id'] == 'submit_answer_dialog':
                content = await req.content
                sig_basestring = f'v0:{req.headers["X-Slack-Request-Timestamp"]}:{content.decode()}'
                if hmac.compare_digest('v0=' + hmac.new(
                    os.getenv('SLACK_SIGNING_SECRET').encode('utf-8'),
                    sig_basestring.encode('utf-8'),
                    hashlib.sha256
                ).hexdigest(), req.headers['X-Slack-Signature']):
                    # 署名チェッククリア
                    pass
```

あとは，ユーザの投稿の中身は `payload['submission']` に入っているので署名チェック後にいろいろしましょう。


# 参考
- [Python responder 入門のために… 下調べ - Qiita](https://qiita.com/y_k/items/6e4da3bef5c8d8bd2730)
- [responder: Deploying Responder](https://github.com/taoufik07/responder/blob/master/docs/source/deployment.rst)
- [Slack: Reference: Interactive components](https://api.slack.com/reference/block-kit/interactive-components)
- [Slack: Interacting with users through dialogs](https://api.slack.com/dialogs)
- [Python 3.7.6rc1 ドキュメント: hmac - メッセージ認証のための鍵付きハッシュ化](https://docs.python.org/ja/3.7/library/hmac.html)

# あとがき
Slack の Dialog ですが，最近 Modal というものが登場して，移行が推奨されているようです。
上でも書きましたが，新規にやる場合は Modal のほうが良さそうです。

あと，[&toru (アンドとる) - 幹事も参加者も面倒な日程調整のお手伝いをします！](https://and-toru.herokuapp.com/) への要望などは [Twitter: @YetAnother_yk](https://twitter.com/YetAnother_yk) までお願いします \:pray: