LoginSignup
29
24

More than 3 years have passed since last update.

Go で Interactive な Slack Bot を作る (2020年5月版)

Last updated at Posted at 2020-05-01

Go で Slack Bot を作る (2020年3月版) の続編です。今回は Interactive Message を活用してちょっとリッチな Slack Bot を作っていきます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は 「まとめ」 に記載されているものをご参照ください。

作るもの

今回は ChatOps を意識した Bot を作ります。

下図のように Bot に deploy と命じると、デプロイするバージョンの候補がセレクトメニューで提示されます。

interactive1.png

バージョンを選択すると確認メッセージが表示されます。

interactive2.png

Do it を選ぶと Bot からデプロイの開始が通知され、その後デプロイ終了が通知されます。

interactive3.png

interactive4.png

準備

準備として次の作業を行っておきます。

Step1: セレクトメニューつきのメッセージを投稿させる

まずは Bot に deploy と命じるとデプロイするバージョンの候補をセレクトメニューで提示する部分を実装します。

ボタンなどの UI は Block Kit で構築します。Block Kit で UI を作る場合は Block Kit Builder を使うことでいろいろと試すことができます。

実装は次のようになります。

main.go
package main

import (
    "encoding/json"
    "io"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"

    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

const (
    selectVersionAction = "select-version"
)

func main() {
    api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))

    http.HandleFunc("/slack/events", func(w http.ResponseWriter, r *http.Request) {
        verifier, err := slack.NewSecretsVerifier(r.Header, os.Getenv("SLACK_SIGNING_SECRET"))
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        bodyReader := io.TeeReader(r.Body, &verifier)
        body, err := ioutil.ReadAll(bodyReader)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        if err := verifier.Ensure(); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        switch eventsAPIEvent.Type {
        case slackevents.URLVerification:
            var res *slackevents.ChallengeResponse
            if err := json.Unmarshal(body, &res); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
            w.Header().Set("Content-Type", "text/plain")
            if _, err := w.Write([]byte(res.Challenge)); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        case slackevents.CallbackEvent:
            innerEvent := eventsAPIEvent.InnerEvent
            switch event := innerEvent.Data.(type) {
            case *slackevents.AppMentionEvent:
                message := strings.Split(event.Text, " ")
                if len(message) < 2 {
                    w.WriteHeader(http.StatusBadRequest)
                    return
                }

                command := message[1]
                switch command {
                case "deploy":
                    text := slack.NewTextBlockObject(slack.MarkdownType, "Please select *version*.", false, false)
                    textSection := slack.NewSectionBlock(text, nil, nil)

                    versions := []string{"v1.0.0", "v1.1.0", "v1.1.1"}
                    options := make([]*slack.OptionBlockObject, 0, len(versions))
                    for _, v := range versions {
                        optionText := slack.NewTextBlockObject(slack.PlainTextType, v, false, false)
                        options = append(options, slack.NewOptionBlockObject(v, optionText))
                    }

                    placeholder := slack.NewTextBlockObject(slack.PlainTextType, "Select version", false, false)
                    selectMenu := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, placeholder, "", options...)

                    actionBlock := slack.NewActionBlock(selectVersionAction, selectMenu)

                    fallbackText := slack.MsgOptionText("This client is not supported.", false)
                    blocks := slack.MsgOptionBlocks(textSection, actionBlock)

                    if _, err := api.PostEphemeral(event.Channel, event.User, fallbackText, blocks); err != nil {
                        log.Println(err)
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                    }
                }
            }
        }
    })

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

Go で Slack Bot を作る (2020年3月版) で作成したものをベースに、主にメッセージの送信部分のコードを修正しています。

コードの解説
                    text := slack.NewTextBlockObject(slack.MarkdownType, "Please select *version*.", false, false)
                    textSection := slack.NewSectionBlock(text, nil, nil)

                    versions := []string{"v1.0.0", "v1.1.0", "v1.1.1"}
                    options := make([]*slack.OptionBlockObject, 0, len(versions))
                    for _, v := range versions {
                        optionText := slack.NewTextBlockObject(slack.PlainTextType, v, false, false)
                        options = append(options, slack.NewOptionBlockObject(v, optionText))
                    }

                    placeholder := slack.NewTextBlockObject(slack.PlainTextType, "Select version", false, false)
                    selectMenu := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, placeholder, "", options...)

                    actionBlock := slack.NewActionBlock(selectVersionAction, selectMenu)

