Go で Slack Bot を作る (2020年3月版) の続編です。今回は Interactive Message を活用してちょっとリッチな Slack Bot を作っていきます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は 「まとめ」 に記載されているものをご参照ください。
作るもの
今回は ChatOps を意識した Bot を作ります。
下図のように Bot に deploy
と命じると、デプロイするバージョンの候補がセレクトメニューで提示されます。
バージョンを選択すると確認メッセージが表示されます。
Do it
を選ぶと Bot からデプロイの開始が通知され、その後デプロイ終了が通知されます。
準備
準備として次の作業を行っておきます。
Step1: セレクトメニューつきのメッセージを投稿させる
まずは Bot に deploy
と命じるとデプロイするバージョンの候補をセレクトメニューで提示する部分を実装します。
ボタンなどの UI は Block Kit で構築します。Block Kit で UI を作る場合は Block Kit Builder を使うことでいろいろと試すことができます。
実装は次のようになります。
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 検証が成功したことが確認できるはずです。
問題なければ Subscribe to bot events
を開いて app_mention
イベントを追加して Save Changes
をクリックします。
これで Bot に対して deploy
と命じるとメニューが表示されるはずです。
Slack クライアントで適当なチャンネルを開き、Bot ユーザーをチャンネルに追加して実行してみましょう。
当然、この段階ではバージョンを選択しても何も起こりません。
Step2: バージョンが選択されたら確認ボタンを出す
次はユーザーのバージョン選択に反応してメッセージを更新し、確認ボタンを表示する部分を実装します。
コードを次のように修正します。
(※ slack-go/slack のバージョンが v0.6.4 以上である必要があります)
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 payload
は slack.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
で設定を保存します。
ngrok を再実行した場合は URL が変わっているので Event Subscriptions
の Request URL
設定も変更しておきましょう。
再びデプロイを命じてみます。
v1.1.0
を選択してみます。
セレクトメニューが消えて確認メッセージが表示されました。
Slack の仕様なのか、replace_original
で上書きしたメッセージのユーザー名やアイコンが変わってしまっていますが気にしないことにします(いい解決法があったら教えてください)。
Step3: 確認ボタンが押されたらデプロイを実行する
最後に Do it
ボタンのクリックに反応してデプロイを実行する部分を実装していきます。
今回はデプロイには10秒かかる想定で、10秒待つだけの関数をデプロイ処理とみなして実装します(簡単のため、エラーは発生しないものとします)。
コードを次のように修正します。
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
に、確認ボタンの blockID
の case
を追加しています。
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
ボタンが押されたときだけデプロイ処理を実行しています。
また、どちらのボタンが押された場合でも発生元の確認メッセージを削除しています。
クリックされたボタンの Value
が action.Value
に入っているので、どのボタンがクリックされたかを特定することができます。
今回は Do it
ボタンに v
から始まるバージョン番号、Stop
ボタンに deny
を Value としてセットしておいたので、action.Value
が v
から始まっていれば 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
デプロイを命じてみます。
v1.1.0
を選択してみます。
Do it
をクリックします。
確認メッセージが削除され、デプロイの開始通知が届きました。
さらに10秒後、デプロイの完了通知が届きました。
まとめ
Go 言語による Interactive な Slack Bot の作り方について解説しました。
slack-go/slack 公式には Block Kit によるインタラクションについての十分なドキュメントや example がなく微妙にハマりがちなので、本記事が少しでも参考になれば幸いです。
最後に完成したコードの全体を載せておきます。
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)
}