Go で Slack Bot を作る (2020年3月版)

シンプルな Slack Bot を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は [こちら](#終わりに) に記載されているものをご参照ください。

## 作るもの

下図のように Bot に対しメンションをつけて `ping` と送ると `pong` と返してくれるだけのシンプルなものです。

![slackbot1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/dca85675-4c76-0be8-6cec-44d3b853b62b.png)

Slack からのイベント受信には [Events API](https://api.slack.com/events-api) を使用します。同様に Slack からのイベントを受信する方法としては WebSocket でやり取りする [RTM API](https://api.slack.com/rtm) があり、こちらであれば App 側から Slack に接続しにいくのでサーバーの公開が不要でお手軽なのですが、現在は Events API の方を使用することが推奨されているようです([こちらの記事](https://api.slack.com/lang/ja-jp/which-api) が参考になります)。 

## Slack App 作成 & 準備

まずは[こちらの URL](https://api.slack.com/apps) を開き、`Create New App` を選択して新しい Slack App を作成します。

作成後、表示される `Basic Information` のページの中程にある `App Credentials` から `Signing Secret` の値を控えておきましょう。
`Signing Secret` は Slack App へのリクエストが Slack からのものであることを検証するために使用します。

![slackbot2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/4180d3ba-972d-5e0c-3b72-7966245523ce.png)

次に、左側のメニューの `OAuth & Permissions` を選択し、Slack App が Slack API を叩く際に使うアクセストークンの発行を行います。
`Scopes` から発行するアクセストークンの権限を設定することができます。今回は `ping` メッセージを受け取った際に `pong` とメッセージを返したいので `Bot Token Scopes` に `chat:write` を追加します。

![slackbot3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/ca6307ba-ab03-f943-0728-88f9981ccfc9.png)

この状態でページトップの `OAuth Tokens & Redirect URLs` にある `Install App to Workspace` をクリックします。
作成した App がワークスペースにインストールされるとともに、Bot 用のアクセストークンが発行されました。このアクセストークンの値も控えておきます。

![slackbot4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/3592b5c7-77c7-dff4-dddc-96ecd3917611.png)

後は Events API の設定を行う必要がありますが、こちらはある程度 Bot の実装が済んでいないと行えないのでひとまずはこれで準備完了とします。

## ローカル開発環境を用意

Bot をローカルで開発するための環境を用意します。
まず先ほど控えておいた `Signing Secret` とアクセストークンを環境変数にセットしておきます。

```console

$ export SLACK_SIGNING_SECRET=<your-signing-secret>
$ export SLACK_BOT_TOKEN=<your-bot-token>
```

次に開発用のローカルリクエスト URL を用意します。
Events API では Slack から App に対してリクエストが飛ぶので、リクエストを受け取れるようにパブリックアクセス可能な URL がなければいけません。最終的にはサーバにデプロイするので良いのですが、動作確認のためにいちいちデプロイするのは面倒です。
今回は [Slack 公式でも案内されている](https://api.slack.com/tutorials/tunneling-with-ngrok) [ngrok](https://ngrok.com/) というプロキシサービスでローカル開発マシンのポートをインターネットに公開することにします。

[こちら](https://dashboard.ngrok.com/signup) から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。

セットアップが完了すると次のようなコマンドで開発マシンのポートを外部公開することができます。

```console
$ ngrok http 8080
```

なかなか便利そうなサービスですがうっかり外部公開できないものを公開してしまわないように気を付けなければいけませんね。

_2021/01/21 追記: [Socket Mode](https://api.slack.com/apis/connections/socket) を使用することで ngrok を利用しなくても簡単にローカル開発ができそうです！_
ソケットモードの使い方に関しては次の記事で詳しく紹介されています。
[Slack ソケットモードの最も簡単な始め方](https://qiita.com/seratch/items/1a460c08c3e245b56441)
[slack-go/slack v0.8.0](https://github.com/slack-go/slack/releases/tag/v0.8.0) からサポートが追加されています。

## Bot 実装: URL 検証

いよいよ Go 言語で Slack Bot の実装を行っていきます。
Go 言語で Slack API を扱うために [slack-go/slack](https://github.com/slack-go/slack) というパッケージを利用します。こちらはもともと [nlopes/slack](https://github.com/nlopes/slack) として開発されていたものですが、リポジトリが変更されコミュニティベースの開発に移行しました。Go で Slack API を扱う際のデファクトスタンダードと言ってもよいパッケージです。

まずは Events API を有効にする際に行われる URL 検証に応答するための実装を行います。
[ドキュメント](https://api.slack.com/events-api#subscriptions) の `Request URL Configuration & Verification` に書いてある通り、Slack からのチャレンジリクエストを受信したらリクエストボディの JSON に含まれる `challenge` の値をレスポンスボディに詰めて 200 応答しなければいけません。

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


```go:main.go
package main

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

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

func main() {
	http.HandleFunc("/slack/events", 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
			}
		}
	})

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

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

```go
		eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
```

リクエストボディを Events API のイベントとして Parse しています。
第二引数に与えている [`slackevents.OptionNoVerifyToken()`](https://pkg.go.dev/github.com/slack-go/slack/slackevents?tab=doc#OptionNoVerifyToken) は Parse 時の token 検証をスキップするためのオプションです。[`slackevents.OptionVerifyToken()`](https://pkg.go.dev/github.com/slack-go/slack/slackevents?tab=doc#OptionVerifyToken) を与えるとここで Slack からのリクエストであることの検証を行えますが、この方法は非推奨となっているため今回はスキップしています。2020年3月現在推奨される方法となっている `Signing Secret` による検証は後ほど実装していきます。

```go
        switch eventsAPIEvent.Type {
        case slackevents.URLVerification:
                // ...
        }
```

Parse して得られた Events API イベントのタイプを判定して処理を分岐させています。
Events API で送信されるイベントには `CallbackEvent` (通常のイベント)、`URLVerification` (URL 検証)、`AppRateLimited` ([Rate 制限](https://api.slack.com/events-api#rate_limiting) に引っかかった際に発生するイベント) の3つがあります。この時点では URL 検証イベントだけ拾えればよいので `URLVerification` のケースだけ用意しています。

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

リクエストに含まれる `challenge` の値を取り出すためにリクエストボディを [slackevents.ChallengeResponse](https://pkg.go.dev/github.com/slack-go/slack/slackevents?tab=doc#ChallengeResponse) に JSON Unmarshal し、得られた値をそのままレスポンスに詰めています。

</div></details>

ここまで実装ができたら実際に URL 検証が成功するか試してみましょう。

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

```console
$ go run main.go
2020/03/07 23:31:46 [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
```

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

![slackbot5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/70fb0860-7245-b570-da3a-2366c2416611.png)

## Event Subscription 設定

URL 検証の動作確認で Events API を有効にできたので、Bot に送信するイベントの設定も行いましょう。
`Evnet Subscription` のページで `Subscribe to bot events` を開き、`app_mention` イベントを追加して `Save Changes` をクリックします。

![slackbot6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/cd0d8c0b-b906-31bd-70d5-22f38782f714.png)

この際、アクセストークンの権限が更新されるため、App の再インストールが必要となります。
左側のメニューから `Install App` を選択し、`Reinstall App` ボタンを押して再インストールを実行しておきましょう。

これで Bot ユーザにメンションを飛ばした際にイベントが App に送信されるようになりました。

## Bot 実装: コールバック

次は Bot の実装に戻り、 `ping` メッセージを受け取ったら `pong` メッセージを返す部分を作ります。
`main.go` を次のように修正します。

```go:main.go
package main

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

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

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

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

		switch eventsAPIEvent.Type {
		case slackevents.URLVerification:
			// 省略
			// ...

		// 追加
		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 "ping":
					if _, _, err := api.PostMessage(event.Channel, slack.MsgOptionText("pong", false)); 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)
	}
}
```

<details><summary>追加した部分についての解説</summary><div>

```go
	api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))
```

Slack API を叩くためのクライアントを初期化しています。準備段階で環境変数に設定しておいたアクセストークンを使用します。

```go
		switch eventsAPIEvent.Type {
		// ...
		case slackevents.CallbackEvent:
			// ...
		}
```

switch case に `slackevents.CallbackEvent` を追加してイベントを処理できるようにしました。

```go
			innerEvent := eventsAPIEvent.InnerEvent
			switch event := innerEvent.Data.(type) {
			case *slackevents.AppMentionEvent:
				// ...
			}
```

Events API の CallbackEvent からさらに詳細なイベント情報 (Inner Event) を取得し、Type switches でその種別を判定しています。今回は Bot に飛ばされたメンションに対して応答するので `*slackevents.AppMentionEvent` ケース内に処理を書いています。

```go
				message := strings.Split(event.Text, " ")
				if len(message) < 2 {
					w.WriteHeader(http.StatusBadRequest)
					return
				}

				command := message[1]
```

Bot 宛てに送信されたメッセージを Parse しています。
`event.Text` には `<@BOT_ID> message` のような形式でメッセージが格納されているので、スペース区切りで Split して必要な部分を取り出します。今回は `message[1]` のみを `command` として取り出していますが、サブコマンドやオプションがある場合は `options := message[2:]` のように取り出すことになると思います。

```go
				switch command {
				case "ping":
					if _, _, err := api.PostMessage(event.Channel, slack.MsgOptionText("pong", false)); err != nil {
						log.Println(err)
						w.WriteHeader(http.StatusInternalServerError)
						return
					}
				}
```

`ping` を受け取った場合に、同じチャンネルに対して `pong` を送信する処理です。

</div></details>

さて、ここまで実装ができたら再び動作確認をします。

```console
$ go run main.go
2020/03/08 01:16:44 [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
```

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

Slack クライアントで適当なチャンネルを開き、Bot ユーザをチャンネルに追加します。
Bot のユーザ名は基本的には App 名と同一ですが、記号などは省略されるようです([App Directroy の管理画面](https://slack.com/apps/manage) で変更可能です)。

```
/invite @samplebot
```

これで Bot に対して `ping` と呼びかけると `pong` と返してくれるはずです！

![slackbot7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/1ea4cfc3-8f73-1bcf-e2d3-62f10b9c1abe.png)

## Bot 実装: リクエスト検証

前項までで目標としている動作をさせるところまでは完成しましたが、まだ足りていないものがあります。
現状の実装では、Events API の仕様に沿ったリクエストであればどこから送信されたものであっても受け付けてしまいます。ChatOps などで Slack Bot を活用する際、外部からのリクエストで処理が走ってしまうのは非常に危険な状態です。
[リクエストの検証](https://api.slack.com/docs/verifying-requests-from-slack) を行い、Slack からのリクエストであることを検証しなければいけません。

`main.go` を次のように修正します。

```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"
)

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
		}

		// 省略
		// ...
	})

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

<details><summary>修正した部分についての解説</summary><div>

```go
		verifier, err := slack.NewSecretsVerifier(r.Header, os.Getenv("SLACK_SIGNING_SECRET"))
		if err != nil {
			log.Println(err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
```

リクエスト検証を行うための [`slack.SecretsVerifier`](https://pkg.go.dev/github.com/slack-go/slack?tab=doc#SecretsVerifier) を初期化しています。検証には `Signing Secret` が必要なので、準備段階で環境変数に設定しておいたものを使用しています。

```go
		bodyReader := io.TeeReader(r.Body, &verifier)
		body, err := ioutil.ReadAll(bodyReader)
```

Slack が送信する署名情報 (`X-Slack-Signature` ヘッダ) はリクエストボディを `Signing Secret` で署名(HMAC-SHA256)したものなので、検証するためにリクエストボディを `slack.SecretsVerifier` に与える必要があります。`slack.SecretsVerifier` は [`io.Writer`](https://pkg.go.dev/io?tab=doc#Writer) interface を実装しており、`Write()` メソッドを利用してリクエストボディを受け取る仕組みになっています。
そこで、リクエストボディを読み込む際に [`io.TeeReader()`](https://pkg.go.dev/io?tab=doc#TeeReader) を噛ませてあげることで読み込みと同時に `verifier` への書き込みを行うようにしています。

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

実際にリクエスト検証を実行している処理です。ここでエラーが出た場合は 400 Bad Request で応答するようにしています。

</div></details>

実際に外部からのリクエストが弾けるかを試してみましょう。
Slack の仕様に沿って偽装リクエストを curl で送信してみます。

```console
$ go run main.go
2020/03/08 02:15:47 [INFO] Server listening
```

```console
$ curl --head -H 'X-Slack-Signature:0123456789abcdef' -H "X-Slack-Request-Timestamp:$(date +%s)" localhost:8080/slack/events
HTTP/1.1 400 Bad Request
Date: Sat, 07 Mar 2020 17:24:36 GMT
```

400 になることが確認できました！

## 終わりに

これで Go 言語による Slack Bot 実装は完了です。後は適当なところにデプロイして使用します。

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

```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"
)

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 "ping":
					if _, _, err := api.PostMessage(event.Channel, slack.MsgOptionText("pong", false)); 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)
	}
}
```

~~暇だったら Interactive messages についての記事も書きたいと思います。~~

(2020/05/02 追記)
「[Go で Interactive な Slack Bot を作る (2020年5月版)](https://qiita.com/frozenbonito/items/1df9bb685e6173160991)」を投稿しました。