Help us understand the problem. What is going on with this article?

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

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

作るもの

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

slackbot1.png

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 からのものであることを検証するために使用します。

slackbot2.png

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

slackbot3.png

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

slackbot4.png

後は 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

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

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 応答しなければいけません。

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

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)
    }
}

コードの解説
        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 を入力します。
次のように検証が成功したことが確認できるはずです。

slackbot5.png

Event Subscription 設定

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

slackbot6.png

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

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

Bot 実装: コールバック

次は Bot の実装に戻り、 ping メッセージを受け取ったら pong メッセージを返す部分を作ります。
main.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)
    }
}

追加した部分についての解説
    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 SubscriptionsRequest URL 設定を変更しておきましょう。

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

/invite @samplebot

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

slackbot7.png

Bot 実装: リクエスト検証

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

main.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)
    }
}

修正した部分についての解説
        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.SecretsVerifierio.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月版)」を投稿しました。

bandainamcostudios
バンダイナムコスタジオは、家庭用ゲームソフト、モバイルコンテンツ、の企画・開発・運営、ゲームに関する技術研究・開発を行っている会社です。
https://www.bandainamcostudios.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした