shiimaxx's blog

最も愛を大切に

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を題材とします(実際に注文はしません)。

f:id:shiimaxx:20181212021251g:plain

以下の流れで利用します。

  1. @coffee orderBotを呼び出し
  2. Order Coffeeボタンをクリック
  3. コーヒーの注文フォームを入力
    • コーヒーの種類を選択
    • カスタマイズの内容を入力(オプション)
    • 配達希望時間を入力
  4. 注文受付のメッセージを受け取る

なお、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は次の流れで処理を行うようにします。

  1. ユーザからInteraction Message、もしくはSlash CommandsによるPOSTリクエストを受ける
  2. dialog.open APIを呼び出して、POSTリクエストのJSONデータに含まれるtrigger_idとフォームをユーザに提供する
  3. ユーザによるフォーム入力の完了後のPOSTリクエストを受ける
  4. フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する

ユーザから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_idslack.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.ClientOpenDialogContext(もしくは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
}

正しくレスポンスを返すことで、次のようにフォームにエラーメッセージを表示することができます。

f:id:shiimaxx:20181212114351p:plain

フォームに入力された内容をもとに何らかの処理を行い、必要に応じてメッセージを投稿する

フォームの入力内容が問題ない場合は、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上だけで色々なことが完結させられてよさそうですね。

今回は入力を受け付けてレスポンスするだけの簡単なアプリケーションでしたが、ステートを持たせるようなものを作る場合は色々と気にする点が増えるのでもう少し複雑になります。そのあたりは公式ドキュメントをご覧いただければと思います。

参考文献