Block Kit でセレクトメニューを含むメッセージを作成しています。
JSON で表すと このような感じ のものを slack-go/slack で組み立てています。

slack.NewTextBlockObject()Text object を作るための関数です。Text object は Block Kit で UI を構築する際の最も基本的な Object の1つなので大抵のケースで頻出する関数になると思います。

slack.NewSectionBlock()Section Block を作るための関数です。Section Block は複数のテキストをまとめたり、テキストとボタンなどを組みわせることができる Block です。今回は単純にテキストを表示するために使用しています。

slack.NewOptionBlockObject()Option object を作るための関数です。Option object はセレクトメニューのオプションとして機能するオブジェクトです。

slack.NewOptionsSelectBlockElement()Select menu element を作るための関数です。第一引数に与える optType によって様々なセレクトメニューを作成できますが、今回はシンプルに与えた Option object から作成したいので slack.OptTypeStatic を指定しています。

slack.NewActionBlock()Actions Block を作るための関数です。Actions Block はボタンのようなインタラクティブな要素を持つことができる Block です(複数の要素を持たせることもできます)。今回はシンプルにセレクトメニューを1つだけ持たせています。第一引数で指定している blockID は後でアクションの発生元を特定するために使用します。

                    fallbackText := slack.MsgOptionText("This client is not supported.", false)
                    blocks := slack.MsgOptionBlocks(textSection, actionBlock)
                    if _, err := api.PostEphemeral(event.Channel, event.User, fallbackText, blocks); err != nil {
                        log.Println(err)
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                    }

Slack の chat.postEphemeral API で、デプロイを命じたユーザーにだけ見える一時メッセージを送信しています。作成した Block は *Client.PostEphemeral() のようなメッセージ送信系の API を叩くためのメソッドに Option として渡すことができます。slack.MsgOptionBlocks() は Block をメソッドに渡せる形式の Option に変換するための関数です。
また、slack.MsgOptionText() を使用して payload のトップレベルの text フィールドに This client is not supported. というテキストを設定しています。これは こちらのドキュメント に記載されているように、Block Kit などのリッチな UI に対応していないクライアントのための Fallback text として機能します。

問題なくセレクトメニューが送信されるかを試してみましょう。

作成した App を実行します。

$ go run main.go
2020/03/31 23:17:51 [INFO] Server listening

ngrok を使用して App を外部公開します。

$ ngrok http 8080
Session Status                online
Account                       frozenbonito (Plan: Free)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:8080
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:8080

Your Apps から作成した Slack App を選択し、左側のメニューの Event Subscriptions を開きます。
Enable Events を On にし、Request URL に ngrok で得られた URL とイベントを待ち受けるパスである /slack/events を結合したエンドポイント https://xxxxxxxx.ngrok.io/slack/events を入力します。
次のように URL 検証が成功したことが確認できるはずです。

slackbot5.png

問題なければ Subscribe to bot events を開いて app_mention イベントを追加して Save Changes をクリックします。

slackbot6.png

これで Bot に対して deploy と命じるとメニューが表示されるはずです。
Slack クライアントで適当なチャンネルを開き、Bot ユーザーをチャンネルに追加して実行してみましょう。

interactive6.png

当然、この段階ではバージョンを選択しても何も起こりません。

Step2: バージョンが選択されたら確認ボタンを出す

次はユーザーのバージョン選択に反応してメッセージを更新し、確認ボタンを表示する部分を実装します。

コードを次のように修正します。
(slack-go/slack のバージョンが v0.6.4 以上である必要があります)

main.go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"

    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

const (
    selectVersionAction     = "select-version"
    confirmDeploymentAction = "confirm-deployment"
)

