Go言語でInteractiveなSlack Botを作ってみた
まえがき
最近就活に現を抜かしていたら、修士研究に背中を刺され重傷を負った ino です。
今回はアルバイト先の業務改善として、slack bot を作成しました。 bot 作りは、公式の提供する機能が充実しているが故に初手で詰まってしまうことも多いため、本ブログが参考になると幸いです。
なお、作り方をググると API Gateway と AWS Lambda で構成している方がほとんどですが、勉強のため Go でイチから作成しました。よってサーバレスではありません。 温かい目で見守っていただけると幸いです。
作成した Bot が出来ること
基本機能
Bots
: 様々なチャンネルに招待可能Event Subscriptions
: メンション(@hogefuga
) での呼び出しに応答可能Permissions
: チャンネルへメッセージを投稿可能Interactive Components
: ユーザのアクションに応じて次の動作を決定可能
これらについては、Slack App が提供する Feature を参照ください。
ユースケース
本 Bot は、会議室予約システムを目指して作成されました。具体的なアクションフローは以下の通りです。
- チャンネルにボット(以降
@予約くん
)を招待する @予約くん
と呼ぶと、現在の予約一覧を返す@予約くん reserve
と呼ぶと、以下の予約手続きに進む- 部屋名、開始時間、終了時間を指定する
- 確認ボタンを押して予約を実行する
- 重複判定を行い、空いていた場合、DB に格納する
- 予約完了メッセージをチャンネルに投稿する
@予約くん reset
と呼ぶと、全ての予約を削除する
※ 予約変更機能は v1.1
で実装予定(忘れてた)。
補足
- 最近は Socket Mode を利用した開発が推奨されているようですが、セキュアな WebSocket サーバの作り方に自信が無かったので従来通り(?) ngrok を使っています。
- Go × Slack API のデファクトスタンダードとなっている slack-go/slack: Slack API in Go ですが、公式の SDK ではないことに注意しましょう。不親切なドキュメントや理不尽な沼に寛容な人向けです。無理な人はおとなしく bolt を使いましょう。
- そういうのはいいからコードを早く見せろ!という人は こちら 。
Slack App の準備
Go で Slack Bot を作る (2020年3月版) - Qiita のブログが丁寧に書かれているので、この通り行えば ok です。 おおよその手順は以下の通り
- Slack API: Applications | Slack からアプリを作成
Basic Information
>App Credentials
>Signing Secret
の値を控えるOAuth & Permissions
>Scopes
にchat:write
を追加- ここで一旦 URL Verification 用のエンドポイントを実装し、デプロイを行う。
Event Subscription
>Enable Events
>Request URL
でデプロイ先 URL の承認を行う。そして 同ページ >Subscribe to bot events
からapp_mention
を追加。- これにより
chat:write
とapp_mention
の権限を持つアクセストークンに更新される。手元の .evn ファイルの更新とアプリのインストールを行う。 Interactivity & Shortcuts
>Enable Interactivity
を ON >Request URL
にアクション用のエンドポイントを入力
多分 slack api 側でやることは以上です。
Basic Information
が以下のように 4 つ✔ついていたら ok。
Go の準備
ngrok
ローカルに立てたサーバを一時的に外部に公開してくれる ngrok というサービスを使って、開発を行います。
# Sign up ngrok # Download ngrok-linux-package $ sudo tar -C ~/.local/bin -xzf ngrok-stable-linux-amd64.tgz $ chown USER:GROUP ~/.local/bin/ngrok # if you need
Go アプリ
ファイル構成は以下の通り。
. ├── api/ │ ├── actions.go │ ├── events.go │ ├── middleware.go │ ├── reserve.go │ └── server.go ├── util/ │ └── config.go ├── .env.sample ├── .gitignore ├── README.md ├── go.mod ├── go.sum └── main.go
重要箇所を抜粋して解説します。
Credentials
本アプリは以下の 2 種類のシークレットを必要とします。
- signing secret
- bot token
前者は Slack App へのリクエストが slack からであることのシークレット、後者は Slack App が Slack API を叩く際に必要なトークンです。後者に関しては Scope と紐づいているので、適宜機能追加に応じて更新する必要があることに注意。
エンドポイントごとに認証作業を行うのは面倒なのでミドルウェア化しましょう。
func slackVerificationMiddleware(config util.Config, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { verifier, err := slack.NewSecretsVerifier(r.Header, config.SlackSigningSecret) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } bodyReader := io.TeeReader(r.Body, &verifier) body, err := ioutil.ReadAll(bodyReader) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if err = verifier.Ensure(); err != nil { w.WriteHeader(http.StatusBadRequest) return } r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) next.ServeHTTP(w, r) } }
TeeReader
ってこういう時に使うんですね。
エンドポイント
Slack App では、Event API と Interactive Components を使います。 最小限のボットでは前者のみで十分ですが、今回は予約時間の指定などを行うため後者の機能も利用し、より対話的な bot を作成します。
まずは Event API 用のエンドポイント作成。パス ("/slack/events"
の部分) は任意です。
http.HandleFunc("/slack/events", slackVerificationMiddleware(srv.config, func(w http.ResponseWriter, r *http.Request) { body, err := ioutil.ReadAll(r.Body) handleError(err, w, http.StatusInternalServerError) eventsApiEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) handleError(err, w, http.StatusInternalServerError) srv.handleEventAPIEvent(eventsApiEvent, body, w) }))
*http.Request.Body
から []byte
型のボディを抜き出した後、slackevents.ParseEvent
を用いて json に直します。なおドキュメント1を読めば分かりますが、slack api では独自形式の json を通して挙動が制御されていきます。
続いて Interactive component 用のエンドポイント作成。
http.HandleFunc("/slack/actions", slackVerificationMiddleware(srv.config, func(w http.ResponseWriter, r *http.Request) { var payload *slack.InteractionCallback fmt.Println(r.FormValue("payload")) err := json.Unmarshal([]byte(r.FormValue("payload")), &payload) handleError(err, w, http.StatusInternalServerError) srv.handleActionPayload(payload, w) }))
先程のようなパース関数が用意されていない代わりに、slack.InteractionCallback
という型が用意されています。json.Unmarshal
を用いてペイロードを slack.InteractionCallback
型に直し、以降のハンドリングに使います。
Event API
今回は app:mention
を使います。その他のイベントは Events API types | Slack を参照ください。ここに無いものは Interactive Components として扱う、という認識でいいんですかね(あまり分かってない)。
func (srv *Server) handleEventAPIEvent(eventApiEvent slackevents.EventsAPIEvent, body []byte, w http.ResponseWriter) { switch eventApiEvent.Type { case slackevents.URLVerification: ... case slackevents.CallbackEvent: innerEvent := eventApiEvent.InnerEvent switch event := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: srv.handleAppMentionEvent(event, w) } } } func (srv *Server) handleAppMentionEvent(event *slackevents.AppMentionEvent, w http.ResponseWriter) { message := strings.Split(event.Text, " ") var command string command = message[1] switch command { case "ping": _, _, err := srv.slack.PostMessage(event.Channel, slack.MsgOptionText("pong", false)) handleError(err, w, http.StatusInternalServerError) case "reserve": text := slack.NewTextBlockObject(slack.MarkdownType, "利用する部屋を選択してください", false, false) textSection := slack.NewSectionBlock(text, nil, nil) ... // 部屋・時間の選択と確認ボタンのコンポネントを用意する inputBlock := slack.NewActionBlock(_selectRoomBlock, roomSelectMenu, startTimePicker, endTimePicker) actionBlock := slack.NewActionBlock(selectRoomBlock, confirmButton) fallbackText := slack.MsgOptionText("This client is not supported.", false) blocks := slack.MsgOptionBlocks(textSection, inputBlock, actionBlock) _, err := srv.slack.PostEphemeral(event.Channel, event.User, fallbackText, blocks) handleError(err, w, http.StatusInternalServerError) } }
slackevents.EventsAPIEvent.Type
に応じて条件分岐を行います。
URLVerification
については Using the Slack Events API | Slack を参照ください。
CallbackEvent
が、EventAPI のメインの型? になります。slack-go/slack では、EventsAPIEvent は outer / inner の 2 層で構成されているので2、innerEvent を取り出してから詳細な条件分岐を行います3。
今回は event.Text
から取得できるメッセージをスペース区切りでパースし、その先頭をコマンドとして扱ってみました。
補足: チャンネルへのポスト 2 種
slack bot が利用できるメッセージポストには 2 種類あり、一つは通常の投稿、もう一つは特定のユーザへの限定投稿(DM ではない)になります。
前者: func (api *Client) PostMessage(channelID string, options ...MsgOption) (string, string, error)
後者: func (api *Client) PostEphemeral(channelID, userID string, options ...MsgOption) (string, error)
Bot とのインタラクティブなやり取りには特に後者が使いやすいのでオススメです。
Interactivity
Event API では表現できない、ユーザとの対話的アクションに Interactive Components を利用します。コンポネントの配置用に Block Kit が提供されています。
Block Kit Builder4 を触れば分かりますが、Block Kit は Block - ActionBlock - BlockElement という三層から構成されているので、slack-go パッケージを使った最小構成のコードは次のようになります。
element = slack.NewHogeBlockElement(...) actionBlock = slack.NewActionBlock(BLOCK_ID, element) block = slack.MsgOptionBlocks(actionBlock)
この actionBlock に登録されている block element が操作されるたびに、事前に登録した Interactivity の request URL にリクエストが送られます。 このリクエストのハンドリング実装は以下になります。
func (srv *Server) handleActionPayload(payload *slack.InteractionCallback, w http.ResponseWriter) { switch payload.Type { case slack.InteractionTypeBlockActions: if len(payload.ActionCallback.BlockActions) == 0 { w.WriteHeader(http.StatusBadRequest) return } action := payload.ActionCallback.BlockActions[0] switch action.BlockID { case BLOCK_ID1: // actionBlock で指定した Block ID room := payload.BlockActionState.Values[_selectRoomBlock][roomNameAction].SelectedOption.Value startTime := payload.BlockActionState.Values[_selectRoomBlock][startTimeAction].SelectedTime endTime := payload.BlockActionState.Values[_selectRoomBlock][endTimeAction].SelectedTime ... replaceOriginal := slack.MsgOptionReplaceOriginal(payload.ResponseURL) _, _, _, err := srv.slack.SendMessage("", replaceOriginal, fallbackText, blocks) handleError(err, w, http.StatusInternalServerError) case BLOCK_ID2: ... } } }
補足: どうやって「部屋名」「開始時間」「終了時間」の 3 つを受け取るか
上記で述べた通り、「block element が操作されるたびに」リクエストが飛んできます。つまり roomSelectMenu
、startTimePicker
、endTimePicker
をまとめた action block の Block ID で条件分岐を行っても、payload.ActionCallback.BlockActions
に格納されている Action は操作した 1 つのみです。
色々調べた結果(かなり沼った)、payload.BlockActionState.Values
に他のパラメータが全て格納されていました。3 入力を束ねる ActionBlock の他に確認ボタン用の ActionBlock を用意し、それをトリガーとしてイベントを発火させることで、payload.BlockActionState.Value
から必要な情報を取得することが出来ます。
本ブログで一番大事なところなので、誰かの時間を救うことが出来たら本望です。
また、もっと良い方法があれば是非教えて下さい。
最後に
次はサーバーレスの実装かなー