GolangでSlack Interactive Messageを使ったBotを書く

SREの@deeeet です。

MercariではSlack Botを使い様々な業務の自動化を行っています。例えばメインのAPIのReleaseはBotによる自動化がされており、JPとUSとUKの3拠点で1日に10回以上のReleaseをSlack上で実現しています(これ以外にも多くの事例があります)。

これまでのSlack Botは基本的には文字ベースでのやり取りが普通でした(グラフなどの画像を返答として利用することはあります)が、SlackはよりInteractiveなやりとりを実現できるInteractive Messageという仕組みも提供しています。これによりButtonによる決定やMenuによる選択といったアクションをユーザにとらせることができるようになります。

Buttonの仕組み自体は古くから提供されていましたが他のTeamへの配布が前提でありOAuthの仕組みを準備する必要があるなど利用するハードルは低くはありませんでした(Third-party Service専用という色合いが強かったように思います)。しかし最近Internal Integrationという仕組みが提供されTeam内に限定される場合にInteractive Messageを使ったBotの開発がより簡単に行えるようになりました。

Mercariでは早速この仕組を利用したBotを開発し使い始めています。本記事ではGolangを使いInteractive Messageを使ったBotの開発を書く方法を紹介します。なおサンプルコードは全て tcnksm/go-slack-interactive に公開してあるので自由にforkして自分なりのBotを開発してみて下さい(Nodeを使いたい場合は slackapi/sample-message-menus-nodeが参考になるでしょう)。

Interactive Messageの利点

そもそもInteractive MessageをBotで使う利点はなんでしょうか? Buttonを押すのが気持ちい

Buttonを使えばある処理の実行の確認を1つのButtonに集約することができます。重要な処理の場合はそのButtonを押せる人を限定することで簡単な承認フローの実現もできます。Menuを使えばBot側から選択肢を提示することができ、ユーザに自由に入力をさせるよりもより安全に期待する入力を受け付けることができます(開発側からすれば入力のValidationが楽になります)。また(特にButtonは)Mobile Appからの操作が非常に楽です。

Botを作るモチベーションとしてChannelに参加しているメンバーとのコラボレーションが挙げられますが、エンジニア以外の仲間へそのツールの利用の窓口を広げることも重要な要素だと思います(エンジニアだけならCLIツールだけで十分なことが多い)。Interactive Messageを使うことでエンジニア以外のメンバーにより直感的な使いやすいインターフェースを提供できます。

Mercariでの事例

Mercariでは以下のようなBotでInteractive Messageを利用しています。

Kubernetes Deploy Bot

f:id:deeeet:20170522091404g:plain

Mercari USでは一部のサービスをKubernetes(GKE)で動かしています(新しく作るものは基本的にKubernetes前提です)。Kubernetes Deploy Botは新しく作成されたDocker ImageをClusterにDeployします。ユーザがBotにDeployを依頼するとDeploy可能なイメージ一覧(具体的にはTag)をMenuで提示し、選択されたものをCluster上に展開します。

ちなみにこのBotはKubernetesのManifestのDocker ImageのVersionを書き換えてPRを作るところまでやるためレポジトリの設定ファイルと本番で動いているImageのVersionに乖離が発生しないようになっています。

Account Bot

f:id:deeeet:20170522092620g:plain

特定のサーバーへアクセスするためのアカウントやVPNのアカウント作成はSREが担っています。昔はSREがサーバーにログインし手動で行ってきましたが、現在はBotで自動化されています。ユーザがBotに対してアカウント作成の依頼を行うとBotがSREに対してButtonで承認を求めます。SREの承認を受けてBotはアカウントの作成を開始します。これにより自動化はされていますが誰でも好き勝手にアカウントを発行することを防いでいます。

ちなみにこのBotはGAE上で動いておりアカウント作成の依頼を受けるとJobをGCP Cloud PubSubに投げます。アカウントを実際に作成するためのWorkerはJPとUSとUKの3Regionで動いておりそれぞれ適切なTopicをSubscribeして実際の処理を行います。

作成するBotの例

以下ではInteractive Messageを使ったBotの作り方とサンプルコードの解説を行います。

今回は例としてビールを注文する@beerbotを作成します(実際には注文はしません。インターフェースのみを作成します)。Botに話しかけるとBotはMenuを使って注文可能なビールの銘柄一覧を提示してユーザに選択させます。そしてButtonを使って注文を確定、もしくはキャンセルさせることができるようにします。以下が今回作成するBotの動作例です。

