はじめに
この記事はCyberAgent PTA Advent Calender19日目の記事です。
W杯終わりましたね!! メッシすごい! エンバペすごい! ABEMAすごい!!
さて、所属しているABEMA AdsではSlack Botによるデプロイ管理や、非エンジニア向けツールの提供を行っており、自分も初めてインタラクティブなSlack Botを作成する機会がありました。
インタラクティブなSlack Botを作成する際には、既に非推奨になったもの1も含め、複数のAPIが提供されており、調べている際に少し戸惑った部分もあったので、具体的な実装例も含めてまとめておきます。
ゴール
以下のものを作成しながら、関連情報をまとめていきます。
- ユーザーとインタラクティブなやりとりを行い世界の主要都市の天気の情報を取得できる
- botをGoで作成します
- Slack APIのクライアントpkgとして https://github.com/slack-go/slack を使います
- 用いるAPIごとに2つの実装パターンで実装します2
- Events API + Interactive Components
- Socket Mode
- 全てのコードはこちら: https://github.com/dai65527/weather-slack-app
実装
早速実装に入ります。それぞれの特徴は実装しながら見ていきましょう。(結論を見たい方はこちら)
前準備
Slack App
何はともあれ、Slack APIのページより、Slack APPを作成します。
「Create New APP > From Scratch」と選択し、適当な名前とworkspaceを入力して作成します。
今回は2通りの実装を行うので、「お天気くん1」と「お天気くん2」の2つを作成します。
続いて、「Features > App Home > Your App’s Presence in Slack > App Display Name > Edit」より、App Display Nameを設定します。(これがないとworkspaceにインストールできないので注意)
次に、「Features > OAuth & Permissions > Scopes > Bot Token Scopes」からAppに与える権限を設定します。
今回は、@メンションで起動し、メッセージを投稿するbotになるので、「app_mentions:read」と「chat:write」の権限を与えれば十分です。この際、生成されるOauthTokenは後ほど使用します。
最後に、「Settings > Install App > Install App to Your Team > Install To Workspace」 から、Slackのworkspaceにインストールすれば、一旦準備完了です。
APIの設定等は実装パターンにより異なるので後述します。
天気の取得
今回ここはあまり気合を入れていません。(最近話題の?)世界中の都市の天気をいい感じに返してくれるサイトwttr.inがあるのでそれを使わせてもらいます。
$ curl 'http://wttr.in/東京?format=3'
東京: ⛅️ +9°C
以下のように、都市名を引数にとって、天気予報の文字列を返す関数を作成します。
func GetWeather(city string) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://wttr.in/%s?format=3", city))
if err != nil {
return "", fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("invalid http status: %d", resp.StatusCode)
}
result, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
return string(result), nil
}
Events API + Interactive Components
APIの説明
この実装パターンでは、以下のAPIを用います。
API | 説明 | 今回の利用法 | ドキュメント |
---|---|---|---|
Events API | Slack上の各種イベントが起きた場合に、イベント情報を含めて任意のURLに送信されるようにできる | アプリに対する@メンションのイベントを受け取る | https://api.slack.com/apis/connections/events-api |
Interactive Components | Botが送信したInteractive Componentsに対するActionの内容を任意のURLに送信できるようにできる | セレクトボックスへの入力の内容を受け取る | https://api.slack.com/messaging/interactivity#components |
このパターンで実装を行う場合、Botの実装は、SlackAPIが送信してくる、Events APIのEventとInteractive ComponentsからのActionに応じて何かを行う、HTTPサーバーを実装する、ということになります。
実装例
mainの実装は以下になります。
Events APIのeventを/slack/events
で受け、Interactive ComponentsのActionを/slack/interaction
で受ける構成になります。(ハンドラは別途定義)
このエンドポイントを後ほどSlack Appに登録することで、Event/Actionを受け取れるようになります。
また、HTTPのハンドラとは別に、SlackのEvent/Actionのハンドラを別途定義します。これは後述のSocket Modeでの実装の際に再利用するためとなります。
oauthToken
はxoxb-
から始まるtokenで、Appの権限と紐づいており権限設定時に取得できます(前述)
signingSecret
はApp作成時に生成されており、App設定画面の「Settings > Basic Information > App Credentials > Signing Secret」から取得できます。リクエストの検証に用います。
func main() {
// Slack Appの設定画面から取得する
oauthToken := os.Getenv("SLACK_OAUTH_TOKEN")
signingSecret := os.Getenv("SLACK_SIGNING_SECRET")
api := slack.New(oauthToken)
// SlackのEventおよびInteractionのハンドラ(再利用するため別定義)
slackHandler := slackhandler.SlackHandler{
Api: api,
}
http.Handle("/slack/events", &handler.EventHandler{
SlackHandler: &slackHandler,
SigningSecret: signingSecret,
})
http.Handle("/slack/interaction", &handler.InteractivityHandler{
SlackHandler: &slackHandler,
})
http.ListenAndServe(":8080", nil)
}
Events APIのHTTPハンドラは以下のような実装です。SigningSecretによってリクエストを検証したのち、eventに応じて処理を行うという形になります。
URLVerification EventはSlackAppの設定画面にURLを登録する際に検証用に叩かれるエンドポイントです。その他のeventの処理はslackhandler.SlackHandler
に記述しています。
type EventHandler struct {
SlackHandler *slackhandler.SlackHandler
SigningSecret string
}
func (h *EventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// リクエストの検証
sv, err := slack.NewSecretsVerifier(r.Header, h.SigningSecret)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if _, err := sv.Write(body); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := sv.Ensure(); err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
// eventをパース
event, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// URLVerification eventをhandle(EventAPI有効化時に叩かれる)
if event.Type == slackevents.URLVerification {
var r *slackevents.ChallengeResponse
err := json.Unmarshal([]byte(body), &r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text")
w.Write([]byte(r.Challenge))
}
// その他Eventのハンドリング(以下、slackhandler.SlackHandlerで定義)
if event.Type == slackevents.CallbackEvent {
err := h.SlackHandler.HandleCallBackEvent(event)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}
Eventのハンドラは以下の通りです。Botへの@メンションに応じて、都市を選択するためのセレクトボックスを送信します。
type SlackHandler struct {
Api *slack.Client
}
func (h *SlackHandler) HandleCallBackEvent(event slackevents.EventsAPIEvent) error {
innerEvent := event.InnerEvent
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
// Botへの@メンションに応じて、都市を選択するためのセレクトボックス(Block)を送信する
_, _, err := h.Api.PostMessage(ev.Channel, slack.MsgOptionBlocks(
slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: slack.PlainTextType,
Text: "どの都市の天気を調べますか?",
},
Accessory: &slack.Accessory{
SelectElement: &slack.SelectBlockElement{
ActionID: "select_city",
Type: slack.OptTypeStatic,
Placeholder: &slack.TextBlockObject{
Type: slack.PlainTextType,
Text: "都市を選択",
},
Options: []*slack.OptionBlockObject{
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "東京"}, Value: "東京"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "ソウル"}, Value: "ソウル"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "北京"}, Value: "北京"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "シドニー"}, Value: "シドニー"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "パリ"}, Value: "パリ"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "ロンドン"}, Value: "ロンドン"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "ベルリン"}, Value: "ベルリン"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "ニューヨーク"}, Value: "ニューヨーク"},
{Text: &slack.TextBlockObject{Type: slack.PlainTextType, Text: "ロサンゼルス"}, Value: "ロサンゼルス"},
},
},
},
},
))
if err != nil {
return err
}
default:
return errors.New("unknown event")
}
return nil
}
なお、セレクトボックスには、Block Kitを用いています。3
Block Kitはこのほかにも、自由入力・日付選択・チェックボックス・ラジオボタンなど多様な入力を受け付けることができるので、インタラクティブなSlack Botには欠かせない機能となります。PlayGroundがあり、構成を簡単に試せるので、実装の際はぜひ確認してみてください。
そして、ユーザからセレクトボックスへの入力が起きると、Actionが発火します。これを以下のハンドラで受けます。
基本的には、GetWeatherの結果を取得し、送信するだけですが、セレクトボックスを消して、選択内容で上書きしています。
これは、メッセージ送信時に、slack.MsgOptionReplaceOriginal(interaction.ResponseURL)
を指定することで、実現しています。
type InteractivityHandler struct {
SlackHandler *slackhandler.SlackHandler
SlackClient *slack.Client
}
func (h *InteractivityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var interaction slack.InteractionCallback
err := json.Unmarshal([]byte(r.FormValue("payload")), &interaction)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
err = h.SlackHandler.HandleInteractionCallback(interaction)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
func (h *SlackHandler) HandleInteractionCallback(interaction slack.InteractionCallback) error {
if len(interaction.ActionCallback.BlockActions) != 1 {
return errors.New("invalid request")
}
action := interaction.ActionCallback.BlockActions[0]
switch action.ActionID {
case "select_city":
weather, err := weather.GetWeather(action.SelectedOption.Value)
if err != nil {
return err
}
_, _, _, err = h.Api.SendMessage(
"",
slack.MsgOptionReplaceOriginal(interaction.ResponseURL),
slack.MsgOptionBlocks(
slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: slack.MarkdownType,
Text: "どの都市の天気を調べますか?: " + action.SelectedOption.Text.Text,
},
},
slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: slack.MarkdownType,
Text: "```\n" + weather + "```",
},
},
),
)
if err != nil {
return err
}
default:
return errors.New("unknown action")
}
return nil
}
動作確認
一通り実装が完了したので、動作確認です。
ここで、SlackAPIが叩くためのグローバルなエンドポイントを提供する必要があります。今回はローカルから動作確認を行うために、ngrokを用います。(インストール手順等は省略)
以下のコマンドでngrok宛のリクエストをローカルの8080に転送してくれます
$ ngrok http 8080
SLACK_OAUTH_TOKEN=xoxb-hogehoge \
SLACK_SIGNING_SECRET=fugafuga \
go run ./eventapi/main.go
SlackAppの設定からngrokのURL+パスを設定します。
「Features > Event Subscriptions > Enable Events」
「Features > Interactivity & Shortcuts > Interactivity > Reqeust URL」
設定がうまくいっていれば、Botが反応してくれるはずです。
Socket Mode
次に、Socket Modeを利用した実装を示します。ただ、Socket Modeの場合も大枠に変更はなく、基本的な差は通信プロトコルが異なることになります。
Events APIの実装例では、HTTPのエンドポイントを用意しましたが、Socket ModeではWebSocketを用います。
HTTPのエンドポイントの代わりに、Bot側からSlackAPIに対して、WebSocketでの接続を確立し、WebSocket経由でEvent/Actionを受け取ります。
以下にコードサンプルを示します。
起動時にWebSocketでの接続し、以降WebSocketのEventとして、Events API、Interactive ComponentsのActionを待ち続けます。Event/Actionの構造体は、共通なので、同じハンドラを流用することができます。
また、go-slack/slack
のpkgがWebSocketの接続処理をWrapしてくれているおかげでずいぶんシンプルですね。
func main() {
appToken := os.Getenv("SLACK_APP_TOKEN")
oauthToken := os.Getenv("SLACK_OAUTH_TOKEN")
api := slack.New(oauthToken, slack.OptionAppLevelToken(appToken))
client := socketmode.New(api)
slackHandler := slackhandler.SlackHandler{
Api: api,
}
go func() {
for socketEvent := range client.Events {
switch socketEvent.Type {
case socketmode.EventTypeConnecting:
fmt.Println("Connecting to Slack with Socket Mode...")
case socketmode.EventTypeConnectionError:
fmt.Println("Connection failed. Retrying later...")
case socketmode.EventTypeConnected:
fmt.Println("Connected to Slack with Socket Mode.")
case socketmode.EventTypeEventsAPI:
event, ok := socketEvent.Data.(slackevents.EventsAPIEvent)
if !ok {
continue
}
client.Ack(*socketEvent.Request)
err := slackHandler.HandleCallBackEvent(event)
if err != nil {
log.Print(err)
}
case socketmode.EventTypeInteractive:
interaction, ok := socketEvent.Data.(slack.InteractionCallback)
if !ok {
continue
}
err := slackHandler.HandleInteractionCallback(interaction)
if err != nil {
log.Print(err)
}
}
}
}()
err := client.Run()
if err != nil {
log.Print(err)
}
}
appTokenには、SlackApp設定画面の「Settings > Basic Information > App Credentials > App-Level Tokens」から生成できる、xapp-
から始まるTokenを設定します。
動作確認
起動前に設定画面「Settings > Socket Mode > Enable Socket Mode」からSocket Modeを有効にし、「Interactivity & Shortcuts」と「Event Subscriptions」を有効にします。
あとは起動するだけでbotが使えるようになります。
$ SLACK_APP_TOKEN=xapp-hogehoge \
SLACK_OAUTH_TOKEN=xoxb-fugafuga \
go run ./socketmode/main.go
Connecting to Slack with Socket Mode...
Connected to Slack with Socket Mode.
グローバルなエンドポイントを公開せずに、インタラクティブbotを実行できるのは、Socket Modeの特徴であり、利点と言えます。
まとめ
それぞれの特徴
2通り実装してみましたが、それぞれの特徴を改めてまとめるとこんな感じかなと思います。
- Events API + Interactive Component
- httpのエンドポイントを立てることでインタラクティブなやりとりを実現
- Publicなhttpのエンドポイントを立てる必要がある
- Socket Mode
- WebSocket経由でEvents APIとInteractive Componentを利用可能
- Publicなエンドポイントが用意できない場合も利用できる
ちなみにドキュメントを読んでみると、Socket Modeでも、Events API + Interactive Componentの場合と、機能的な違いはほぼないようです。
In Socket Mode, your app still uses the very same Events API and interactive components of the Slack platform. The only difference is the communication protocol.
https://api.slack.com/apis/connections/socket
どちらを使うべきか
これは個人的に思ったことも含めですが、
- 常時起動してるサーバを用いる場合 → どちらでもよさそう。Socket Modeの方が楽かも?
- ネットワーク・セキュリティ上などの都合でPublicなエンドポイントが用意できない場合 → Socket Mode一択
- Cloud Runやlambdaなどを使いたい場合: 素直にHTTPのEvents APIを使うのが良さそう。リクエストベースでインスタンスを起動するという性質上、Socket ModeでBotサーバからWebSocketコネクションを張りに行くことがまず難しそう(ごにょごにょやればいけるのかもしれないが、その手間をかける必要はどう考えてもない)
- ローカルの開発しやすさ → ngrokなど不要なので、Socket Modeが楽です
また、切り替えはAppの設定で容易に行うことができますし、実装もある程度共通化できるので、切り替えも容易だと思われますので、あまり深く考えなくても良いかもしれませんね。
参考
-
Real Time Messaging API(RTM API)を用いることも可能ですが、現在は非推奨となっており、Events APIまたはSocket Modeが推奨されています。(https://api.slack.com/rtm ) ↩
-
上記の通り、Real Time Messaging API(RTM API)は非推奨となっているため、サンプルは作成しません。新規にRTM APIをを用いるAppも作成できないようです。 ↩
-
Attachmentを用いてセレクトボックスを送信することも可能ですが、Block Kitの方が、自由入力や、日付入力等、コンポーネントが豊富であり、新規にインタラクティブなBotを開発する際はBlock Kitを採用しておいた方が幸せになれると思います。 ↩