func main() {
    api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))

    http.HandleFunc("/slack/events", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        switch eventsAPIEvent.Type {
        case slackevents.URLVerification:
            var res *slackevents.ChallengeResponse
            if err := json.Unmarshal(body, &res); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
            w.Header().Set("Content-Type", "text/plain")
            if _, err := w.Write([]byte(res.Challenge)); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        case slackevents.CallbackEvent:
            innerEvent := eventsAPIEvent.InnerEvent
            switch event := innerEvent.Data.(type) {
            case *slackevents.AppMentionEvent:
                message := strings.Split(event.Text, " ")
                if len(message) < 2 {
                    w.WriteHeader(http.StatusBadRequest)
                    return
                }

                command := message[1]
                switch command {
                case "deploy":
                    text := slack.NewTextBlockObject(slack.MarkdownType, "Please select *version*.", false, false)
                    textSection := slack.NewSectionBlock(text, nil, nil)

                    versions := []string{"v1.0.0", "v1.1.0", "v1.1.1"}
                    options := make([]*slack.OptionBlockObject, 0, len(versions))
                    for _, v := range versions {
                        optionText := slack.NewTextBlockObject(slack.PlainTextType, v, false, false)
                        options = append(options, slack.NewOptionBlockObject(v, optionText))
                    }

                    placeholder := slack.NewTextBlockObject(slack.PlainTextType, "Select version", false, false)
                    selectMenu := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, placeholder, "", options...)

                    actionBlock := slack.NewActionBlock(selectVersionAction, selectMenu)

                    fallbackText := slack.MsgOptionText("This client is not supported.", false)
                    blocks := slack.MsgOptionBlocks(textSection, actionBlock)

                    if _, err := api.PostEphemeral(event.Channel, event.User, fallbackText, blocks); err != nil {
                        log.Println(err)
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                    }
                }
            }
        }
    }))

    http.HandleFunc("/slack/actions", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        var payload *slack.InteractionCallback
        if err := json.Unmarshal([]byte(r.FormValue("payload")), &payload); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        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 selectVersionAction:
                version := action.SelectedOption.Value

                text := slack.NewTextBlockObject(slack.MarkdownType,
                    fmt.Sprintf("Could I deploy `%s`?", version), false, false)
                textSection := slack.NewSectionBlock(text, nil, nil)

                confirmButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Do it", false, false)
                confirmButton := slack.NewButtonBlockElement("", version, confirmButtonText)
                confirmButton.WithStyle(slack.StylePrimary)

                denyButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Stop", false, false)
                denyButton := slack.NewButtonBlockElement("", "deny", denyButtonText)
                denyButton.WithStyle(slack.StyleDanger)

                actionBlock := slack.NewActionBlock(confirmDeploymentAction, confirmButton, denyButton)

                fallbackText := slack.MsgOptionText("This client is not supported.", false)
                blocks := slack.MsgOptionBlocks(textSection, actionBlock)

                replaceOriginal := slack.MsgOptionReplaceOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", replaceOriginal, fallbackText, blocks); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }
            }
        }
    }))

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func slackVerificationMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        verifier, err := slack.NewSecretsVerifier(r.Header, os.Getenv("SLACK_SIGNING_SECRET"))
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        bodyReader := io.TeeReader(r.Body, &verifier)
        body, err := ioutil.ReadAll(bodyReader)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        if err := verifier.Ensure(); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        r.Body = ioutil.NopCloser(bytes.NewBuffer(body))

        next.ServeHTTP(w, r)
    }
}

コードの解説
func slackVerificationMiddleware(next http.HandlerFunc) http.HandlerFunc {
    // ...
}

ボタンクリック等のユーザーのアクションを待ち受けるためのエンドポイントを新しく用意する必要があるので、リクエスト検証の部分は Middleware として関数に切り出しました。

func main() {
    api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))

    http.HandleFunc("/slack/events", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        // ...
    }))

    http.HandleFunc("/slack/actions", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        // ...
    }))

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

Middleware を使用するように main 関数を修正しています。
また、ユーザーのアクションを待ち受けるためのパスとして /slack/actions を追加しました。

以下、/slack/actions 内の処理について解説していきます。

        var payload *slack.InteractionCallback
        if err := json.Unmarshal([]byte(r.FormValue("payload")), &payload); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

ユーザーのボタンクリック等のアクションが発生したとき、Slack はそのアクションに関する情報を interaction payload (JSON) として Slack App 設定で指定された URL に対して POST で送信します。
この interaction payloadslack.InteractionCallback という構造体として定義されているので、構造体ポインタを用意して JSON Unmarshal しています。
interaction payload はそのままリクエストボディに入っているわけではなく payload パラメータに入れられていることに注意が必要です(ドキュメント を参照)。

        switch payload.Type {
        case slack.InteractionTypeBlockActions:
            // ...
        }

payload の Type によって処理を分岐させています。
Block Kit で作られたメッセージ中のインタラクティブなコンポーネントの場合は block_actions (slack.InteractionTypeBlockActions) となります。

            if len(payload.ActionCallback.BlockActions) == 0 {
                w.WriteHeader(http.StatusBadRequest)
                return
            }

            action := payload.ActionCallback.BlockActions[0]

