GoでDialogsを使ったSlack Appを作る
このエントリは Go Advent Calendar 2018 12日目の記事です。
SlackにはInteractive frameworkという仕組みがあります。これによりButtonやMenuなどのインターフェースをユーザに提供することができます。
このInteractive frameworkのひとつとしてDialogsがあります。Dialogsは、Text、TextArea、Selectの要素からなるフォームを構築し、ユーザに入力してもらうことで、より複雑なワークフローを実現するSlack Appを作るのに便利な仕組みになっています。
このエントリでは、Dialogsを使ったSlack AppをGoで書くときの流れを紹介します。
サンプル
デリバリーのコーヒーを注文するCoffeebotを題材とします(実際に注文はしません)。
以下の流れで利用します。
@coffee order
でBotを呼び出しOrder Coffee
ボタンをクリック- コーヒーの注文フォームを入力
- コーヒーの種類を選択
- カスタマイズの内容を入力(オプション)
- 配達希望時間を入力
- 注文受付のメッセージを受け取る
なお、Coffeebotは以下のPythonのサンプル用のAppをカスタマイズして実装しています。
https://github.com/slackapi/python-dialog-example
また、Goで実装するにあたり、以下のリポジトリのコードをベースとして使わせていいただいています。
https://github.com/tcnksm/go-slack-interactive
Coffeebot自体のコードはこちらのリポジトリにあります。
https://github.com/shiimaxx/slack-coffeebot
Slack Appの作成
ドキュメントに手順がありますので詳しい説明は省略します。
https://api.slack.com/slack-apps
次の2点は実施しておく必要があります。
- Appに紐づくBot Userの作成
- Interactive Componentsの有効化
Bot Userは、Slackチャンネル上におけるユーザへのインタフェースとして利用します。
Interactive ComponentsはButtonやDialogを利用するために有効にする必要があります。
また、ユーザがButtonやDialogを入力した際のPOSTリクエストを受け付けるRequest URLも指定します。Request URLのドメイン名とエンドポイントは実際のアプリケーションにあわせて設定してください。
Slackライブラリ
Slackライブラリはnlopes/slack
を利用しています。
2018/12/12現在の最新リリースは、v0.4.0です。このリリースではDialogsに関するAPIはEXPERIMENTALですのでご注意ください。
また、v0.4.0以降に導入された一部structを利用したかったため、サンプルアプリケーションでは2018/12/12時点でのmasterブランチのHEAD を利用しています。
Dialogsを使ったSlack Appの処理フロー
Slack Appは次の流れで処理を行うようにします。
- ユーザからInteraction Message、もしくはSlash CommandsによるPOSTリクエストを受ける
dialog.open
APIを呼び出して、POSTリクエストのJSONデータに含まれるtrigger_id
とフォームをユーザに提供する- ユーザによるフォーム入力の完了後のPOSTリクエストを受ける
- フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する
ユーザからInteraction Message、もしくはSlash CommandsによるPOSTリクエストを受ける
Dialogsを呼び出すためには、Button, Menuもしくは/coffee
のようなSlash Commandsをトリガーにする必要があるため、まずはこれらのPOSTリクエストを受け付けるようにします。
サンプルアプリケーションのCoffeebotではButtonがトリガーとなります。
POSTリクエストはSlack AppのInteractive Componentsを有効化する際に設定したRequest URL宛に送られます。
Request URLにはDialogフォームの入力が完了した際にもPOSTリクエストが送られてきますが、これはButton, MenuともSlash CommandsともJSONデータの構造が異なります。
nlopes/slack
では、slack.InteractionCallback
という、ButtonとDialogのどちらのPOSTリクエストのJSONデータでもパースできるようになっているstructが定義されていますのでこれを利用します。
JSONデータがButtonによるものかDialogによるものかの判別は、type
フィールドの値で行なえます。
コードは次のようになります。
var message slack.InteractionCallback if err := json.Unmarshal([]byte(jsonStr), &message); err != nil { log.Print("json unmarshal message failed: ", err) w.WriteHeader(http.StatusInternalServerError) return } // : if message.Type == "interactive_message" { // Order Coffeeボタンの入力を受け付けてフォームを表示する } else if message.Type == "dialog_submission" { // フォームの入力を受け付けて何かする }
dialog.open
APIを呼び出して、POSTリクエストのJSONデータに含まれるtrigger_id
とフォームをユーザに提供する
trigger_id
はslack.InteractionCallback
型のmessage
のフィールドにあるので、これを使えばOKです。
フォームはslack.Dialog
として定義します。Coffeebotでは次のようにしています。
func makeDialog(userID string) *slack.Dialog { return &slack.Dialog{ Title: "Request a coffee", SubmitLabel: "Submit", CallbackID: userID + "coffee_order_form", Elements: []slack.DialogElement{ slack.DialogInputSelect{ DialogInput: slack.DialogInput{ Label: "Coffee Type", Type: slack.InputTypeSelect, Name: "mealPreferences", Placeholder: "Select a drink", }, Options: []slack.DialogSelectOption{ { Label: "Cappuccino", Value: "cappuccino", }, { Label: "Latte", Value: "latte", }, { Label: "Pour Over", Value: "pourOver", }, { Label: "Cold Brew", Value: "coldBrew", }, }, }, slack.DialogInput{ Label: "Customization orders", Type: slack.InputTypeTextArea, Name: "customizePreference", Optional: true, }, slack.DialogInput{ Label: "Time to deliver", Type: slack.InputTypeText, Name: "timeToDeliver", Placeholder: "hh:mm", }, }, } }
dialog.open
APIの呼び出しはslack.Client
のOpenDialogContext
(もしくはOpenDialog
)メソッドでできます。trigger_id
とフォーム(dialog
)を引数にします。
if err := s.slackClient.OpenDialogContext(context.TODO(), message.TriggerID, dialog); err != nil { log.Print("open dialog failed: ", err) w.WriteHeader(http.StatusInternalServerError) return }
ユーザによるフォーム入力の完了後のPOSTリクエストを受ける
必要に応じて入力内容のチェックをするとよいでしょう。Coffeebotでは、注文フォームで次の項目を入力します。
- コーヒーの種類を選択
- カスタマイズの内容を入力(オプション)
- 配達希望時間を入力
「コーヒーの種類を選択」は選択式なのでチェックは必要ありません。「カスタマイズの内容を入力」もTextAreaによる自由記入欄なのでチェックは不要でしょう。
「配達希望時間」については、時刻以外の入力があったり、現在時刻より前の時間を指定されると正常にオーダーを受け付けられないので、チェックしておいたほうがよさそうです。
Coffeebotでは次のような関数を用意してチェックしています。(厳密ではなかったり冗長なところもありますがご了承ください。)
var errInvalidInput = errors.New("invalid input") var errTimePreference = errors.New("time should be after 30 minutes ago") func validateTime(t string) error { const format = "15:04" parsedTime, err := time.Parse(format, t) if err != nil { return errInvalidInput } var jst = time.FixedZone("UTC+9", 9*60*60) now := time.Now().In(jst) addLocationJST := func(t time.Time) time.Time { return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), jst) } parsedTime = addLocationJST(parsedTime) if parsedTime.Before(now.Add(time.Minute * 30)) { return errTimePreference } return nil }
入力チェックで不正な値があった場合は、エラー内容をレスポンスで返す必要があります。
レスポンスは次のような形式のJSONデータで返します。また、ステータスコードは200 OK
にします。
{ "errors": [ { "name": "email_address", "error": "Sorry, this email domain is not authorized!" }, { "name": "username", "error": "Uh-oh. This username has been taken!" } ] }
Coffeebotの例です。
t := message.Submission["timeToDeliver"] if err := validateTime(t); err != nil { log.Print("validate error: ", err) type validateError struct { Name string `json:"name"` Error string `json:"error"` } type validateErrorResponse struct { Errors []validateError `json:"errors"` } w.Header().Add("Content-type", "application/json") json.NewEncoder(w).Encode(&validateErrorResponse{ []validateError{{Name: "timeToDeliver", Error: err.Error()}}, }) return }
正しくレスポンスを返すことで、次のようにフォームにエラーメッセージを表示することができます。
フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する
フォームの入力内容が問題ない場合は、200 OK
の空レスポンスによってユーザに伝える必要があります。これは3秒以内にレスポンスするか、アプリケーション側の処理に時間を要する場合はresponse_url
を利用してレスポンスを遅延して返すこともできます。
Coffeebotでは時間のかかりそうな処理は新たに起動したgoroutineで実行して、レスポンスは即時返すようにしています。このあたりはGoならではだと思います。
go func() { time.Sleep(time.Second * 5) // 注文の処理を想定したスリープ attachment := slack.Attachment{ Text: ":white_check_mark: Order received!", CallbackID: s.botID + "coffee_order_form", } options := slack.MsgOptionAttachments(attachment) if _, _, err := s.slackClient.PostMessage(message.Channel.ID, options); err != nil { log.Print("[ERROR] Failed to post message") } return }() w.Header().Add("Content-type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "")
まとめ
Dialogsを活用すればSlack上だけで色々なことが完結させられてよさそうですね。
今回は入力を受け付けてレスポンスするだけの簡単なアプリケーションでしたが、ステートを持たせるようなものを作る場合は色々と気にする点が増えるのでもう少し複雑になります。そのあたりは公式ドキュメントをご覧いただければと思います。