LoginSignup
14
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-14

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

これは

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

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

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

実装方針

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

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

    使用技術

    • slack api
      • Slackにメッセージを送ったり, Slackのメッセージを利用してリクエストを送ったりするなどの操作をおこなうためにslack apiを使用します.
      • 今回はAPIをそのまま使うのではなく, 後述するslack apiを使ったBotを作るためのライブラリであるBoltを使用します
    • 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
      • BotからSlackにメッセージを送信する際に使用します
      • 厳密には, Boltが提供しているsayという関数の内部でchat.postMessageが呼ばれます
      • sayでできることや, sayを使うためにどんなScope(slack botに付与する権限のようなもの)が必要かを知るために, chat.postMessageのドキュメントを参照するといい気がします
    • chat.scheduleMessage
      • 指定した時間にslackにメッセージを送信する際に使用します
    • chat.scheduledMessages.list
      • すでにスケジュールされているメッセージを一覧で取得します
      • 今回の用件では, 1つのチャンネルにつき1つの登校予定をセットできるようにしたいので, すでにスケジュールされた登校予定がないかを確認するために使用します
    • chat.deleteScheduledMessage
      • 指定したスケジュールを削除します
      • 登校予定時刻に間に合った場合に, スケジュールを削除し, MANEKOが怒りのメッセージを送らないようにするために使用します

    作る

    基本的な流れはBoltの公式チュートリアル通りになります.
    今回作るBot特有の機能について, チュートリアルの内容に追加しながら進めていきます.

    slack app作成

    slack app作成の部分は公式チュートリアルの「トークンとアプリのインストール」通りに進めます.

    プロジェクトの雛形を作成

    ここは公式チュートリアルのプロジェクトをセットアップするの部分にあたります.
    今回はDockerを使って環境構築したいのと, 実装する機能も公式チュートリアルよりも少し多いので色々と異なってきます.

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

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

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

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

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

    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のイメージを使用します.

    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を用意します.

    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 eventsapp_mentionというeventを追加します(③).

    スクリーンショット 2022-12-13 17.55.40.png

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

    # メンションが付けられたときのハンドラ
    @app.event("app_mention")
    def respond_to_mention(client, body, context, logger, say):
        say(f"呼んだ? <@{context['user_id']}>:cat:")
    

    実際にslackからメンションをしてみると, 以下のように反応してくれました🎉
    mention.png

    時間の登録フォームの送信

    「時間」というキーワードを含むメンションが飛ばされたら, 時間を入力するフォームを送信するようにします.
    slack apiではblockというパーツを組み合わせることで, 複雑なUIを持ったメッセージを送信できます.
    フォーム等の複雑なUIをメッセージで実現したい場合, Block Kit Builderがとても便利です. 今回はBlock Kit Builderを使ってこのようなフォームを作りました.
    あとはBlock kit builderを使って生成したPayloadをsayに渡せばいいのですが, Payloadは結構長いので, util/utils.pyに切り出しました.

    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"
                    }
                ]
            }
        ]
    
    @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

    時間の登録

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

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

    # セットボタンが押されたときのハンドラ
    @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()
    

    次にリクエストから, 選択された日付を取得します.
    リスナー関数の引数のひとつであるbodystate.valuesというフィールドでフォームの値を持っているようなので, state.valuesをパースします. Bodyが持つフィールドはこちら

    ちなみにリスナー関数についてのドキュメントはこれです.

    state.valuesの中身は以下のようになっていました.

     {'Sqy': {'set-datepicker': {'type': 'datepicker', 'selected_date': '2022-12-14'}}, 'oRHX': {'set-timepicker': {'type': 'timepicker', 'selected_time': '08:00'}}}
    

    これをやや強引にパースします.

    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使って確認します.
    リスナー関数の引数のひとつであるclientを使うことで, slack apiを使用できます.

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

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

    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に切り出した関数を以下のように使います.

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

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

    
    # セットボタンが押されたときのハンドラ
    @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

    到着

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

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

    次に, chat.deleteScheduledMessageを使ってスケジュールを削除し, 褒めメッセージを送信します.

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

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

    # メンションが付けられたときのハンドラ
    @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

    間に合った場合
    intime.png

    まとめ

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

    参考

    14
    1
    0

    Register as a new user and use Qiita more conveniently

    1. You get articles that match your needs
    2. You can efficiently read back useful information
    3. You can use dark theme
    What you can do with signing up
    14
    1