ゆっくりのんびり。

いの (@inox_ee) です

Go言語でInteractiveなSlack Botを作ってみた

まえがき

最近就活に現を抜かしていたら、修士研究に背中を刺され重傷を負った ino です。

今回はアルバイト先の業務改善として、slack bot を作成しました。 bot 作りは、公式の提供する機能が充実しているが故に初手で詰まってしまうことも多いため、本ブログが参考になると幸いです。

なお、作り方をググるAPI GatewayAWS Lambda で構成している方がほとんどですが、勉強のため Go でイチから作成しました。よってサーバレスではありません。 温かい目で見守っていただけると幸いです。

作成した Bot が出来ること

基本機能

  • Bots: 様々なチャンネルに招待可能
  • Event Subscriptions: メンション(@hogefuga) での呼び出しに応答可能
  • Permissions: チャンネルへメッセージを投稿可能
  • Interactive Components: ユーザのアクションに応じて次の動作を決定可能

これらについては、Slack App が提供する Feature を参照ください。

ユースケース

Bot は、会議室予約システムを目指して作成されました。具体的なアクションフローは以下の通りです。

  1. チャンネルにボット(以降 @予約くん)を招待する
  2. @予約くん と呼ぶと、現在の予約一覧を返す
  3. @予約くん reserve と呼ぶと、以下の予約手続きに進む
    1. 部屋名、開始時間、終了時間を指定する
    2. 確認ボタンを押して予約を実行する
    3. 重複判定を行い、空いていた場合、DB に格納する
    4. 予約完了メッセージをチャンネルに投稿する
  4. @予約くん 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 です。 おおよその手順は以下の通り

  1. Slack API: Applications | Slack からアプリを作成
  2. Basic Information > App Credentials > Signing Secret の値を控える
  3. OAuth & Permissions > Scopeschat:write を追加
  4. ここで一旦 URL Verification 用のエンドポイントを実装し、デプロイを行う。
  5. Event Subscription > Enable Events > Request URL でデプロイ先 URL の承認を行う。そして 同ページ > Subscribe to bot events から app_mention を追加。
  6. これにより chat:writeapp_mention の権限を持つアクセストークンに更新される。手元の .evn ファイルの更新とアプリのインストールを行う。
  7. Interactivity & Shortcuts > Enable Interactivity を ON > Request URL にアクション用のエンドポイントを入力

多分 slack api 側でやることは以上です。 Basic Information が以下のように 4 つ✔ついていたら ok。

f:id:puyobyee18:20211219122540p:plain
Basic Information から4つの設定が終わっていることを確認

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 が操作されるたびに」リクエストが飛んできます。つまり roomSelectMenustartTimePickerendTimePicker をまとめた action block の Block ID で条件分岐を行っても、payload.ActionCallback.BlockActions に格納されている Action は操作した 1 つのみです。

色々調べた結果(かなり沼った)、payload.BlockActionState.Values に他のパラメータが全て格納されていました。3 入力を束ねる ActionBlock の他に確認ボタン用の ActionBlock を用意し、それをトリガーとしてイベントを発火させることで、payload.BlockActionState.Value から必要な情報を取得することが出来ます。

本ブログで一番大事なところなので、誰かの時間を救うことが出来たら本望です。
また、もっと良い方法があれば是非教えて下さい。

最後に

f:id:puyobyee18:20211219125316g:plain
Slack Bot の完成!

次はサーバーレスの実装かなー


  1. Using the Slack Events API | Slack あたり

  2. slack/outer_events.go at master · slack-go/slack

  3. .(type) なんて記法あるのか、と思ったらどうやら型アサーションの一部らしい。 switch 内限定で、 interface{} の型判定が可能。

  4. https://api.slack.com/tools/block-kit-builder