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

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

## 作るもの

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

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

![interactive1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/5cb7a911-19f8-8bd3-0c75-1aa3a8070a4d.png)

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

![interactive2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/b2818f4e-7ae3-7622-dc88-674728acadd6.png)


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

![interactive3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/2cc50401-43b5-1f17-0944-8784bd071e38.png)

![interactive4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/dd69ab42-39b5-e5ca-ea3b-9d320a34bb10.png)


## 準備

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

- [Go で Slack Bot を作る (2020年3月版)#Slack App 作成 & 準備](https://qiita.com/frozenbonito/items/cf75dadce12ef9a048e9#slack-app-%E4%BD%9C%E6%88%90--%E6%BA%96%E5%82%99) 
- [Go で Slack Bot を作る (2020年3月版)#ローカル開発環境を用意](https://qiita.com/frozenbonito/items/cf75dadce12ef9a048e9#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83%E3%82%92%E7%94%A8%E6%84%8F)

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

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

ボタンなどの UI は [Block Kit](https://api.slack.com/block-kit) で構築します。Block Kit で UI を作る場合は [Block Kit Builder](https://api.slack.com/tools/block-kit-builder) を使うことでいろいろと試すことができます。

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

```go: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月版)](https://qiita.com/frozenbonito/items/cf75dadce12ef9a048e9) で作成したものをベースに、主にメッセージの送信部分のコードを修正しています。

<details><summary>コードの解説</summary><div>

```go
					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 で表すと [このような感じ](https://api.slack.com/tools/block-kit-builder?mode=message&blocks=%5B%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Please%20select%20*version*.%22%7D%7D%2C%7B%22type%22%3A%22actions%22%2C%22block_id%22%3A%22select-version%22%2C%22elements%22%3A%5B%7B%22type%22%3A%22static_select%22%2C%22placeholder%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Select%20version%22%7D%2C%22options%22%3A%5B%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22v1.0.0%22%7D%2C%22value%22%3A%22v1.0.0%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22v1.1.0%22%7D%2C%22value%22%3A%22v1.1.0%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22v1.1.1%22%7D%2C%22value%22%3A%22v1.1.1%22%7D%5D%7D%5D%7D%5D) のものを [slack-go/slack](https://github.com/slack-go/slack) で組み立てています。

[`slack.NewTextBlockObject()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewTextBlockObject) は [Text object](https://api.slack.com/reference/block-kit/composition-objects#text) を作るための関数です。Text object は Block Kit で UI を構築する際の最も基本的な Object の1つなので大抵のケースで頻出する関数になると思います。

[`slack.NewSectionBlock()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewSectionBlock) は [Section Block](https://api.slack.com/reference/block-kit/blocks#section) を作るための関数です。Section Block は複数のテキストをまとめたり、テキストとボタンなどを組みわせることができる Block です。今回は単純にテキストを表示するために使用しています。

[`slack.NewOptionBlockObject()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewOptionBlockObject) は [Option object](https://api.slack.com/reference/block-kit/composition-objects#option) を作るための関数です。Option object はセレクトメニューのオプションとして機能するオブジェクトです。

[`slack.NewOptionsSelectBlockElement()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewOptionsSelectBlockElement) は [Select menu element](https://api.slack.com/reference/block-kit/block-elements#select) を作るための関数です。第一引数に与える `optType` によって様々なセレクトメニューを作成できますが、今回はシンプルに与えた Option object から作成したいので `slack.OptTypeStatic` を指定しています。

[`slack.NewActionBlock()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewActionBlock) は [Actions Block](https://api.slack.com/reference/block-kit/blocks#actions) を作るための関数です。Actions Block はボタンのようなインタラクティブな要素を持つことができる Block です(複数の要素を持たせることもできます)。今回はシンプルにセレクトメニューを1つだけ持たせています。第一引数で指定している `blockID` は後でアクションの発生元を特定するために使用します。

```go
					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](https://api.slack.com/methods/chat.postEphemeral) API で、デプロイを命じたユーザーにだけ見える一時メッセージを送信しています。作成した Block は [`*Client.PostEphemeral()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#Client.PostEphemeral) のようなメッセージ送信系の API を叩くためのメソッドに Option として渡すことができます。[`slack.MsgOptionBlocks()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#MsgOptionBlocks) は Block をメソッドに渡せる形式の Option に変換するための関数です。
また、[`slack.MsgOptionText()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#MsgOptionText) を使用して payload のトップレベルの `text` フィールドに `This client is not supported.` というテキストを設定しています。これは [こちらのドキュメント](https://api.slack.com/messaging/attachments-to-blocks#fallback_text) に記載されているように、Block Kit などのリッチな UI に対応していないクライアントのための Fallback text として機能します。

</div></details>

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

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

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

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

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

![slackbot5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/65c68bef-bb39-c3fe-2bbb-8b64bad1ef87.png)

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

![slackbot6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/d9aab3a9-55b1-fc1d-63bf-745090301386.png)

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

![interactive6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/debcd49b-b45c-664b-387d-16198639e29b.png)

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

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

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

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

```go: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)
	}
}
```

<details><summary>コードの解説</summary><div>

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

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

```go
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` 内の処理について解説していきます。

```go
		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`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#InteractionCallback) という構造体として定義されているので、構造体ポインタを用意して JSON Unmarshal しています。
`interaction payload` はそのままリクエストボディに入っているわけではなく `payload` パラメータに入れられていることに注意が必要です([ドキュメント](https://api.slack.com/interactivity/handling#payloads) を参照)。

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

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

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

			action := payload.ActionCallback.BlockActions[0]

```

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

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

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

```go
				version := action.SelectedOption.Value
```

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

```go
				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 で表すと [このような感じ](https://api.slack.com/tools/block-kit-builder?mode=message&blocks=%5B%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Could%20I%20deploy%20*v1.1.0*%3F%22%7D%7D%2C%7B%22type%22%3A%22actions%22%2C%22block_id%22%3A%22confirm-deployment%22%2C%22elements%22%3A%5B%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Do%20it%22%7D%2C%22value%22%3A%22v1.1.0%22%2C%22style%22%3A%22primary%22%7D%2C%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Stop%22%7D%2C%22value%22%3A%22deny%22%2C%22style%22%3A%22danger%22%7D%5D%7D%5D) になります。

[`slack.NewButtonBlockElement()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#NewButtonBlockElement) は [Button element](https://api.slack.com/reference/block-kit/block-elements#button) を作るための関数です。今回は `Do it` (confirm) ボタンと `Stop` (deny) ボタンを作るために使用しています。`Do it` ボタンでは選択されたバージョンを `Value` にセットすることでクリックされたときにバージョンを取れるようにしています。

また、[`*ButtonBlockElement.WithStyle()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#ButtonBlockElement.WithStyle) メソッドを使用することでボタンの色を変更しています。

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

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

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

[slack-go/slack](https://github.com/slack-go/slack) では [`MsgOptionReplaceOriginal()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#MsgOptionReplaceOriginal) を使用することでこの操作を実現できます(`replace_original` がいらない場合は [`MsgOptionResponseURL()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#MsgOptionResponseURL) を使います)。
この場合、メッセージの送信には [`*Client.SendMessage()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#Client.SendMessage) を使います。

</div></details>

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

```console
$ go run main.go
2020/04/27 00:48:32 [INFO] Server listening
```

```console
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/3f42967c-9aad-957a-c41e-ce69e25fb4f9.png)

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

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

![interactive7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/d7a1da83-b53a-8ca0-6284-dcf23cc6bcab.png)

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

![interactive8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/fc326bd0-8423-17a1-5580-d5e07dafd6ce.png)

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

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

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


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

```go: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)
}
```

<details><summary>コードの解説</summary><div>

```go
			switch action.BlockID {
			case selectVersionAction:
				// ...
			// 追加
			case confirmDeploymentAction:
				// ...
			}
```

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

```go
				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](https://github.com/slack-go/slack) では [`MsgOptionDeleteOriginal()`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#MsgOptionDeleteOriginal) を使用します。

```go
					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秒以内にレスポンスを返す必要がある](https://api.slack.com/interactivity/handling#acknowledgment_response) ので、基本的に時間がかかる処理は非同期処理に逃がしてあげる必要があります。

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

</div></details>

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

```console
$ go run main.go 
2020/05/01 23:13:41 [INFO] Server listening
```

```console
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/5cb7a911-19f8-8bd3-0c75-1aa3a8070a4d.png)

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

![interactive2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/b2818f4e-7ae3-7622-dc88-674728acadd6.png)

`Do it` をクリックします。

![interactive3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/2cc50401-43b5-1f17-0944-8784bd071e38.png)

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

![interactive4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/dd69ab42-39b5-e5ca-ea3b-9d320a34bb10.png)

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

## まとめ

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

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

```go: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)
}
```