シンプルな Slack Bot を Go 言語で作ってみます。
順を追って作り方を解説していくので「サンプルコードだけ見たいよ」という方は こちら に記載されているものをご参照ください。
作るもの
下図のように Bot に対しメンションをつけて ping
と送ると pong
と返してくれるだけのシンプルなものです。
Slack からのイベント受信には Events API を使用します。同様に Slack からのイベントを受信する方法としては WebSocket でやり取りする RTM API があり、こちらであれば App 側から Slack に接続しにいくのでサーバーの公開が不要でお手軽なのですが、現在は Events API の方を使用することが推奨されているようです(こちらの記事 が参考になります)。
Slack App 作成 & 準備
まずはこちらの URL を開き、Create New App
を選択して新しい Slack App を作成します。
作成後、表示される Basic Information
のページの中程にある App Credentials
から Signing Secret
の値を控えておきましょう。
Signing Secret
は Slack App へのリクエストが Slack からのものであることを検証するために使用します。
次に、左側のメニューの OAuth & Permissions
を選択し、Slack App が Slack API を叩く際に使うアクセストークンの発行を行います。
Scopes
から発行するアクセストークンの権限を設定することができます。今回は ping
メッセージを受け取った際に pong
とメッセージを返したいので Bot Token Scopes
に chat:write
を追加します。
この状態でページトップの OAuth Tokens & Redirect URLs
にある Install App to Workspace
をクリックします。
作成した App がワークスペースにインストールされるとともに、Bot 用のアクセストークンが発行されました。このアクセストークンの値も控えておきます。
後は Events API の設定を行う必要がありますが、こちらはある程度 Bot の実装が済んでいないと行えないのでひとまずはこれで準備完了とします。
ローカル開発環境を用意
Bot をローカルで開発するための環境を用意します。
まず先ほど控えておいた Signing Secret
とアクセストークンを環境変数にセットしておきます。
$ export SLACK_SIGNING_SECRET=<your-signing-secret>
$ export SLACK_BOT_TOKEN=<your-bot-token>
次に開発用のローカルリクエスト URL を用意します。
Events API では Slack から App に対してリクエストが飛ぶので、リクエストを受け取れるようにパブリックアクセス可能な URL がなければいけません。最終的にはサーバにデプロイするので良いのですが、動作確認のためにいちいちデプロイするのは面倒です。
今回は Slack 公式でも案内されている ngrok というプロキシサービスでローカル開発マシンのポートをインターネットに公開することにします。
こちら から ngrok へのユーザ登録を行います。
登録後、セットアップ方法の案内が表示されるのでそれに従ってセットアップしていきます。
セットアップが完了すると次のようなコマンドで開発マシンのポートを外部公開することができます。
$ ngrok http 8080
なかなか便利そうなサービスですがうっかり外部公開できないものを公開してしまわないように気を付けなければいけませんね。
2021/01/21 追記: Socket Mode を使用することで ngrok を利用しなくても簡単にローカル開発ができそうです!
ソケットモードの使い方に関しては次の記事で詳しく紹介されています。
Slack ソケットモードの最も簡単な始め方
slack-go/slack v0.8.0 からサポートが追加されています。
Bot 実装: URL 検証
いよいよ Go 言語で Slack Bot の実装を行っていきます。
Go 言語で Slack API を扱うために slack-go/slack というパッケージを利用します。こちらはもともと nlopes/slack として開発されていたものですが、リポジトリが変更されコミュニティベースの開発に移行しました。Go で Slack API を扱う際のデファクトスタンダードと言ってもよいパッケージです。
まずは Events API を有効にする際に行われる URL 検証に応答するための実装を行います。
ドキュメント の Request URL Configuration & Verification
に書いてある通り、Slack からのチャレンジリクエストを受信したらリクエストボディの JSON に含まれる challenge
の値をレスポンスボディに詰めて 200 応答しなければいけません。
実装は次のようになります。
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)
}
}
コードの解説
eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
リクエストボディを Events API のイベントとして Parse しています。
第二引数に与えている slackevents.OptionNoVerifyToken()
は Parse 時の token 検証をスキップするためのオプションです。slackevents.OptionVerifyToken()
を与えるとここで Slack からのリクエストであることの検証を行えますが、この方法は非推奨となっているため今回はスキップしています。2020年3月現在推奨される方法となっている Signing Secret
による検証は後ほど実装していきます。
switch eventsAPIEvent.Type {
case slackevents.URLVerification:
// ...
}
Parse して得られた Events API イベントのタイプを判定して処理を分岐させています。
Events API で送信されるイベントには CallbackEvent
(通常のイベント)、URLVerification
(URL 検証)、AppRateLimited
(Rate 制限 に引っかかった際に発生するイベント) の3つがあります。この時点では URL 検証イベントだけ拾えればよいので 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
}
リクエストに含まれる challenge
の値を取り出すためにリクエストボディを slackevents.ChallengeResponse に JSON Unmarshal し、得られた値をそのままレスポンスに詰めています。
ここまで実装ができたら実際に URL 検証が成功するか試してみましょう。
作成した App を実行します。
$ go run main.go
2020/03/07 23:31:46 [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
Slack の App 設定ページを開き、左側のメニューから Event Subscription
を選択します。
Enable Events
を On にし、Request URL
に ngrok で得られた URL とイベントを待ち受けるパスである /slack/events
を結合したエンドポイント https://xxxxxxxx.ngrok.io/slack/events
を入力します。
次のように検証が成功したことが確認できるはずです。
Event Subscription 設定
URL 検証の動作確認で Events API を有効にできたので、Bot に送信するイベントの設定も行いましょう。
Evnet Subscription
のページで Subscribe to bot events
を開き、app_mention
イベントを追加して Save Changes
をクリックします。
この際、アクセストークンの権限が更新されるため、App の再インストールが必要となります。
左側のメニューから Install App
を選択し、Reinstall App
ボタンを押して再インストールを実行しておきましょう。
これで Bot ユーザにメンションを飛ばした際にイベントが App に送信されるようになりました。
Bot 実装: コールバック
次は Bot の実装に戻り、 ping
メッセージを受け取ったら pong
メッセージを返す部分を作ります。
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)
}
}
追加した部分についての解説
api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))
Slack API を叩くためのクライアントを初期化しています。準備段階で環境変数に設定しておいたアクセストークンを使用します。
switch eventsAPIEvent.Type {
// ...
case slackevents.CallbackEvent:
// ...
}
switch case に slackevents.CallbackEvent
を追加してイベントを処理できるようにしました。
innerEvent := eventsAPIEvent.InnerEvent
switch event := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
// ...
}
Events API の CallbackEvent からさらに詳細なイベント情報 (Inner Event) を取得し、Type switches でその種別を判定しています。今回は Bot に飛ばされたメンションに対して応答するので *slackevents.AppMentionEvent
ケース内に処理を書いています。
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:]
のように取り出すことになると思います。
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
を送信する処理です。
さて、ここまで実装ができたら再び動作確認をします。
$ go run main.go
2020/03/08 01:16:44 [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
ngrok を再実行した場合は URL が変わっているので Event Subscriptions
の Request URL
設定を変更しておきましょう。
Slack クライアントで適当なチャンネルを開き、Bot ユーザをチャンネルに追加します。
Bot のユーザ名は基本的には App 名と同一ですが、記号などは省略されるようです(App Directroy の管理画面 で変更可能です)。
/invite @samplebot
これで Bot に対して ping
と呼びかけると pong
と返してくれるはずです!
Bot 実装: リクエスト検証
前項までで目標としている動作をさせるところまでは完成しましたが、まだ足りていないものがあります。
現状の実装では、Events API の仕様に沿ったリクエストであればどこから送信されたものであっても受け付けてしまいます。ChatOps などで Slack Bot を活用する際、外部からのリクエストで処理が走ってしまうのは非常に危険な状態です。
リクエストの検証 を行い、Slack からのリクエストであることを検証しなければいけません。
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)
}
}
修正した部分についての解説
verifier, err := slack.NewSecretsVerifier(r.Header, os.Getenv("SLACK_SIGNING_SECRET"))
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
リクエスト検証を行うための slack.SecretsVerifier
を初期化しています。検証には Signing Secret
が必要なので、準備段階で環境変数に設定しておいたものを使用しています。
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
interface を実装しており、Write()
メソッドを利用してリクエストボディを受け取る仕組みになっています。
そこで、リクエストボディを読み込む際に io.TeeReader()
を噛ませてあげることで読み込みと同時に verifier
への書き込みを行うようにしています。
if err := verifier.Ensure(); err != nil {
log.Println(err)
w.WriteHeader(http.StatusBadRequest)
return
}
実際にリクエスト検証を実行している処理です。ここでエラーが出た場合は 400 Bad Request で応答するようにしています。
実際に外部からのリクエストが弾けるかを試してみましょう。
Slack の仕様に沿って偽装リクエストを curl で送信してみます。
$ go run main.go
2020/03/08 02:15:47 [INFO] Server listening
$ 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 実装は完了です。後は適当なところにデプロイして使用します。
最後に完成したコードの全体を載せておきます。
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月版)」を投稿しました。