アクションの詳細な情報を取得しています。
アクションの詳細は payload.ActionCallbacl.BlockActions から取得することができます。
BlockActions は複数の値が入りうるため slice になっていますが、今回の使い方の場合は1つの値しか入らないはずなので決め打ちで最初の要素を取り出してしまっています。

            switch action.BlockID {
            case selectVersionAction:
                // ...
            }

アクションの発生元によって処理を分岐させています。
発生元は Block 作成時に振っておいた BlockID から判定することができます。

                version := action.SelectedOption.Value

選択されたバージョンを取得しています。

                text := slack.NewTextBlockObject(slack.MarkdownType,
                    fmt.Sprintf("Could I deploy `%s`?", version), false, false)
                textSection := slack.NewSectionBlock(text, nil, nil)

                confirmButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Do it", false, false)
                confirmButton := slack.NewButtonBlockElement("", version, confirmButtonText)
                confirmButton.WithStyle(slack.StylePrimary)

                denyButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Stop", false, false)
                denyButton := slack.NewButtonBlockElement("", "deny", denyButtonText)
                denyButton.WithStyle(slack.StyleDanger)

                actionBlock := slack.NewActionBlock(confirmDeploymentAction, confirmButton, denyButton)

                fallbackText := slack.MsgOptionText("This client is not supported.", false)
                blocks := slack.MsgOptionBlocks(textSection, actionBlock)

Block Kit で確認用のメッセージを作成しています。
JSON で表すと このような感じ になります。

slack.NewButtonBlockElement()Button element を作るための関数です。今回は Do it (confirm) ボタンと Stop (deny) ボタンを作るために使用しています。Do it ボタンでは選択されたバージョンを Value にセットすることでクリックされたときにバージョンを取れるようにしています。

また、*ButtonBlockElement.WithStyle() メソッドを使用することでボタンの色を変更しています。

                replaceOriginal := slack.MsgOptionReplaceOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", replaceOriginal, fallbackText, blocks); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }

Block Kit で作成したメッセージを送信しています。

アクションに対するレスポンスとしてメッセージを送信する場合は こちらのドキュメント に記載されているように interaction payload に含まれる response_url に対してメッセージを送信する必要があります。これによってアクションの発生元の場所に対してメッセージが投稿されます。
また、その際メッセージの payload に "replace_original": true をセットすることで発生元のメッセージを上書きする形で投稿が可能です(chat.update でもメッセージの上書きが可能ですが、chat.postEphemeral で作った一時メッセージには使えないのでこの方法を使う必要があります)。

slack-go/slack では MsgOptionReplaceOriginal() を使用することでこの操作を実現できます(replace_original がいらない場合は MsgOptionResponseURL() を使います)。
この場合、メッセージの送信には *Client.SendMessage() を使います。

インタラクションのための設定をして動作確認をしましょう。

$ go run main.go
2020/04/27 00:48:32 [INFO] Server listening
$ ngrok http 8080
Session Status                online
Account                       frozenbonito (Plan: Free)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:8080
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:8080

再び Slack App の設定画面を開き、左側のメニューの Interactivity & Shortcuts を選択します。
Interactivity を On にし、Request URL に アクション用のエンドポイント https://xxxxxxxx.ngrok.io/slack/actions を入力したら Save Changes で設定を保存します。

interactive5.png

ngrok を再実行した場合は URL が変わっているので Event SubscriptionsRequest URL 設定も変更しておきましょう。

再びデプロイを命じてみます。

interactive7.png

v1.1.0 を選択してみます。

interactive8.png

セレクトメニューが消えて確認メッセージが表示されました。
Slack の仕様なのか、replace_original で上書きしたメッセージのユーザー名やアイコンが変わってしまっていますが気にしないことにします(いい解決法があったら教えてください)。

Step3: 確認ボタンが押されたらデプロイを実行する

最後に Do it ボタンのクリックに反応してデプロイを実行する部分を実装していきます。
今回はデプロイには10秒かかる想定で、10秒待つだけの関数をデプロイ処理とみなして実装します(簡単のため、エラーは発生しないものとします)。

コードを次のように修正します。

main.go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

const (
    selectVersionAction     = "select-version"
    confirmDeploymentAction = "confirm-deployment"
)

