slackのモーダルからlambda関数を実行できるようにする

# やりたいこと

slackのモーダルからlambda関数を実行できるようにします
![out.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176335/65e45de2-8d0b-72e1-8ca4-09a5387bbc23.gif)

# やったこと

今回のコードでは下記アプローチでモーダルを起動できるようにします。

* グローバルショートカットで起動
* 全てAPIを投げることで実行
    * views.openとviews.update（とchat.postmessage）
* views.pushではなく、views.updateを使う
    * pushの場合、複数のダイアログを開いたときに、閉じる操作を明示的に行う必要があり手間
    * というかダイアログを閉じさせるやり方がよくわからなかった。。。

#　コード

```python
import base64
import json
import os
import urllib.parse
import boto3
import requests

headers = {
    "content-type": "application/json",
    "Authorization": f"Bearer {os.environ['token']}",
}


def post_text(text):

    r = requests.post(
        url="https://slack.com/api/chat.postMessage",
        headers=headers,
        json={"channel": "#general", "text": text},
    )
    return r


def open_view(trigger_id, view):

    r = requests.post(
        url="https://slack.com/api/views.open",
        headers=headers,
        json={"trigger_id": trigger_id, "view": view},
    )
    return r


def update_view(hash, view_id, view):

    r = requests.post(
        url="https://slack.com/api/views.update",
        headers=headers,
        json={"hash": hash, "view": view, "view_id": view_id},
    )
    return r


def parse_value(d):
    if "selected_option" in d:
        return d["selected_option"]["value"]
    elif "selected_date" in d:
        return d["selected_date"]


def handler(event, context):
    decoded = base64.b64decode(event["body"]).decode()
    param = urllib.parse.parse_qs(decoded)
    payload = json.loads(param["payload"][0])
    print(payload)

    if payload["type"] == "shortcut":
        # ショートカット起動時

        # userごとに実行可能なタスクを制御したい場合はuser_idが使えそう
        user_id = payload["user"]["id"]

        view = {
            "type": "modal",
            "callback_id": "modal-select-task",
            "title": {"type": "plain_text", "text": "実行するタスクを選択"},
            "blocks": [
                {
                    "type": "section",
                    "block_id": "ecs-task-block",
                    "text": {"type": "mrkdwn", "text": "select action"},
                    "accessory": {
                        "type": "static_select",
                        "placeholder": {"type": "plain_text", "text": "Select an item"},
                        "options": [
                            {
                                "text": {"type": "plain_text", "text": "taskA"},
                                "value": "taskA",
                            },
                            {
                                "text": {"type": "plain_text", "text": "taskB"},
                                "value": "taskB",
                            },
                            {
                                "text": {"type": "plain_text", "text": "taskC"},
                                "value": "taskC",
                            },
                        ],
                        "action_id": "select-task",
                    },
                }
            ],
        }
        trigger_id = payload["trigger_id"]
        r = open_view(trigger_id, view)
    elif (
        payload["type"] == "block_actions"
        and payload["actions"][0]["action_id"] == "select-task"
    ):
        # タスクを選んだ時
        select = payload["actions"][0]["selected_option"]["value"]
        view = {
            "type": "modal",
            "callback_id": "modal-parameter-task",
            "title": {"type": "plain_text", "text": "パラメータを指定してください"},
            "submit": {"type": "plain_text", "text": "選択"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "mrkdwn", "text": f"実行タスク: `{select}`"},
                },
                {
                    "type": "section",
                    "block_id": "date",
                    "text": {"type": "mrkdwn", "text": "date"},
                    "accessory": {"type": "datepicker", "action_id": "date"},
                },
                {
                    "type": "section",
                    "block_id": "parameter1",
                    "text": {
                        "type": "mrkdwn",
                        "text": "Pick an item from the dropdown list",
                    },
                    "accessory": {
                        "type": "static_select",
                        "placeholder": {"type": "plain_text", "text": "Select an item"},
                        "options": [
                            {
                                "text": {"type": "plain_text", "text": "value-0"},
                                "value": "value-0",
                            },
                            {
                                "text": {"type": "plain_text", "text": "value-1"},
                                "value": "value-1",
                            },
                            {
                                "text": {"type": "plain_text", "text": "value-2"},
                                "value": "value-2",
                            },
                        ],
                        "action_id": "parameter1",
                    },
                },
            ],
        }
        trigger_id = payload["trigger_id"]
        view_id = payload["container"]["view_id"]
        hash = payload["view"]["hash"]
        r = update_view(hash, view_id, view)
    elif payload["type"] == "view_submission":
        # 送信を押した時
        user_id = payload["user"]["id"]
        values = payload["view"]["state"]["values"]

        # block-idとaction-idが一致している前提のコード
        params = {k: parse_value(values[k][k]) for k in values.keys()}

        # この辺りに実行したい内容を記載 

        # 実行ユーザの情報を投稿
        text = f"""実行者: <@{user_id}>
{json.dumps(params, indent=2, ensure_ascii=False)}
"""
        r = post_text(text)
    else:
        # 他の通信は無視
        r = None

    if r:
        print(r.text)

    return {"statusCode": 200}
```

# 説明

## Lambda関連
上記のコードはAWS Lambda上で実行させることを前提にしています。
外部ライブラリ（requests）を使用しているため、zipでアップロードするかECR経由でコンテナとしてアップロードする必要があります。

筆者は後者のやり方を利用しました。
この辺りは解説記事等があるかと思いますので省略し、Dockerfileだけ記載しておきます。

```Dockerfile
FROM public.ecr.aws/lambda/python:3.8
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./
CMD ["app.handler"]
```

## slack関連

### 前提
まずは公式のドキュメントを読むことをお勧めします
https://api.slack.com/surfaces/modals/using#pushing_response


### 事前準備
![スクリーンショット 2021-09-11 15.29.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176335/8f04ff48-e82c-f7b2-96f2-4c55eaa3c936.png)

* Interactive Components
  * slackアプリの設定のInteractivity & Shortcutsを有効にして、RequestURLを取得。
ショートカット起動時とモーダル操作時に通信がそのURLに飛んでくるので、API Gateway -> Lambdaでその通信を受け取れるようにしておく
* Permissions
  * botユーザのトークンが必要なので、有効化しておく。モーダル表示は権限なしで実行可能なので、それ以外のタスクで必要なものだけ有効にしておく。
  * 例: 最後に何かしら投稿させたいなら chat:write とか。
* Bots
    * Permissionsを有効にした時点で有効になってるはず。。。

### コードについて

#### viewの作り方
公式のwebツールを見ながら生成すれば問題なく作れるはず
https://app.slack.com/block-kit-builder/

#### データの取得の仕方
payloadの中身を見れば下記がわかるので、条件分岐や値の保持に使う。
結構ネストが深いところにあるので頑張る

* どの操作が成された時の通信かを把握する
* stateを見てモーダルの選択状況を取得しているか把握する

#### 個人的なハマりポイント

* 全ての操作に対して通信が飛んでくるので、不要な通信を無視するコード書く必要がある
    * モーダル閉じるときは飛んでこない
* モーダルに備わっている送信ボタンに対して、views.updateを適用してはいけない
    * モーダルが閉じちゃうので、うんともすんとも言わなくなる
    * 送信ボタンは最後に押させるようなフローにするべき
    * なお、views.pushも使えない