f:id:deeeet:20170522084647g:plain

Slack Appの準備

では実際にBotを作成していきます。Interactive Messageを使ったBotはSlack Appとして作成する必要があります。まずはここから新しくAppを作成します。

f:id:deeeet:20170522085004p:plain

次にFeaturesの項目よりBot userを追加しInteractive Messageを有効にします。ここで専用のRequest URLが必要になります。ユーザのInteractive Messageに対するアクションの結果はこのURLにPOSTされることになります。

最後に作成したSlack AppをTeamにインストールします。これによりBotがSlack APIにアクセスするためのBot User OAuth Access TokenとInteractive Messageのリクエストをサーバー側でVerifyするためのVerification Tokenが得られます。これらの値はBotを動かすのに必要になります。これで準備は完了です。

GolangによるBotの開発

では実際にBotを書いていきます。Botに必要なのは以下の2つです。

  • SlackのEventをWatchして適切なEventに反応すること(Slack Event Listener)
  • ユーザのInteractive Messageへのアクションの結果を受け付けること(Interactive Handler)

Slack Event Listener

まずはSlackのEvent Listenerを書きます。今回の例では「@beerbot hey」という発言(Message Event)に反応してMenuをユーザに提示するようにします。SlackのAPI Clientにはnlopes/slack packageを利用します。以下のようなListenerを書いてEventをWatchし今回必要なMessageEventを待ち受けます。

func (s *SlackListener) ListenAndResponse() {
// Start listening slack events
rtm := s.client.NewRTM()
go rtm.ManageConnection()
// Handle slack events
for msg := range rtm.IncomingEvents {
switch ev := msg.Data.(type) {
case *slack.MessageEvent:
if err := s.handleMessageEvent(ev); err != nil {
log.Printf("[ERROR] Failed to handle message: %s", err)
}
}
}
}

次にMessageEventを処理する以下のような関数を書きます。

func (s *SlackListener) handleMessageEvent(ev *slack.MessageEvent) error 

関数内ではまずMessageEventのValidateを行います。最低限以下が必要でしょう。

  • 期待するChannelからのMessageであるか(botによっては反応するべきChannelを限定するべきです。例えばReleaseを行うBotはRelease Channelのみで動作するべきです)
  • Botに対するメンションであるか

次に実際の発言内容(MessageEvent.Msg.Text)をParseします。この辺りはCLIツールを書くときと同じように考えることができます。ここでのコマンドのParse結果により以下のMenuの表示などを変更します(今回の例は簡単のために単に「hey」という発言に反応するのみです)。

次に実際にMenuの表示を行います。Menuの表示にはSlack Post Message APIAttachmentフィールドを利用します。Golangで書いた場合は以下のようになります。

var attachment = slack.Attachment{
Text:       "Which beer do you want? :beer:",
Color:      "#f9a41b",
CallbackID: "beer",
Actions: []slack.AttachmentAction{
{
Name: actionSelect,
Type: "select",
Options: []slack.AttachmentActionOption{
{
Text:  "Asahi Super Dry",
Value: "Asahi Super Dry",
},
{
Text:  "Kirin Lager Beer",
Value: "Kirin Lager Beer",
},
{
Text:  "Sapporo Black Label",
Value: "Sapporo Black Label",
},
{
Text:  "Suntory Malt's",
Value: "Suntory Malts",
},
{
Text:  "Yona Yona Ale",
Value: "Yona Yona Ale",
},
},
},
{
Name:  actionCancel,
Text:  "Cancel",
Type:  "button",
Style: "danger",
},
},
}

まずAttachmentAction.Typeによりselect(Menu)かbuttonを指定します。AttachmentActionOptionのSliceがそのままMenuの選択肢として表示されます(今回はビールの銘柄を表示します)。あとはこれをSlackのPost APIでEventを受けたChannelに投稿するだけです。すると以下のようなMenuを表示することができます。

f:id:deeeet:20170522090649p:plain

f:id:deeeet:20170522090719p:plain

Interactive Handler

次にユーザのInteractive Messageに対するアクションの結果を受け取るHandlerを書きます。例えば以下のようなHandlerを書きます。