func main() {
    api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))

    http.HandleFunc("/slack/events", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        // ...
    }))

    http.HandleFunc("/slack/actions", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        var payload *slack.InteractionCallback
        if err := json.Unmarshal([]byte(r.FormValue("payload")), &payload); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        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 selectVersionAction:
                // ...
            // 追加
            case confirmDeploymentAction:
                if strings.HasPrefix(action.Value, "v") {
                    version := action.Value
                    go func() {
                        startMsg := slack.MsgOptionText(
                            fmt.Sprintf("<@%s> OK, I will deploy `%s`.", payload.User.ID, version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, startMsg); err != nil {
                            log.Println(err)
                        }

                        deploy(version)

                        endMsg := slack.MsgOptionText(
                            fmt.Sprintf("`%s` deployment completed!", version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, endMsg); err != nil {
                            log.Println(err)
                        }
                    }()
                }

                deleteOriginal := slack.MsgOptionDeleteOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", deleteOriginal); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }
            }
        }
    }))

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func slackVerificationMiddleware(next http.HandlerFunc) http.HandlerFunc {
    // ...
}

// 追加
func deploy(version string) {
    log.Printf("deploy %s", version)
    time.Sleep(10 * time.Second)
}

コードの解説
            switch action.BlockID {
            case selectVersionAction:
                // ...
            // 追加
            case confirmDeploymentAction:
                // ...
            }

アクションの発生元で処理を分岐させている switch に、確認ボタンの blockIDcase を追加しています。

                if strings.HasPrefix(action.Value, "v") {
                    // デプロイ処理
                    // ...
                }

                deleteOriginal := slack.MsgOptionDeleteOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", deleteOriginal); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }

Do it ボタンが押されたときだけデプロイ処理を実行しています。
また、どちらのボタンが押された場合でも発生元の確認メッセージを削除しています。

クリックされたボタンの Valueaction.Value に入っているので、どのボタンがクリックされたかを特定することができます。
今回は Do it ボタンに v から始まるバージョン番号、Stop ボタンに deny を Value としてセットしておいたので、action.Valuev から始まっていれば Do it が押されたものと判断することができます。

