MYJLab Advent Calendar 2022、15日目の記事です!
前回は@takumiakasaka1231のPrisma+fakerでサクッとデモデータを作る
でした!
PrismaはGraphQLと相性が良くて個人的に推せるORMなので, 勝手にテンションが上がりました🌟
これは
私はよく大学に遅刻してしまうのですが, 冬は自分の意志の弱さにお外の寒さが加わり, 余計に家を出るのが遅くなってしまいます.
今回はこの課題を解決するために, 登校時間を管理してくれるマネージャーをslack botで作りました. 名前はMANEKOです🐈(私は犬派です)
MANEKOに期待することは以下の3つです.
- 登校予定時間を覚えていてくれる
- 登校予定時間に間に合ったら褒めてくれる
- 登校予定時間に間に合わなかった場合, みんなの前で叱ってくれる
弊ゼミでは, timesチャンネルを作っている人が何人かいて, 私もtimesチャンネルがあります.
それを利用してチャンネル1つにつき, 登校予定を1つセットできるようにし, 間に合わなかったらチャンネル内で晒し者になるようにしてみました.
実装方針
MANEKOに期待する3つを満たすために, それぞれの機能を次のように実装します
機能 | 実装方針 |
---|---|
登校予定時間を覚えていてくれる |
|
登校予定時間に間に合ったら褒めてくれる |
|
登校予定時間に間に合わなかった場合, みんなの前で叱ってくれる |
|
使用技術
- slack api
-
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の作成の際に生成したトークンを環境変数に設定します -
Dockerfile
とdocker-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 events
にapp_mention
というeventを追加します(③).
これでイベントの有効化は完了です! メンションを検知できるようになったはずなので, bolt/app.py
に以下のリスナー関数を追加します.
# メンションが付けられたときのハンドラ
@app.event("app_mention")
def respond_to_mention(client, body, context, logger, say):
say(f"呼んだ? <@{context['user_id']}>:cat:")
実際にslackからメンションをしてみると, 以下のように反応してくれました🎉
時間の登録フォームの送信
「時間」というキーワードを含むメンションが飛ばされたら, 時間を入力するフォームを送信するようにします.
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)
上記を追加して, 「時間」という言葉を含めてメンションすると, 無事フォームが送られてきました🎉
時間の登録
セットボタンが押されたら, その時間にメッセージをスケジュールします.
まずセットボタンをリッスンする関数を用意します.
Payloadの中で, セットボタンにはset-school-time
, 日付のピッカーにはset-datepicker
, 時刻のピッカーにはset-timepicker
というidを付与しています. このidを使うことで, フォームが操作されたことを検知できます. set-datepicker
とset-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()
次にリクエストから, 選択された日付を取得します.
リスナー関数の引数のひとつであるbody
がstate.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)
上記を追加し, 時間をセットすると, 無事できてそうなメッセージが返ってきました🎉
到着
最後に「到着」言葉を含むメンションを受け取ったら, スケジュールを解除し, お褒めの言葉を送信するようにします.
先ほどと同様に, スケジュールが存在するかを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)
これで完成です🎉
成果物
まとめ
cloud schedulerやcloud watch等のツールを使うことなく, slack apiだけで目的が達成できて感動しました.
MANEKOといっしょに寒い冬の朝を乗り切ろうと思います.
最後まで読んでいただきありがとうございました🌟