func (h interactionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
log.Printf("[ERROR] Invalid method: %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
buf, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Printf("[ERROR] Failed to read request body: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
jsonStr, err := url.QueryUnescape(string(buf)[8:])
if err != nil {
log.Printf("[ERROR] Failed to unespace request body: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
var message slack.AttachmentActionCallback
if err := json.Unmarshal([]byte(jsonStr), &message); err != nil {
log.Printf("[ERROR] Failed to decode json message from slack: %s", jsonStr)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Only accept message from slack with valid token
if message.Token != h.verificationToken {
log.Printf("[ERROR] Invalid token: %s", message.Token)
w.WriteHeader(http.StatusUnauthorized)
return
}
....
}

上の例はHandlerの前半の処理です(ここはどのInteractive Messageでも共通になると思います)。以下のような処理を行っています。

  • POSTであるかを判別する
  • PayloadをUnescapeする
  • UnescapeしたPayloadをslack.AttachmentActionCallbackにUnmarshalする
  • 得られたメッセージのTokenがSlack Appを登録した際に発行されたVerification Tokenと一致するかを確認する

あとは得られたユーザのActionの結果を処理するだけです。この部分は以下のように書けます。

func (h interactionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
....
action := message.Actions[0]
switch action.Name {
case actionSelect:
value := action.SelectedOptions[0].Value
// Overwrite original drop down message.
originalMessage := message.OriginalMessage
originalMessage.Attachments[0].Text = fmt.Sprintf("OK to order %s ?", strings.Title(value))
originalMessage.Attachments[0].Actions = []slack.AttachmentAction{
{
Name:  actionStart,
Text:  "Yes",
Type:  "button",
Value: "start",
Style: "primary",
},
{
Name:  actionCancel,
Text:  "No",
Type:  "button",
Style: "danger",
},
}
w.Header().Add("Content-type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&originalMessage)
return
case actionStart:
title := ":ok: your order was submitted! yay!"
responseMessage(w, message.OriginalMessage, title, "")
return
case actionCancel:
title := fmt.Sprintf(":x: @%s canceled the request", message.User.Name)
responseMessage(w, message.OriginalMessage, title, "")
return
default:
log.Printf("[ERROR] ]Invalid action was submitted: %s", action.Name)
w.WriteHeader(http.StatusInternalServerError)
return
}
}

ここでは得られたアクションによりそれぞれ処理を行います。アクションの判別はAttachmentAction.Nameで定義したものを利用します。今回の例では以下の3つのアクションを定義しています。

  • actionSelect – Menuの選択のアクション
  • actionStart – 注文を確定するButtonのアクション
  • actionCancel – 注文をキャンセルするButtonのアクション

例えばactionSelectの場合はユーザの選択(ビールの銘柄)を取り出し実際に注文を行ってよいかの確認を再びButtonを使って行っています。actionCancelの場合は注文がキャンセルされた旨をレスポンスとして返答しています。

Interactive Messageではユーザのアクションに対する返答は既にPostされているOriginalMessageのメッセージを上書きするように返答を行います。これは例えばButtonを押してもButtonがそのまま表示されていたら永遠にButtonを押せることになるのを防ぐためです。これを行うためには以下のような関数を準備しておくと便利です。ButtonなどのAttachmentActionを消して新たなメッセージで上書きしたレスポンスを返す事ができます。

 func responseMessage(w http.ResponseWriter, original slack.Message, title, value string) {
original.Attachments[0].Actions = []slack.AttachmentAction{} // empty buttons
original.Attachments[0].Fields = []slack.AttachmentField{
{
Title: title,
Value: value,
Short: false,
},
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&original)
}

アクションのレスポンスには必ず誰がそのアクションを行ったかを提示するのが大切です。そうしないと誰がそのアクションを行ったかを後で追えなくなるためです。今回のBotでは以下のように誰がButtonを押したかが分かるようになっています(Buttonなどを使ったInteractionのデザインに関してはGuidelines for building messagesが参考になります)。

f:id:deeeet:20170522090912p:plain

まとめ

本記事ではGolangを使いInteractive Messageを使ったBotの開発を書く方法について紹介しました。Interactive Messageを使いより使いやすいBotを開発しどんどん自動化していきましょう。

Mercariでは自動化が好きGolangが好きなSREを募集しています。6/7にSRE Drink Meetupがあります。興味がある方は是非参加して下さい〜

mercari.connpass.com