発生元のメッセージは response_url に対して {"delete_original": true} を送信することで削除することができます。
slack-go/slack では MsgOptionDeleteOriginal() を使用します。

                    version := action.Value
                    go func() {
                        startMsg := slack.MsgOptionText(
                            fmt.Sprintf("<@%s> OK, I will deploy `%s`.", payload.User.ID, version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, startMsg); err != nil {
                            log.Println(err)
                        }

                        deploy(version)

                        endMsg := slack.MsgOptionText(
                            fmt.Sprintf("`%s` deployment completed!", version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, endMsg); err != nil {
                            log.Println(err)
                        }
                    }()

デプロイ処理は時間がかかる想定なので、goroutine を使って非同期に実行しています。
Slack App ではリクエストに対して3秒以内にレスポンスを返す必要がある ので、基本的に時間がかかる処理は非同期処理に逃がしてあげる必要があります。

デプロイの開始通知と終了通知は実行者だけでなくチャンネルのメンバー全員に見えた方がいいので、一時メッセージではなく、chat.postMessage でチャンネルに投稿するようにしています。
ちなみに Slack API からのメッセージでは <@userID> のように書くことでメンションを飛ばすことができます。

これで全ての実装が完了したので最後の動作確認を行います。

$ go run main.go 
2020/05/01 23:13:41 [INFO] Server listening
$ ngrok http 8080
Session Status                online
Account                       frozenbonito (Plan: Free)
Version                       2.3.35
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:8080
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:8080

デプロイを命じてみます。

interactive1.png

v1.1.0 を選択してみます。

interactive2.png

Do it をクリックします。

interactive3.png

確認メッセージが削除され、デプロイの開始通知が届きました。

interactive4.png

さらに10秒後、デプロイの完了通知が届きました。

まとめ

Go 言語による Interactive な Slack Bot の作り方について解説しました。
slack-go/slack 公式には Block Kit によるインタラクションについての十分なドキュメントや example がなく微妙にハマりがちなので、本記事が少しでも参考になれば幸いです。

最後に完成したコードの全体を載せておきます。

main.go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

const (
    selectVersionAction     = "select-version"
    confirmDeploymentAction = "confirm-deployment"
)

func main() {
    api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))

    http.HandleFunc("/slack/events", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        switch eventsAPIEvent.Type {
        case slackevents.URLVerification:
            var res *slackevents.ChallengeResponse
            if err := json.Unmarshal(body, &res); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
            w.Header().Set("Content-Type", "text/plain")
            if _, err := w.Write([]byte(res.Challenge)); err != nil {
                log.Println(err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        case slackevents.CallbackEvent:
            innerEvent := eventsAPIEvent.InnerEvent
            switch event := innerEvent.Data.(type) {
            case *slackevents.AppMentionEvent:
                message := strings.Split(event.Text, " ")
                if len(message) < 2 {
                    w.WriteHeader(http.StatusBadRequest)
                    return
                }

                command := message[1]
                switch command {
                case "deploy":
                    text := slack.NewTextBlockObject(slack.MarkdownType, "Please select *version*.", false, false)
                    textSection := slack.NewSectionBlock(text, nil, nil)

                    versions := []string{"v1.0.0", "v1.1.0", "v1.1.1"}
                    options := make([]*slack.OptionBlockObject, 0, len(versions))
                    for _, v := range versions {
                        optionText := slack.NewTextBlockObject(slack.PlainTextType, v, false, false)
                        options = append(options, slack.NewOptionBlockObject(v, optionText))
                    }

                    placeholder := slack.NewTextBlockObject(slack.PlainTextType, "Select version", false, false)
                    selectMenu := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, placeholder, "", options...)

                    actionBlock := slack.NewActionBlock(selectVersionAction, selectMenu)

                    fallbackText := slack.MsgOptionText("This client is not supported.", false)
                    blocks := slack.MsgOptionBlocks(textSection, actionBlock)

                    if _, err := api.PostEphemeral(event.Channel, event.User, fallbackText, blocks); err != nil {
                        log.Println(err)
                        w.WriteHeader(http.StatusInternalServerError)
                        return
                    }
                }
            }
        }
    }))

    http.HandleFunc("/slack/actions", slackVerificationMiddleware(func(w http.ResponseWriter, r *http.Request) {
        var payload *slack.InteractionCallback
        if err := json.Unmarshal([]byte(r.FormValue("payload")), &payload); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        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 selectVersionAction:
                version := action.SelectedOption.Value

                text := slack.NewTextBlockObject(slack.MarkdownType,
                    fmt.Sprintf("Could I deploy `%s`?", version), false, false)
                textSection := slack.NewSectionBlock(text, nil, nil)

                confirmButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Do it", false, false)
                confirmButton := slack.NewButtonBlockElement("", version, confirmButtonText)
                confirmButton.WithStyle(slack.StylePrimary)

                denyButtonText := slack.NewTextBlockObject(slack.PlainTextType, "Stop", false, false)
                denyButton := slack.NewButtonBlockElement("", "deny", denyButtonText)
                denyButton.WithStyle(slack.StyleDanger)

                actionBlock := slack.NewActionBlock(confirmDeploymentAction, confirmButton, denyButton)

                fallbackText := slack.MsgOptionText("This client is not supported.", false)
                blocks := slack.MsgOptionBlocks(textSection, actionBlock)

                replaceOriginal := slack.MsgOptionReplaceOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", replaceOriginal, fallbackText, blocks); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }
            case confirmDeploymentAction:
                if strings.HasPrefix(action.Value, "v") {
                    version := action.Value
                    go func() {
                        startMsg := slack.MsgOptionText(
                            fmt.Sprintf("<@%s> OK, I will deploy `%s`.", payload.User.ID, version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, startMsg); err != nil {
                            log.Println(err)
                        }

                        deploy(version)

                        endMsg := slack.MsgOptionText(
                            fmt.Sprintf("`%s` deployment completed!", version), false)
                        if _, _, err := api.PostMessage(payload.Channel.ID, endMsg); err != nil {
                            log.Println(err)
                        }
                    }()
                }

                deleteOriginal := slack.MsgOptionDeleteOriginal(payload.ResponseURL)
                if _, _, _, err := api.SendMessage("", deleteOriginal); err != nil {
                    log.Println(err)
                    w.WriteHeader(http.StatusInternalServerError)
                    return
                }
            }
        }
    }))

    log.Println("[INFO] Server listening")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

func slackVerificationMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        verifier, err := slack.NewSecretsVerifier(r.Header, os.Getenv("SLACK_SIGNING_SECRET"))
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        bodyReader := io.TeeReader(r.Body, &verifier)
        body, err := ioutil.ReadAll(bodyReader)
        if err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        if err := verifier.Ensure(); err != nil {
            log.Println(err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        r.Body = ioutil.NopCloser(bytes.NewBuffer(body))

        next.ServeHTTP(w, r)
    }
}

func deploy(version string) {
    log.Printf("deploy %s", version)
    time.Sleep(10 * time.Second)
}
29
24
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
29
24