📆

[Mattermost Integrations] Interactive Dialog

2021/06/19に公開

Mattermost 記事まとめ: https://blog.kaakaa.dev/tags/mattermost/

本記事について

Mattermost の統合機能アドベントカレンダーの第 16 日目の記事です。

本記事では、Mattermost でユーザーの入力を受け付けるダイアログを表示する Interactive Dialog の機能について紹介します。

Interactive Dialog の概要

Interactive Dialog は、Slash Command や Interactive Message などのアクションを起点に、Mattermost 上にユーザー入力を受け付けるダイアログ(モーダルウィンドウ)を表示する機能です。

official example
(画像は公式ドキュメントから)

Interactive Dialog に関する公式ドキュメントは下記になります。

Interactie Dialog は、何度か Mattermost とインタラクションをしながら動作するもののため、動作が複雑になります。今までのようにcurlだけで動作させることは難しいため、Go のコードで書いたものを断片的に紹介していきます。

今回は、Interactive Dialog の入力内容から Message Attachments のメッセージを作成するような例を考えてみます。

Trigger ID の取得

Interactive Dialog を起動するには、まず、Mattermost 内部で生成される Trigger ID というものが必要です。Trigger ID は Slash Command や Interactive Message のアクションを実行した時に、Mattermost から送信されるリクエストに含まれています。Slash Command 実行時のリクエストから Trigger ID を取得する場合、Slash Command 実行時に送信されるリクエストを処理するサーバーで、以下のように Trigger ID を取得することができます。

	http.HandleFunc("/command", func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()

		// (1) Slash Command実行時に送信されるリクエストから "Trigger ID" を取得
		triggerId := r.Form.Get("trigger_id")

		...

Interactive Message Button などのアクションから取得する際は、PostActionIntegrationRequest.TriggerIdから Trigger ID を取得できます。

Interactive Dialog の起動

先ほど取得した Trigger ID を使って、Mattermost へ Interactive Dialog 起動のリクエストを投げます。
Trigger ID を取得するコードに続けて、/api/v4/actions/dialogs/openOpenDialogRequestで定義されるリクエストを送信することで Interactive Dialog を起動することができます。

	http.HandleFunc("/command", func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()

		// (1) Slash Command実行時に送信されるリクエストから "Trigger ID" を取得
		triggerId := r.Form.Get("trigger_id")

		// (2) Interactive Dialogを起動するためのリクエストを構築
		request := model.OpenDialogRequest{
			TriggerId: triggerId,
			URL:       "http://localhost:8080/actions/dialog",
			Dialog: model.Dialog{
				Title: "Sample Interactive Dialog",
				Elements: []model.DialogElement{{
					DisplayName: "Title",
					Name:        "title",
					Type:        "text",
				}, {
					DisplayName: "Message",
					Name:        "message",
					Type:        "textarea",
				}},
			},
		}

		// (3) Interactive Dialogを開く
		b, _ := json.Marshal(request)
		req, _ := http.NewRequest(http.MethodPost, "http://localhost:8065/api/v4/actions/dialogs/open", bytes.NewReader(b))
		req.Header.Add("Content-Type", "application/json")
		resp, err := http.DefaultClient.Do(req)

		...

(2)で構築しているOpenDialogRequestにどのようなダイアログを表示するかという情報も指定するのですが、詳しくは後述します。
(3)/actions/dialogs/openにリクエストを送信していますが、ここでは AccessToken などが必要ありません。これは Trigger ID 自体の利用可能期限が 3 秒と短く、悪用の心配がないためだと思われます。この点は、Trigger ID を取得してからダイアログを開く前に時間のかかる処理などを入れないよう注意する必要があるということも意味します。

/actions/dialogs/openへのリクエストが正常に完了すると、Mattermost 上で Interactive Dialog が表示されます。

video

Interactive Dialog 起動時のパラメータ

Interactive Dialog を起動する際に送信するOpenDialogRequestに与えることができるパラメータは下記の通りです。

  • TriggerId: Slash Command や Interactive Message のアクションを実行した時に Mattermost 内部で生成される Interactive Dialog 起動用の ID を指定します
  • URL: Interactive Dialog に入力された情報の送信先 URL を指定します
  • Dialog: Interactive Dialog 上に表示される要素を指定します
    • CallbackId: 統合機能で設定される ID です。Slash Command の場合はCommandArgs.RootId、Interactive Message の場合はPostActionIntegrationRequest.PostIdを指定している気がしますが、何に使われているかはいまいちわかりません。
    • Title: Interactive Dialog のタイトル部分に表示されるテキストを指定します
    • IntroductionText: Titleの下に表示されるダイアログの説明文を指定します
    • IconURL: ダイアログに表示されるアイコンの URL を指定します
    • SubmitLabel: ダイアログの決定ボタンのラベルを指定します
    • NotifyOnCancel: ダイアログのキャンセルボタンが押された時に、サーバーにその旨を通知するかを選択します。trueの場合、キャンセル通知がサーバーに送信されます
    • State: 統語機能によって処理の状態を管理したい場合に設定される任意のフィールドです
    • Elements: ダイアログ上の入力フィールドを指定します。利用可能なElementについては公式ドキュメントを参照してください。

Interactive Dialog からのリクエスト受信

Interactive Dialog の送信ボタンが押されると、OpenDialogRequestURLフィールドに指定した URL へリクエストが送信されます。

		// (2) Interactive Dialogを起動するためのリクエストを構築
		request := model.OpenDialogRequest{
			TriggerId: triggerId,
			URL:       "http://localhost:8080/actions/dialog",
			...

送信されるリクエストは Mattermost のコードではSubmitDialogRequestとして定義されています。

type SubmitDialogRequest struct {
	Type       string                 `json:"type"`
	URL        string                 `json:"url,omitempty"`
	CallbackId string                 `json:"callback_id"`
	State      string                 `json:"state"`
	UserId     string                 `json:"user_id"`
	ChannelId  string                 `json:"channel_id"`
	TeamId     string                 `json:"team_id"`
	Submission map[string]interface{} `json:"submission"`
	Cancelled  bool                   `json:"cancelled"`
}

ユーザーが Interactive Dialog 上で入力したデータは Submission に格納されています。SubmissionOpenDialogRequest内のDialogElementNameを key、入力データを value とした map 形式のデータです。

今回の Interactive Dialog では、titlemessageというNameを持つDialogElementを指定しているため、Submissionからはこれらの値をキーとする Value が格納されています。

...
Elements: []model.DialogElement{{
	DisplayName: "Title",
	Name:        "title",
	Type:        "text",
}, {
	DisplayName: "Message",
	Name:        "message",
	Type:        "textarea",
}},
...

以上より、Interactive Dialog からのリクエストを受信し、入力内容から Message Attachment のメッセージを作るアプリケーションは以下のようになります。

...
	http.HandleFunc("/actions/dialog", func(w http.ResponseWriter, r *http.Request) {
		defer r.Body.Close()

		// (4) リクエストデータの読み出し
		b, _ := ioutil.ReadAll(r.Body)
		var payload model.SubmitDialogRequest
		json.Unmarshal(b, &payload)

		title, ok := payload.Submission["title"].(string)
		if !ok {
			resp := model.SubmitDialogResponse{Error: "failed to get title"}
			w.Header().Add("Content-Type", "application/json")
			io.WriteString(w, string(resp.ToJson()))
			return
		}
		msg, ok := payload.Submission["message"].(string)
		if !ok {
			resp := model.SubmitDialogResponse{Error: "failed to get message"}
			w.Header().Add("Content-Type", "application/json")
			io.WriteString(w, string(resp.ToJson()))
			return
		}

		// (5) Message Attachmentsインスタンス作成
		post := &model.Post{
			ChannelId: payload.ChannelId,
			Props: model.StringInterface{
				"attachments": []*model.SlackAttachment{{
					Title: title,
					Text:  msg,
				}},
			},
		}

		// (6) REST APIによるメッセージ投稿
		req, _ := http.NewRequest(http.MethodPost, "http://localhost:8065/api/v4/posts", strings.NewReader(post.ToJson()))
		req.Header.Add("Authorization", "Bearer "+MattermostAccessToken)
		req.Header.Add("Content-Type", "application/json")
		resp, err := http.DefaultClient.Do(req)

		// (7) エラー処理
		dialogResp := model.SubmitDialogResponse{}
		if err != nil {
			dialogResp.Error = err.Error()
		}
		if resp.StatusCode != http.StatusCreated {
			dialogResp.Error = fmt.Sprintf("failed to request: %s", resp.Status)
		}
		w.Header().Add("Content-Type", "application/json")
		io.WriteString(w, string(dialogResp.ToJson()))
	})
...

Interactive Dialog からのリクエストを受け取ったら、(4)でリクエストを SubmitDialogRequest形式で読み込みます。そして、SubmitDialogRequestSubmissionからtitlemessageをキーに持つ値を取得します。Submissionの Value はinterface{}型なので、文字列の場合はキャストが必要です。

データを読み出せたら (5) で、読み出したデータを使って Message Attachments を含むPostインスタンスを作成し、(6)で REST API 経由で投稿を作成しています。REST API を実行するため、Mattermost のアクセストークン(MattermostAccessToken)を事前に取得しておく必要があります。

最後に (7) で REST API の実行結果をチェックし、エラーが発生している場合はSubmitDialogResponse形式のデータを返却します。

type SubmitDialogResponse struct {
	Error  string            `json:"error,omitempty"`
	Errors map[string]string `json:"errors,omitempty"`
}

SubmitDialogResponseErrorには Interactive Dialog 全体のエラーとして表示される文字列、ErrorsにはDialogElementの要素ごとのエラーメッセージを指定します。ErrorsSubmissionと同じくDialogElementNameを key とする map 形式でエラーメッセージを指定します。

試しに、以下のようなSubmitDialogResponseを返したときの結果を紹介します。

dialogResp.Errors = map[string]string{
	"title":   "title error",
	"message": "message error",
}
dialogResp.Error = "error"
w.Header().Add("Content-Type", "application/json")
io.WriteString(w, string(dialogResp.ToJson()))

error

以上のように Interactive Dialog からのリクエストを処理できます。

さいごに

本日は、Interactive Dialog の使い方について紹介しました。
明日からは、Mattermost のプラグイン機能について紹介していきます。

Discussion