【SlackBot】冬の登校時間を管理するBotを作った🐱

MYJLab Advent Calendar 2022、15日目の記事です!
前回は@takumiakasaka1231の[Prisma+fakerでサクッとデモデータを作る](https://qiita.com/takumiakasaka1231/items/e9a4a0854445f147dbfb)
でした!
PrismaはGraphQLと相性が良くて個人的に推せるORMなので, 勝手にテンションが上がりました🌟

## これは
私はよく大学に遅刻してしまうのですが, 冬は自分の意志の弱さにお外の寒さが加わり, 余計に家を出るのが遅くなってしまいます.
今回はこの課題を解決するために, 登校時間を管理してくれるマネージャーをslack botで作りました. 名前はMANEKOです🐈(私は犬派です)
MANEKOに期待することは以下の3つです.

- 登校予定時間を覚えていてくれる
- 登校予定時間に間に合ったら褒めてくれる
- 登校予定時間に間に合わなかった場合, みんなの前で叱ってくれる

弊ゼミでは, timesチャンネルを作っている人が何人かいて, 私もtimesチャンネルがあります. 
それを利用してチャンネル1つにつき, 登校予定を1つセットできるようにし, 間に合わなかったらチャンネル内で晒し者になるようにしてみました.

## 実装方針
MANEKOに期待する3つを満たすために, それぞれの機能を次のように実装します

|  機能  |  実装方針  |
| ---- | ---- |
|  登校予定時間を覚えていてくれる  | <ul><li>「時間」というキーワードを含むメンションが飛ばされたら, 時間を入力するフォームを送信する</li><li>フォームから時間を送信されたら, リマインダーをスケジュールする</li></ul>|
|  登校予定時間に間に合ったら褒めてくれる  |  <ul><li>「到着」というキーワードを含むメンションが登校予定時刻までに飛ばされたら, リマインダーを削除し, 褒めメッセージを送信する</li></ul>  |
|  登校予定時間に間に合わなかった場合, みんなの前で叱ってくれる  |  <ul><li>リマインダーで怒りのメッセージを送信する</li><ul>  |

## 使用技術
- [slack api](https://api.slack.com/)
  - Slackにメッセージを送ったり, Slackのメッセージを利用してリクエストを送ったりするなどの操作をおこなうために[slack api](https://api.slack.com/)を使用します.
  - 今回はAPIをそのまま使うのではなく, 後述するslack apiを使ったBotを作るためのライブラリである[Bolt](https://api.slack.com/tools/bolt)を使用します
- [Bolt](https://api.slack.com/tools/bolt)
  - slack botを作るためのライブラリはいくつかありますが, 今回はSlack公式のBoltというライブラリを使いました. 
  - Slackの公式のものである点と, 他のライブラリはあまりメンテナンスされてなさそうだったのでBoltを選びました.
- Python
  - BoltはJavaScript, Python, Javaを用いた開発に対応しています. 今回はいちばん書き慣れている気がするPythonを選びました.
- Docker
  - 基本的にデプロイや共有のしやすさからアプリはDockerでパッケージ化するようにしています

## 今回使用するslack apiのMethod
slack apiは非常に多くのメソッドを用意しており, ドキュメントも充実しています.
MANEKOを作成するにあたっては, 以下のAPI Methodを使用しています.

- [chat.postMessage](https://api.slack.com/methods/chat.postMessage)
  - BotからSlackにメッセージを送信する際に使用します
  - 厳密には, Boltが提供している`say`という関数の内部でchat.postMessageが呼ばれます
  - `say`でできることや, `say`を使うためにどんなScope(slack botに付与する権限のようなもの)が必要かを知るために, chat.postMessageのドキュメントを参照するといい気がします
- [chat.scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage)
  - 指定した時間にslackにメッセージを送信する際に使用します
- [chat.scheduledMessages.list](https://api.slack.com/methods/chat.scheduledMessages.list)
  - すでにスケジュールされているメッセージを一覧で取得します
  - 今回の用件では, 1つのチャンネルにつき1つの登校予定をセットできるようにしたいので, すでにスケジュールされた登校予定がないかを確認するために使用します
- [chat.deleteScheduledMessage](https://api.slack.com/methods?filter=chat#:~:text=chat.deleteScheduledMessage)
  - 指定したスケジュールを削除します
  - 登校予定時刻に間に合った場合に, スケジュールを削除し, MANEKOが怒りのメッセージを送らないようにするために使用します

## 作る
基本的な流れは[Boltの公式チュートリアル](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started)通りになります.
今回作るBot特有の機能について, チュートリアルの内容に追加しながら進めていきます.

### slack app作成
slack app作成の部分は公式チュートリアルの「[トークンとアプリのインストール](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started#tokens-and-installing-apps)」通りに進めます.

### プロジェクトの雛形を作成
ここは公式チュートリアルの[プロジェクトをセットアップする](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started#setting-up-your-project)の部分にあたります.
今回はDockerを使って環境構築したいのと, 実装する機能も公式チュートリアルよりも少し多いので色々と異なってきます.

ディレクトリ構成は以下のようなものを目指します.

```
MANEKO
├── .env
├── Dockerfile
├── bot
│   ├── app.py
│   └── util
│       ├── __init__.py
│       └── utils.py
└── docker-compose.yaml

```

- `bot`内でbot自体の実装をします
- `.env`でslack appの作成の際に生成したトークンを環境変数に設定します
- `Dockerfile`と`docker-compose.yaml`に環境構築の手順をコード化して, `docker compose up`でコンテナ内でアプリが起動するようにします

雛形を作るにあたり, まず`.env`に環境変数を定義します.
チュートリアルにあるように, このbotでも`ボットトークン`と`アプリレベルトークン`を使用します.

```
SLACK_APP_TOKEN="xapp-..."
SLACK_BOT_TOKEN="xoxb-..."
```

次に, `bot/app.py`にアプリを起動するための最小のコードを書いていきます. これは公式チュートリアルの通りです!

```python
import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

# ボットトークンとソケットモードハンドラーを使ってアプリを初期化
app = App(token=os.environ.get("SLACK_BOT_TOKEN"))

# アプリを起動
if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
```

次に`Dockerfile`を書いていきます. 今回は[Python3](https://hub.docker.com/_/python)のイメージを使用します.

```dockerfile
FROM python:3
USER root

RUN apt-get update
RUN apt-get -y install locales && \
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN pip install --upgrade pip
RUN pip install --upgrade setuptools

# slack_boltをインストール
RUN python -m pip install slack_bolt

CMD ["python", "app.py"]
```

次に`docker-compose.yaml`を用意します.
```yaml
version: '3'
services:
  maneko:
    restart: always
    build: .
    working_dir: '/root/bot'
    tty: true
    volumes:
      - ./bot:/root/bot
    env_file:
      - .env
```

ここまでできたら`docker-compose.yaml`がある階層で`docker compose up`を実行します.
以下のように出力されていれば雛形は無事できています!
```
maneko-maneko-1  | ⚡️ Bolt app is running!
```

### メンションに反応できるようにする
`@MANEKO`を含むメッセージに反応できるようにしていきます.
メンションを検知するためには`app_mention`というイベントを有効にする必要があります.
slack apiのアプリのページの右側メニューから, `Event Subscriptions`を選択します(①).
もし`Enable Events`がOffになっていたら, Onにします(②).

次に, `Subscribe to bot events`に`app_mention`というeventを追加します(③).

![スクリーンショット 2022-12-13 17.55.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/05abe2bb-231a-7809-03a8-bb83a371a807.png)


これでイベントの有効化は完了です! メンションを検知できるようになったはずなので, `bolt/app.py`に以下のリスナー関数を追加します.

```python
# メンションが付けられたときのハンドラ
@app.event("app_mention")
def respond_to_mention(client, body, context, logger, say):
    say(f"呼んだ？ <@{context['user_id']}>:cat:")
```
実際にslackからメンションをしてみると, 以下のように反応してくれました🎉
![mention.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/49fa1791-0f8d-6301-7c75-e3233c0e5a83.png)




### 時間の登録フォームの送信
「時間」というキーワードを含むメンションが飛ばされたら, 時間を入力するフォームを送信するようにします. 
slack apiではblockというパーツを組み合わせることで, 複雑なUIを持ったメッセージを送信できます. 
フォーム等の複雑なUIをメッセージで実現したい場合, [Block Kit Builder](https://app.slack.com/block-kit-builder)がとても便利です. 今回はBlock Kit Builderを使って[このようなフォーム](https://app.slack.com/block-kit-builder/T03HK9GA1#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22%E4%BD%95%E6%99%82%E3%81%AB%E7%99%BB%E6%A0%A1%E4%BA%88%E5%AE%9A%E3%81%8B%E6%95%99%E3%81%88%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%F0%9F%90%88%22%7D%7D,%7B%22type%22:%22input%22,%22element%22:%7B%22type%22:%22datepicker%22,%22initial_date%22:%221990-04-28%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Select%20a%20date%22,%22emoji%22:true%7D,%22action_id%22:%22datepicker-action%22%7D,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%E6%97%A5%E4%BB%98%22,%22emoji%22:true%7D%7D,%7B%22type%22:%22input%22,%22element%22:%7B%22type%22:%22timepicker%22,%22initial_time%22:%2213:37%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Select%20time%22,%22emoji%22:true%7D,%22action_id%22:%22timepicker-action%22%7D,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%E6%99%82%E9%96%93%22,%22emoji%22:true%7D%7D,%7B%22type%22:%22actions%22,%22elements%22:%5B%7B%22type%22:%22button%22,%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%E3%82%BB%E3%83%83%E3%83%88%F0%9F%90%B1%22,%22emoji%22:true%7D,%22value%22:%22click_me_123%22,%22action_id%22:%22actionId-1%22%7D%5D%7D%5D%7D)を作りました.
あとはBlock kit builderを使って生成したPayloadを`say`に渡せばいいのですが, Payloadは結構長いので, `util/utils.py`に切り出しました.

```python
def build_form_blocks():
    return [
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "次回の登校予定時刻を教えてください:cat2:"
            }
        },
        {
            "type": "input",
            "element": {
                "type": "datepicker",
                "initial_date": str(datetime.date.today() + datetime.timedelta(days=1)),
                "placeholder": {
                    "type": "plain_text",
                    "text": "Select a date",
                    "emoji": True
                },
                "action_id": "set-datepicker"
            },
            "label": {
                "type": "plain_text",
                "text": "日付",
                "emoji": True
            }
        },
        {
            "type": "input",
            "element": {
                "type": "timepicker",
                "initial_time": "08:00",
                "placeholder": {
                    "type": "plain_text",
                    "text": "Select time",
                    "emoji": True
                },
                "action_id": "set-timepicker"
            },
            "label": {
                "type": "plain_text",
                "text": "時間",
                "emoji": True
            }
        },
        {
            "type": "actions",
            "elements": [
                {
                    "type": "button",
                    "text": {
                        "type": "plain_text",
                        "text": "セット:cat:",
                        "emoji": True
                    },
                    "action_id": "set-school-time"
                }
            ]
        }
    ]
```

```python
@app.event("app_mention")
def respond_to_mention(client, body, context, logger, say):
    try:
        message = body["event"]["text"]
        # メッセージに「時間」が含まれていたら登校予定時刻を入力するフォームをSlackに送信する
        if "時間" in message:
            say(
                blocks=build_form_blocks()
            )
        else:
            say(f"呼んだ？ <@{context['user_id']}>:cat:")
    except Exception as e:
        say("処理に失敗しました:crying_cat_face:")
        logger.error(e)
```
上記を追加して, 「時間」という言葉を含めてメンションすると, 無事フォームが送られてきました🎉
![time_mention.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/a55d4395-fd13-e273-98c4-001e14f4a08b.png)




### 時間の登録
セットボタンが押されたら, その時間にメッセージをスケジュールします.

まずセットボタンをリッスンする関数を用意します.
Payloadの中で, セットボタンには`set-school-time`, 日付のピッカーには`set-datepicker`, 時刻のピッカーには`set-timepicker`というidを付与しています. このidを使うことで, フォームが操作されたことを検知できます. `set-datepicker`と`set-timepicker`について, 操作されても特に処理は必要ありませんが, 関数を用意しないと警告が出るので, ほぼ空のリスナー関数を定義しておきます.
アクションを検知する関数では`ack`関数を呼び出すことで, リクエストを受信したことを伝えます.

```python
# セットボタンが押されたときのハンドラ
@app.action("set-school-time")
def handle_set_school_time(ack):
    ack()

@app.action("set-datepicker")
def handle_set_datepicker(ack):
    ack()


@app.action("set-timepicker")
def handle_set_timepicker(ack):
    ack()
```
次にリクエストから, 選択された日付を取得します.
リスナー関数の引数のひとつである`body`が`state.values`というフィールドでフォームの値を持っているようなので, `state.values`をパースします. Bodyが持つフィールドは[こちら](https://api.slack.com/reference/interaction-payloads/block-actions#fields)

ちなみにリスナー関数についてのドキュメントは[これ](https://slack.dev/bolt-js/ja-jp/reference)です.

`state.values`の中身は以下のようになっていました.
```
 {'Sqy': {'set-datepicker': {'type': 'datepicker', 'selected_date': '2022-12-14'}}, 'oRHX': {'set-timepicker': {'type': 'timepicker', 'selected_time': '08:00'}}}
```
これをやや強引にパースします.
```python
selected_date: str
selected_time: str
for form_state in body['state']['values'].values():
    if (
        'set-datepicker' in form_state
        and 'selected_date' in form_state['set-datepicker']
    ):
        selected_date = form_state['set-datepicker']['selected_date']
    elif (
        'set-timepicker' in form_state
        and 'selected_time' in form_state['set-timepicker']
    ):
        selected_time = form_state['set-timepicker']['selected_time']
    else:
        raise Exception("Unexpected action_id.")
```

次に, すでにスケジュールが設定されているかを[chat.scheduledMessages.list](https://api.slack.com/methods/chat.scheduledMessages.list)使って確認します.
リスナー関数の引数のひとつである`client`を使うことで, slack apiを使用できます.

```python
result_scheduled_messages = client.chat_scheduledMessages_list(
    channel=context["channel_id"]
)

if len(result_scheduled_messages["scheduled_messages"]) > 0:
    say("既に登校予定が登録されています:cat:")
    return
```

次に, [chat.scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage)を使ってメッセージをスケジュールします.
`chat.scheduleMessage`に時刻を渡す際に日付+時刻をタイムスタンプに変換する必要があるので, 以下の関数を`util/utils.py`に切り出して使うようにしました.

```python
def convert_datetime_str_to_timestamp(date_str, time_str):
    tdatetime = datetime.datetime.strptime(
        f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
    return int(tdatetime.timestamp())
```
`util/utils.py`に切り出した関数を以下のように使います.
```python
client.chat_scheduleMessage(
    channel=context["channel_id"],
    text="登校予定時刻ですよ〜〜〜:anger::pouting_cat:",
    post_at=convert_datetime_str_to_timestamp(
        selected_date, selected_time)
)
```

上記を全て組み合わせると以下のようになります.
```python

# セットボタンが押されたときのハンドラ
@app.action("set-school-time")
def handle_set_school_time(client, ack, say, body, context, logger):
    ack()
    try:
        selected_date: str
        selected_time: str
        for form_state in body['state']['values'].values():
            if (
                'set-datepicker' in form_state
                and 'selected_date' in form_state['set-datepicker']
            ):
                selected_date = form_state['set-datepicker']['selected_date']
            elif (
                'set-timepicker' in form_state
                and 'selected_time' in form_state['set-timepicker']
            ):
                selected_time = form_state['set-timepicker']['selected_time']
            else:
                raise Exception("Unexpected action_id.")

        if selected_date == "":
            raise Exception("Failed to get selected_date.")

        if selected_time == "":
            raise Exception("Failed to get selected_time.")

        result_scheduled_messages = client.chat_scheduledMessages_list(
            channel=context["channel_id"]
        )

        if len(result_scheduled_messages["scheduled_messages"]) > 0:
            say("既に登校予定が登録されています:cat:")
            return

        client.chat_scheduleMessage(
            channel=context["channel_id"],
            text="登校予定時刻ですよ〜〜〜:anger::pouting_cat:",
            post_at=convert_datetime_str_to_timestamp(
                selected_date, selected_time)
        )

        say(f"{selected_date} {selected_time}にMANEKOをセットしました :cat:")
    except Exception as e:
        say("登校予定の登録に失敗しました:crying_cat_face:")
        logger.error(e)

```
上記を追加し, 時間をセットすると, 無事できてそうなメッセージが返ってきました🎉
![set_response.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/bd1e2889-3140-373a-257d-eec0d8ee1255.png)


### 到着
最後に「到着」言葉を含むメンションを受け取ったら, スケジュールを解除し, お褒めの言葉を送信するようにします.
先ほどと同様に, スケジュールが存在するかを[chat.scheduledMessages.list](https://api.slack.com/methods/chat.scheduledMessages.list)使って確認します.

```python
result = client.chat_scheduledMessages_list(
    channel=context["channel_id"],
)
scheduled_messages = result["scheduled_messages"]
if len(scheduled_messages) == 0:
    say("登校予定が登録されていません:cat:")
    return
```

次に, [chat.deleteScheduledMessage](https://api.slack.com/methods?filter=chat#:~:text=chat.deleteScheduledMessage)を使ってスケジュールを削除し, 褒めメッセージを送信します.

```python
# スケジュールされたメッセージを削除する
for message in scheduled_messages:
    client.chat_deleteScheduledMessage(
        channel=context["channel_id"],
        scheduled_message_id=message['id']
    )
say("おめでとうございます! 目標時刻に間に合いました!:cat:")
```

上記を`app_mention`イベントのリスナー関数内に追加します.

```python
# メンションが付けられたときのハンドラ
@app.event("app_mention")
def respond_to_mention(client, body, context, logger, say):
    try:
        message = body["event"]["text"]
        # メッセージに「時間」が含まれていたら登校予定時刻を入力するフォームをSlackに送信する
        if "時間" in message:
            say(
                blocks=build_form_blocks()
            )
        # メッセージに「到着」が含まれていた場合の処理
        elif "到着" in message:
            # スケジュールされたメッセージを取得
            result = client.chat_scheduledMessages_list(
                channel=context["channel_id"],
            )
            scheduled_messages = result["scheduled_messages"]
            if len(scheduled_messages) == 0:
                say("登校予定が登録されていません:cat:")
                return

            # スケジュールされたメッセージを削除する
            for message in scheduled_messages:
                client.chat_deleteScheduledMessage(
                    channel=context["channel_id"],
                    scheduled_message_id=message['id']
                )
            say("おめでとうございます! 目標時刻に間に合いました!:cat:")
        else:
            say(f"呼んだ？ <@{context['user_id']}>:cat:")
    except Exception as e:
        say("処理に失敗しました:crying_cat_face:")
        logger.error(e)
```

これで完成です🎉

## 成果物
登校予定時刻に間に合わなかった場合
![late.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/26ab4dc6-0082-07ef-ec23-6c06365020b0.png)


間に合った場合
![intime.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533290/e439d9e9-116a-f330-d61a-cef050ca4e17.png)



## まとめ
cloud schedulerやcloud watch等のツールを使うことなく, slack apiだけで目的が達成できて感動しました.
MANEKOといっしょに寒い冬の朝を乗り切ろうと思います.
最後まで読んでいただきありがとうございました🌟

## 参考
- https://slack.dev/bolt-python/ja-jp/tutorial/getting-started
- https://app.slack.com/block-kit-builder
- https://api.slack.com/methods
- https://slack.dev/bolt-js/ja-jp/reference