- Goで作るSlack modal applicationのサンプルコードをご紹介
- ポップアップで画面が飛び出してくるアレです
- 基本機能をほぼ入れ込みましたので、forkして自由にカスタムください!
みんな大好きChatOps。
いつも使っているチャットツールから、ササササッとプロダクトを操作するあの感覚は、一度慣れると抜けられない魅力をもっています。
多くのエンジニアにSlackが愛されているのも、こうしたChatOpsツールを簡単に作れる仕組みが充実しているからじゃないでしょうか。アンケートやスクリプトを動かすだけの簡単なアプリだったら、もうわざわざWebでUIを作らなくてもサクッと実現できちゃいますもんね。
とはいえ、やはり限界はあって、作成できるのはほぼ同じ部品の組み合わせでできたアプリのみ。UIに拘ったものは実装できない、というのが現実です。たとえば、画面が次々変わるようなインタラクティブなアプリは流石につくれません。
...と、つい最近まではそんな状況だったんですが、最近リリースされた「Modal」の登場で状況が一変。複雑な画面遷移を伴うアプリもラクに実装できるようになりました!
僕もいくつか自作のアプリをチームのSlackに組み込んでるんですが、まぁこれが便利で便利で。
- テキストボックス
- 選択リスト
- ラジオボタン
- チェックボックス
と、ひととおりのUIコンポーネントが揃ってるので、作りたいものはだいたい作れるようになりました。
そんなわけで今回は、これからModalアプリを作ってみようと考えられてるあなたに向けて、まだまだ実装例も少ないModalのサンプルコードをご紹介したいと思います。
今回のサンプル
今回作成するのは「出前アプリ」。何はともあれまずは完成形を見てみましょう。
ざっくりと、
- Botを呼んで
- お店を選ぶボタンを押すと
- ダイアログ(Modal)がポップアップするので、注文を入力
- 支払い確認画面でOKを押すと
- 完了通知が飛んでくる
という仕様。Modalを使ったアプリの基本である
- 呼び出し
- トリガーメッセージ送信
- Modal新規作成
- Modal更新
- バリデーション
- 通知送信
をすべて網羅した作りになっているので、これをベースにカスタムしていただければと思います。
AWS lambda+API GatewayのServerless構成のコードになっていますが、Slackメッセージを処理するコア部分は他のプラットフォームでも同様です。
サクッと試したい方向けにaws cdkでdeployできるようにしていますので、ぜひお試しください
処理の流れ
出前アプリの処理内容をStep別にまとめてみました。
- STEP1: Botを呼び出して、トリガーメッセージを送付
- STEP2: お店情報のModalを送付
- STEP3: 注文確認画面のModalを送付
- STEP4: 完了メッセージを送付
なかなかに長い処理となるので、まずは「やりとりは4回するんだな」くらいに覚えてもらえばOKです。
では、STEP別にみていきましょう!
STEP1 Botを呼び出して、トリガーメッセージを送付
最初のSTEPで行うのは、
- リクエストの認証(自分のWorkspaceから送付されたメッセージか)
- リクエストの認証(呼び出し元イベントがなにか)
- トリガーメッセージの送信
の3つです。
「ん?トリガーメッセージ送付?Modalじゃなくて?」と思われた方、そのとおり。はじめはModalでなくメッセージを送るんです。
なんでこんなことになっているかというと、ModalはSlackの仕様上、「Interactive message」つまり 「ボタンやセレクトボックスのついたメッセージ」からしか開けないつくりになっています。
Modalをはじめから送れないので、まずはメッセージを送る作りにしてるんですね。
また、
- はじめのBot呼び出し(STEP1)
- 以降の処理(STEP2〜4)
で、リクエストが送付されてくるURLが異なるのもポイント。サーバーサイドには受口を2つ作っておきましょう。
STEP1の完成イメージはこんな感じ。
コアとなるhandlerのソースはこちらです。(各関数の実装含めた完全版は、GitHub参照)
go_event_message/main.gofunc handleEventRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { // Verify the request. if err := verify(request, signingSecret); err != nil { log.Printf("[ERROR] Failed to verify request: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Parse event. eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(request.Body), slackevents.OptionNoVerifyToken()) if err != nil { log.Printf("[ERROR] Failed to parse request body: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Check if the request type is URL Verification. This logic is only called from slack developer's console when you set up your app. if eventsAPIEvent.Type == slackevents.URLVerification { var r *slackevents.ChallengeResponse if err := json.Unmarshal([]byte(request.Body), &r); err != nil { log.Printf("[ERROR] Failed to unmarshal json: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } return events.APIGatewayProxyResponse{Body: r.Challenge, StatusCode: 200}, nil } // Verify the request type. if eventsAPIEvent.Type != slackevents.CallbackEvent { log.Printf("[ERROR] Unexpected event type: expect = CallbackEvent , actual = %v", eventsAPIEvent.Type) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Verify the event type. switch ev := eventsAPIEvent.InnerEvent.Data.(type) { case *slackevents.AppMentionEvent: // Create a shop list. list := createShopListBySDK() // Send a shop list to slack channel. api := slack.New(tokenBotUser) if _, _, err := api.PostMessage(ev.Channel, list); err != nil { log.Printf("[ERROR] Failed to send a message to Slack: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } default: return events.APIGatewayProxyResponse{StatusCode: 200}, nil } return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
では、ポイントをみていきます。
リクエストの認証(自分のWorkspaceから送付されたメッセージか)
はじめの認証では、メッセージが自分のWorkspaceから来ていることを確認します。
slack-go/slackに認証用の関数が用意されているので、それを使いましょう。
リクエストbody、header、ポータルから発行できるSigning Secretを渡します。
この部分は、すべてのSTEPで利用するので、関数化しておくのがおすすめです。
go_event_message/main.go// verify returns the result of slack signing secret verification. func verify(request events.APIGatewayProxyRequest, sc string) error { body := request.Body header := http.Header{} for k, v := range request.Headers { header.Set(k, v) } sv, err := slack.NewSecretsVerifier(header, sc) if err != nil { return err } sv.Write([]byte(body)) return sv.Ensure() }
メッセージ認証(呼び出し元イベントがなにか)
次の認証では、リクエストがなにをトリガーに呼ばれたものかを確認します。
まずは、リクエストをParse。
go_event_message/main.go/handleEventRequest// Parse event. eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(request.Body), slackevents.OptionNoVerifyToken()) if err != nil { log.Printf("[ERROR] Failed to parse request body: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
しれっと失敗時に200OKを返していますが、ここ重要です。
Slack APIのルールでは、**リクエストを受け取れたら200OKを返してね!**と書かれているため、サーバー側のロジックで失敗した場合にも200OKを返すようにします。
意識していないと、5XXや4XXを返しがちなので注意しましょう。
さて、Parseが成功したら次は認証です。今回はBotからの呼び出しであることを確認していますが、必要に応じてロジック追加してください。
go_event_message/main.go/handleEventRequest// Verify the request type. if eventsAPIEvent.Type != slackevents.CallbackEvent { log.Printf("[ERROR] Unexpected event type: expect = CallbackEvent , actual = %v", eventsAPIEvent.Type) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Verify the event type. switch ev := eventsAPIEvent.InnerEvent.Data.(type) { case *slackevents.AppMentionEvent: // ここにトリガーメッセージ送付処理 default: return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
よくあるのは、、呼び出し元チャンネルや呼び出しユーザを確認するパターンかなあと。
特にプロダクション環境を触れるようなアプリを作る場合は、ガチガチに固めておきましょう。
トリガーメッセージ送付
最後にトリガーメッセージを送ります。
Slackが公式にGUIツール「Block Kit Builder」を用意してくれているので、そちらで見た目を確認しながら作りましょう。
go_event_message/main.go/createShopListBySDKfunc createShopListBySDK() slack.MsgOption { // Top text descText := slack.NewTextBlockObject("mrkdwn", "What do you want to have?", false, false) descTextSection := slack.NewSectionBlock(descText, nil, nil) // Divider dividerBlock := slack.NewDividerBlock() // Shops // - Hamburger hamburgerButtonText := slack.NewTextBlockObject("plain_text", "Order", true, false) hamburgerButtonElement := slack.NewButtonBlockElement("actionIDHamburger", "hamburger", hamburgerButtonText) hamburgerAccessory := slack.NewAccessory(hamburgerButtonElement) hamburgerSectionText := slack.NewTextBlockObject("mrkdwn", ":hamburger: *Hungryman Hamburgers*\nOnly for the hungriest of the hungry.", false, false) hamburgerSection := slack.NewSectionBlock(hamburgerSectionText, nil, hamburgerAccessory) // - Sushi sushiButtonText := slack.NewTextBlockObject("plain_text", "Order", true, false) sushiButtonElement := slack.NewButtonBlockElement("actionIDSushi", "sushi", sushiButtonText) sushiAccessory := slack.NewAccessory(sushiButtonElement) sushiSectionText := slack.NewTextBlockObject("mrkdwn", ":sushi: *Ace Wasabi Rock-n-Roll Sushi Bar*\nFresh raw wish and wasabi.", false, false) sushiSection := slack.NewSectionBlock(sushiSectionText, nil, sushiAccessory) // - Ramen ramenButtonText := slack.NewTextBlockObject("plain_text", "Order", true, false) ramenButtonElement := slack.NewButtonBlockElement("actionIDRamen", "ramen", ramenButtonText) ramenAccessory := slack.NewAccessory(ramenButtonElement) ramenSectionText := slack.NewTextBlockObject("mrkdwn", ":ramen: *Sazanami Ramen*\nWhy don't you try Japanese soul food?", false, false) ramenSection := slack.NewSectionBlock(ramenSectionText, nil, ramenAccessory) // Blocks blocks := slack.MsgOptionBlocks(descTextSection, dividerBlock, hamburgerSection, sushiSection, ramenSection) return blocks }
作成〜送信まで、上記の関数を使ったhandlerの実装は以下の部分です。
go_event_message/main.go/handleEventRequest// Create a shop list. list := createShopListBySDK() // Send a shop list to slack channel. api := slack.New(tokenBotUser) if _, _, err := api.PostMessage(ev.Channel, list); err != nil { log.Printf("[ERROR] Failed to send a message to Slack: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
STEP2 お店情報のModalを送付
このSTEPでは、トリガーメッセージ内のボタンが押された際に飛んでくるリクエストを受けとって、対応するお店の注文Modalを送付します。
完成イメージはこんな感じ。
ざっくりとやることは、
- リクエストの認証(自分のWorkspaceからのリクエストかどうか)
- リクエストをStructにマッピング
- リクエストタイプを判別して、適切なhandlerにDispatch
- どのお店が選択されたかの情報取得
- Modalを作成して送信
の5つです。リクエストの認証は、STEP1と同様なので、説明省きます。
先に述べた通り、STEP2〜4は、すべて同じURLにリクエストが送られてきます。今回は、途中でリクエストタイプを判別して、handlerを分岐させる作りとしてみました(handlerと呼ぶのが適切かはあやしいですが)。
コアとなるhandlerのソースはこちら。(各関数の実装含めた完全版は、GitHub参照)
go_interactive_message/main.go/handleEventRequestvar( reqButtonPushedAction = "buttonPushedAction" reqOrderModalSubmission = "orderModalSubmission" reqConfirmationModalSubmission = "confirmationModalSubmission" reqUnknown = "unknown" ) func handleInteractiveRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { // Verify the request. if err := verify(request, signingSecret); err != nil { log.Printf("[ERROR] Failed to verify request: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Parse the request payload, err := url.QueryUnescape(request.Body) if err != nil { log.Printf("[ERROR] Failed to unescape: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } payload = strings.Replace(payload, "payload=", "", 1) var message slack.InteractionCallback if err := json.Unmarshal([]byte(payload), &message); err != nil { log.Printf("[ERROR] Failed to unmarshal json: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } // Identify the request type and dispatch message to appropreate handlers. switch identifyRequestType(message) { case reqButtonPushedAction: res, err := handleButtonPushedRequest(message) if err != nil { log.Printf("[ERROR] Failed to handle button pushed action: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } return res, nil case reqOrderModalSubmission: res, err := handleOrderSubmissionRequest(message) if err != nil { log.Printf("[ERROR] Failed to handle order submission: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } return res, nil case reqConfirmationModalSubmission: res, err := handleConfirmationModalSubmissionRequest(message) if err != nil { log.Printf("[ERROR] Failed to handle confirmation modal submission: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } return res, nil default: log.Printf("[ERROR] unknown request type: %v", message.Type) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } } // identifyRequestType returns the request type of a slack message. func identifyRequestType(message slack.InteractionCallback) string { // Check if the request is button pushed message. if message.Type == slack.InteractionTypeBlockActions && message.View.Hash == "" { return reqButtonPushedAction } // Check if the request is order modal submission. if message.Type == slack.InteractionTypeViewSubmission && strings.Contains(message.View.CallbackID, reqOrderModalSubmission) { return reqOrderModalSubmission } // Check if the request is confirmation modal submission. if message.Type == slack.InteractionTypeViewSubmission && strings.Contains(message.View.CallbackID, reqConfirmationModalSubmission) { return reqConfirmationModalSubmission } return reqUnknown }
go_interactive_message/button_pushed_action.go/handleButtonPushedRequestfunc handleButtonPushedRequest(message slack.InteractionCallback) (events.APIGatewayProxyResponse, error) { // Get selected value shop := message.ActionCallback.BlockActions[0].Value switch shop { case "hamburger": // Create an order modal. // - apperance modal := createOrderModalBySDK() // - metadata : CallbackID modal.CallbackID = reqOrderModalSubmission // - metadata : ExternalID modal.ExternalID = message.User.ID + strconv.FormatInt(time.Now().UTC().UnixNano(), 10) // - metadata : PrivateMeta params := privateMeta{ ChannelID: message.Channel.ID, } bytes, err := json.Marshal(params) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal private metadata: %w", err) } modal.PrivateMetadata = string(bytes) // Send the view to slack api := slack.New(tokenBotUser) if _, err := api.OpenView(message.TriggerID, *modal); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to open modal: %w", err) } case "sushi": // ... case "ramen": // ... } return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
では、ポイントです。
リクエストをStructにマッピング
slack-go/slackに、Interactive messageをマッピングできる構造体「InteractionCallback」が用意されているので、それを使います。
bodyの先頭に余分な「payload=」がついてしまってるので、トリムしてからUnmarshalしましょう。
go_interactive_message/main.go/handleRequest// Parse the request payload, err := url.QueryUnescape(request.Body) if err != nil { log.Printf("[ERROR] Failed to unescape: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil } payload = strings.Replace(payload, "payload=", "", 1) var message slack.InteractionCallback if err := json.Unmarshal([]byte(payload), &message); err != nil { log.Printf("[ERROR] Failed to unmarshal json: %v", err) return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
リクエストタイプを判別して、適切なhandlerにDispatch
つづいて、マッピングしたmessageの中身をみて、STEP2〜4のどのリクエストなのかを判別します。
go_interactive_message/main.go/identifyRequestType// identifyRequestType returns the request type of a slack message. func identifyRequestType(message slack.InteractionCallback) string { // Check if the request is button pushed message. if message.Type == slack.InteractionTypeBlockActions && message.View.Hash == "" { return reqButtonPushedAction } // Check if the request is order modal submission. if message.Type == slack.InteractionTypeViewSubmission && strings.Contains(message.View.CallbackID, reqOrderModalSubmission) { return reqOrderModalSubmission } // Check if the request is confirmation modal submission. if message.Type == slack.InteractionTypeViewSubmission && strings.Contains(message.View.CallbackID, reqConfirmationModalSubmission) { return reqConfirmationModalSubmission } return reqUnknown }
この部分は、どんなModal、メッセージを実装したかに依存するため、適宜修正ください。
以下で判断できる、と覚えておくと便利です。
BlockAction(=ボタン押下やリスト選択)かModal Submissionか | message.Typeの値 |
---|---|
メッセージに紐づくBlockActionかModalに紐づくBlockActionか | message.View.Hashが空か |
どのModalか | message.View.CallbackIDの値 |
どのお店が選択されたかの取得
ボタンが押された場合、Slackからは「block_action」タイプのリクエストが飛んできます(後述しますが、ModalのSubmissionを押した場合は、view_submissionで飛んできます)。
block_actionの場合は、actionCallbackに選択された値が格納されているので、以下のように取り出してください。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// Get selected value shop := message.ActionCallback.BlockActions[0].Value
Modalを作成して送信
最後に、Modalを新規作成して送信します。
作り方は
- SDKで部品を生成して組み合わせる
- JSONを読み込む
の2通りです。
使い分けは、
- 動的に項目を変える必要があれば、SDK
- 決めうちの項目でよければ、JSON読み込み
がよいかなと。GUIツールのおかげで、ほぼコピペで作れるので、できるだけJSON読み込みに寄せるのがおすすめです。
サンプルコードでは、両方用意しています。
go_interactive_message/button_pushed_action.go/createOrderModalBySDK// createOrderModalBySDK makes a modal view by using slack-go/slack func createOrderModalBySDK() *slack.ModalViewRequest { // Text section shopText := slack.NewTextBlockObject("mrkdwn", ":hamburger: *Hey! Thank you for choosing us! We'll promise you to be full.*", false, false) shopTextSection := slack.NewSectionBlock(shopText, nil, nil) // Divider dividerBlock := slack.NewDividerBlock() // Input with radio buttons optHamburgerText := slack.NewTextBlockObject("plain_text", burgers["hamburger"] /*"Hamburger"*/, false, false) optHamburgerObj := slack.NewOptionBlockObject("hamburger", optHamburgerText) optCheeseText := slack.NewTextBlockObject("plain_text", burgers["cheese_burger"] /*"Cheese burger"*/, false, false) optCheeseObj := slack.NewOptionBlockObject("cheese_burger", optCheeseText) optBLTText := slack.NewTextBlockObject("plain_text", burgers["blt_burger"] /*"BLT burger"*/, false, false) optBLTObj := slack.NewOptionBlockObject("blt_burger", optBLTText) optBigText := slack.NewTextBlockObject("plain_text", burgers["big_burger"] /*"Big burger"*/, false, false) optBigObj := slack.NewOptionBlockObject("big_burger", optBigText) optKingText := slack.NewTextBlockObject("plain_text", burgers["king_burger"] /*"King burger"*/, false, false) optKingObj := slack.NewOptionBlockObject("king_burger", optKingText) menuElement := slack.NewRadioButtonsBlockElement("action_id_menu", optHamburgerObj, optCheeseObj, optBLTObj, optBigObj, optKingObj) menuLabel := slack.NewTextBlockObject("plain_text", "Which one you want to have?", false, false) menuInput := slack.NewInputBlock("block_id_menu", menuLabel, menuElement) // Input with static_select optWellDoneText := slack.NewTextBlockObject("plain_text", "well done", false, false) optWellDoneObj := slack.NewOptionBlockObject("well_done", optWellDoneText) optMediumText := slack.NewTextBlockObject("plain_text", "medium", false, false) optMediumObj := slack.NewOptionBlockObject("medium", optMediumText) optRareText := slack.NewTextBlockObject("plain_text", "rare", false, false) optRareObj := slack.NewOptionBlockObject("rare", optRareText) optBlueText := slack.NewTextBlockObject("plain_text", "blue", false, false) optBlueObj := slack.NewOptionBlockObject("blue", optBlueText) steakInputElement := slack.NewOptionsSelectBlockElement("static_select", nil, "action_id_steak", optWellDoneObj, optMediumObj, optRareObj, optBlueObj) steakLabel := slack.NewTextBlockObject("plain_text", "How do you like your steak?", false, false) steakInput := slack.NewInputBlock("block_id_steak", steakLabel, steakInputElement) // Input with plain_text_input noteText := slack.NewTextBlockObject("plain_text", "Anything else you want to tell us?", false, false) noteInputElement := slack.NewPlainTextInputBlockElement(nil, "action_id_note") noteInputElement.Multiline = true noteInput := slack.NewInputBlock("block_id_note", noteText, noteInputElement) noteInput.Optional = true // Blocks blocks := slack.Blocks{ BlockSet: []slack.Block{ shopTextSection, dividerBlock, menuInput, steakInput, noteInput, }, } // ModalView modal := slack.ModalViewRequest{ Type: slack.ViewType("modal"), Title: slack.NewTextBlockObject("plain_text", "Hungryman Hamburgers", false, false), Close: slack.NewTextBlockObject("plain_text", "Cancel", false, false), Submit: slack.NewTextBlockObject("plain_text", "Submit", false, false), Blocks: blocks, } return &modal }
JSON読み込み版
go_interactive_message/button_pushed_action.go/createOrderModalByJSON// createOrderModalByJSON makes a modal view by using JSON func createOrderModalByJSON() (*slack.ModalViewRequest, error) { // modal JOSN j := ` { "type": "modal", "submit": { "type": "plain_text", "text": "Submit", "emoji": true }, "close": { "type": "plain_text", "text": "Cancel", "emoji": true }, "title": { "type": "plain_text", "text": "Hungryman Hamburgers", "emoji": true }, "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": ":hamburger: *Hey! Thank you for choosing us! We'll promise you to be full.*" } }, { "type": "divider" }, { "type": "input", "block_id": "block_id_menu", "label": { "type": "plain_text", "text": "Which one you want to have?", "emoji": true }, "element": { "type": "radio_buttons", "action_id": "action_id_menu", "options": [ { "text": { "type": "plain_text", "text": "Hamburger", "emoji": true }, "value": "hamburger" }, { "text": { "type": "plain_text", "text": "Cheese Burger", "emoji": true }, "value": "cheese_burger" }, { "text": { "type": "plain_text", "text": "BLT Burger", "emoji": true }, "value": "blt_burger" }, { "text": { "type": "plain_text", "text": "Big Burger", "emoji": true }, "value": "big_burger" }, { "text": { "type": "plain_text", "text": "King Burger", "emoji": true }, "value": "king_burger" } ] } }, { "type": "input", "block_id": "block_id_steak", "element": { "type": "static_select", "action_id": "action_id_steak", "placeholder": { "type": "plain_text", "text": "Select ...", "emoji": true }, "options": [ { "text": { "type": "plain_text", "text": "well done", "emoji": true }, "value": "well_done" }, { "text": { "type": "plain_text", "text": "medium", "emoji": true }, "value": "medium" }, { "text": { "type": "plain_text", "text": "rare", "emoji": true }, "value": "rare" }, { "text": { "type": "plain_text", "text": "blue", "emoji": true }, "value": "blue" } ] }, "label": { "type": "plain_text", "text": "How do you like your steak? ", "emoji": true } }, { "type": "input", "block_id": "block_id_note", "label": { "type": "plain_text", "text": "Anything else you want to tell us?", "emoji": true }, "element": { "type": "plain_text_input", "action_id": "action_id_note", "multiline": true }, "optional": true } ] }` var modal slack.ModalViewRequest if err := json.Unmarshal([]byte(j), &modal); err != nil { return nil, fmt.Errorf("failed to unmarshal json: %w", err) } return &modal, nil }
さて、これで見た目は整いましたが、ここからさらにちょこっとカスタムします。
3つのメタデータ「CallbackID」「ExternalID」「PrivateMetadata」を追加しておきましょう
CallbackID
Modalの種類ごとの識別コードのイメージです。今回はリクエストタイプの判別に利用しています。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// - metadata : CallbackID modal.CallbackID = reqOrderModalSubmission
ExternalID
こちらは種類ごとではなくて、表示されたModal1つ1つの識別コードのイメージ。Workspace内で一意にしておく必要があります。
おすすめは、ユーザー名+タイムスタンプ。
これだと、よほどのことがない限り被らず安心です。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// - metadata : ExternalID modal.ExternalID = message.User.ID + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
Private Metadata
任意の値をStringで保存しておける便利なフィールドです。
Modalの通信はすべてステートレスのため、なにか次の画面に引き継ぎたい情報がある場合は、このフィールドに保管しておくのがよいと思います。
Goで利用する場合は、専用のStructを定義して、marshalして渡しておくと、のちのち使い勝手がよくおすすめ。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// - metadata : PrivateMeta params := privateMeta{ ChannelID: message.Channel.ID, } bytes, err := json.Marshal(params) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal private metadata: %w", err) } modal.PrivateMetadata = string(bytes)
ここでは、メッセージが送信されたチャンネルのIDを保管しています。
しれっとやってますが、ここが結構大事なポイントで、どのチャンネルから送信されたメッセージを起点にやりとりがはじまったかは、一番初めに送られてきたメッセージにしかついていません。
なので、のちのち通知をしたい(=チャンネルIDが必要)場合は、チャンネルIDを忘れずにPrivate Metadataとして保管して、後続に渡してあげる必要があります。
ここまでできれば、あとは送るだけ。以下のコードで送信しましょう。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// Send the view to slack api := slack.New(tokenBotUser) if _, err := api.OpenView(message.TriggerID, *modal); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to open modal: %w", err) }
STEP3 注文確認画面のModalを送る
つづいて、注文確認画面の作成と送付。先程作った注文Modalの入力情報をもとに作成します。
完成イメージはこんな感じ。
今回は、注文内容の確認だけでなく、Chipを入れるフィールドも作ってみました。せっかくなので、これを使ってバリデーションの方法も学習しておきましょう(詳細はSTEP4で!)。
このSTEPでやることは、
- リクエストの認証(自分のWorkspaceからのリクエストかどうか)
- リクエストをStructにマッピング
- 注文内容を取得
- Modalを作成して送信
の4つです。リクエストの認証と、StructにマッピングはSTEP2と同様なので省きます。
handlerのソースはこちら。(各関数の実装含めた完全版は、GitHub参照)
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequestfunc handleOrderSubmissionRequest(message slack.InteractionCallback) (events.APIGatewayProxyResponse, error) { // Get the selected information. // - radio button menu := message.View.State.Values["block_id_menu"]["action_id_menu"].SelectedOption.Value // - static_select steak := message.View.State.Values["block_id_steak"]["action_id_steak"].SelectedOption.Value // - text note := message.View.State.Values["block_id_note"]["action_id_note"].Value // Create a confirmation modal. // - apperance modal := createConfirmationModalBySDK(menu, steak, note) // - metadata : CallbackID modal.CallbackID = reqConfirmationModalSubmission // - metadata : ExternalID modal.ExternalID = message.User.ID + strconv.FormatInt(time.Now().UTC().UnixNano(), 10) // - metadata : PrivateMeta // - Get private metadata of a message var pMeta privateMeta if err := json.Unmarshal([]byte(message.View.PrivateMetadata), &pMeta); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to unmarshal private metadata: %w", err) } // - Create new private metadata params := privateMeta{ ChannelID: pMeta.ChannelID, order: order{ Menu: menu, Steak: steak, Note: note, Amount: "700", }, } pBytes, err := json.Marshal(params) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal private metadata: %w", err) } modal.PrivateMetadata = string(pBytes) // Create response resAction := slack.NewUpdateViewSubmissionResponse(modal) rBytes, err := json.Marshal(resAction) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal json: %w", err) } return events.APIGatewayProxyResponse{ StatusCode: 200, IsBase64Encoded: false, Headers: map[string]string{ "Content-Type": "application/json", }, Body: string(rBytes), }, nil }
では、ポイントです。
注文内容を取得
Modalへの入力情報は、messageのView.Stateフィールドにmap形式で格納されているので、block_id、action_idをkeyに取り出します。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest/ Get the selected information. // - radio button menu := message.View.State.Values["block_id_menu"]["action_id_menu"].SelectedOption.Value // - static_select steak := message.View.State.Values["block_id_steak"]["action_id_steak"].SelectedOption.Value // - text note := message.View.State.Values["block_id_note"]["action_id_note"].Value
input blockの種類によって、取り出し方が微妙に違うので注意。
Modalを作成して送信
Modalの作成手順は基本的にSTEP2と同様ですが、今回は注文内容に合わせて、動的に内容を変えるためSDKでの作成しかできない点に注意。
(一応強引にJSONから作成もできます。JSON内にPlace Holderを置いておいて、strings.Replaceで入力値に置き換える、だったりをすれば可能は可能です。)
go_interactive_message/order_modal_submission.go/createConfirmationModalBySDKfunc createConfirmationModalBySDK(menu, steak, note string) *slack.ModalViewRequest { // Create a modal. // - Text section titleText := slack.NewTextBlockObject("mrkdwn", ":wave: *Order confirmation*", false, false) titleTextSection := slack.NewSectionBlock(titleText, nil, nil) // Divider dividerBlock := slack.NewDividerBlock() // - Text section sMenuText := slack.NewTextBlockObject("mrkdwn", "*Menu :hamburger:*\n"+burgers[menu], false, false) sMenuTextSection := slack.NewSectionBlock(sMenuText, nil, nil) // - Text section sSteakText := slack.NewTextBlockObject("mrkdwn", "*How do you like your steak?*\n"+steak, false, false) sSteakTextSection := slack.NewSectionBlock(sSteakText, nil, nil) // - Text section sNoteText := slack.NewTextBlockObject("mrkdwn", "*Anything else you want to tell us?*\n"+note, false, false) sNoteTextSection := slack.NewSectionBlock(sNoteText, nil, nil) // - Text section amountText := slack.NewTextBlockObject("mrkdwn", "*Amount :moneybag:*\n$ 700", false, false) amountTextSection := slack.NewSectionBlock(amountText, nil, nil) // - Input with plain_text_input chipText := slack.NewTextBlockObject("plain_text", "Chip ($)", false, false) chipInputElement := slack.NewPlainTextInputBlockElement(nil, "action_id_chip") chipInput := slack.NewInputBlock("block_id_chip", chipText, chipInputElement) chipHintText := slack.NewTextBlockObject("plain_text", "Thank you for your kindness!", false, false) chipInput.Hint = chipHintText chipInput.Optional = true // Blocks blocks := slack.Blocks{ BlockSet: []slack.Block{ titleTextSection, dividerBlock, sMenuTextSection, sSteakTextSection, sNoteTextSection, dividerBlock, amountTextSection, chipInput, }, } // ModalView modal := slack.ModalViewRequest{ Type: slack.ViewType("modal"), Title: slack.NewTextBlockObject("plain_text", "Hungryman Hamburgers", false, false), Close: slack.NewTextBlockObject("plain_text", "Cancel", false, false), Submit: slack.NewTextBlockObject("plain_text", "Order!", false, false), Blocks: blocks, } return &modal }
つづいて、metadataの追加。先程と同様にCallbackID、ExternalID、Private Metadataを追加します。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest// - metadata : CallbackID modal.CallbackID = reqConfirmationModalSubmission
ExternalID
こちらもユーザーID+タイムスタンプから変化なし。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest// - metadata : ExternalID modal.ExternalID = message.User.ID + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
Private Metadata
Private Metadataには、
- チャンネルID
- 注文内容
の2つを保管しましょう。繰り返しになりますが、Modalアプリの各通信はステートレスなので、次の通信に引き継ぎたい情報はPrivate Metadataに自分で入れる必要があります。
まずは、注文ModalのPrivate Metadataを取り出します。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest// - metadata : PrivateMeta // - Get private metadata of a message var pMeta privateMeta if err := json.Unmarshal([]byte(message.View.PrivateMetadata), &pMeta); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to unmarshal private metadata: %w", err) }
次にPrivate Metadataから取り出したチャンネルIDと、message.View.State内の注文内容から新たにPrivate Metadataを生成。注文確認Modalに埋め込みましょう。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest// - Create new private metadata params := privateMeta{ ChannelID: pMeta.ChannelID, order: order{ Menu: menu, Steak: steak, Note: note, Amount: "700", }, } pBytes, err := json.Marshal(params) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal private metadata: %w", err) } modal.PrivateMetadata = string(pBytes)
ここまでできたら、あとは送信するのみ。
今回はViewの更新なので、NewUpdateViewSubmissionResponseを使って、レスポンスとして返してあげればOKです。
go_interactive_message/order_modal_submission.go/handleOrderSubmissionRequest// Create response resAction := slack.NewUpdateViewSubmissionResponse(modal) rBytes, err := json.Marshal(resAction) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal json: %w", err) } return events.APIGatewayProxyResponse{ StatusCode: 200, IsBase64Encoded: false, Headers: map[string]string{ "Content-Type": "application/json", }, Body: string(rBytes), }, nil
STEP4 完了メッセージを送信
では、いよいよ最後です。注文をうけつけたメッセージを送信します。
このSTEPでやることは、
- リクエストの認証(自分のWorkspaceからのリクエストかどうか)
- リクエストをStructにマッピング
- 注文内容を取得
- Chipをバリデーション(数値かどうか)
- メッセージを作成、送付
の5つ。だいたいやることがテンプレ化されてきましたね。
上3つは、前のSTEPとかぶるので、説明から省きます。
handlerのソースはこちらです。(各関数の実装含めた完全版は、GitHub参照)
go_interactive_message/confirmation_modal_submission.go/handleConfirmationModalSubmissionRequestfunc handleConfirmationModalSubmissionRequest(message slack.InteractionCallback) (events.APIGatewayProxyResponse, error) { // Validate a message. if err := validateChip(message); err != nil { // Create validation failed response. errors := map[string]string{ "block_id_chip": "[ERROR] Please enter a number.", } resAction := slack.NewErrorsViewSubmissionResponse(errors) bytes, err := json.Marshal(resAction) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal a validation failed message: %w", err) } return events.APIGatewayProxyResponse{ StatusCode: 200, IsBase64Encoded: false, Headers: map[string]string{ "Content-Type": "application/json", }, Body: string(bytes), }, nil } // Get private metadata var privateMeta privateMeta if err := json.Unmarshal([]byte(message.View.PrivateMetadata), &privateMeta); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to unmarshal private metadata: %w", err) } // Send a complession message. // - Create message options option, err := createOption(message, privateMeta) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to create message options: %w", err) } // - Post a message api := slack.New(tokenBotUser) if _, _, err := api.PostMessage(privateMeta.ChannelID, option); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to send a message: %w", err) } return events.APIGatewayProxyResponse{StatusCode: 200}, nil }
Chipをバリデーション(数値かどうか)
まずはChipに「数値以外」が入力されていないかを確認します。数値以外が入力されていた場合は、エラーメッセージが出るようにしてみましょう。
今回はParseFloatでerrorを返さないかで判断してみましょう。以下のような関数としてみました。
go_interactive_message/confirmation_modal_submission.go/validateChipfunc validateChip(message slack.InteractionCallback) error { // Get an input value. chip := message.View.State.Values["block_id_chip"]["action_id_chip"].Value // Chech if the value is number or not. if _, err := strconv.ParseFloat(chip, 64); err != nil { return err } return nil }
バリデーションに失敗した場合は、
- どのフィールドになんのエラーメッセージを表示するか定義したmapを作成
- NewErrorsViewSubmissionResponseでレスポンスを生成
- レスポンスとして返す
の流れでエラーメッセージを表示します。
go_interactive_message/button_pushed_action.go/handleButtonPushedRequest// Validate a message. if err := validateChip(message); err != nil { // Create validation failed response. errors := map[string]string{ "block_id_chip": "[ERROR] Please enter a number.", } resAction := slack.NewErrorsViewSubmissionResponse(errors) bytes, err := json.Marshal(resAction) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to marshal a validation failed message: %w", err) } return events.APIGatewayProxyResponse{ StatusCode: 200, IsBase64Encoded: false, Headers: map[string]string{ "Content-Type": "application/json", }, Body: string(bytes), }, nil }
メッセージを作成、送付
さて、いよいよこれで最後です。注文完了のメッセージを作成して送信します。
Private Metadataに格納して引き継ぎ続けてきた「チャンネルID」、「注文内容」はここで使います。
まずは、Private Metadataを取得しましょう。
go_interactive_message/confirmation_modal_submission.go/handleConfirmationModalSubmissionRequest// Get private metadata var privateMeta privateMeta if err := json.Unmarshal([]byte(message.View.PrivateMetadata), &privateMeta); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to unmarshal private metadata: %w", err) }
つづいてメッセージの見た目を生成。もう慣れたものですね。
go_interactive_message/confirmation_modal_submission.go/handleConfirmationModalSubmissionRequest// Send a complession message. // - Create message options option, err := createOption(message, privateMeta) if err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to create message options: %w", err) }
go_interactive_message/confirmation_modal_submission.go/createOptionfunc createOption(message slack.InteractionCallback, privateMeta privateMeta) (slack.MsgOption, error) { // Text section titleText := slack.NewTextBlockObject("mrkdwn", ":hamburger: *Thank you for your order !!*", false, false) titleTextSection := slack.NewSectionBlock(titleText, nil, nil) // Divider dividerBlock := slack.NewDividerBlock() // Text section sMenuText := slack.NewTextBlockObject("mrkdwn", "*Menu*\n"+burgers[privateMeta.Menu], false, false) sMenuTextSection := slack.NewSectionBlock(sMenuText, nil, nil) // Text section sSteakText := slack.NewTextBlockObject("mrkdwn", "*How do you like your steak?*\n"+privateMeta.Steak, false, false) sSteakTextSection := slack.NewSectionBlock(sSteakText, nil, nil) // Text section sNoteText := slack.NewTextBlockObject("mrkdwn", "*Anything else you want to tell us?*\n"+privateMeta.Note, false, false) sNoteTextSection := slack.NewSectionBlock(sNoteText, nil, nil) // Text section amount, err := strconv.ParseFloat(privateMeta.Amount, 64) if err != nil { return nil, fmt.Errorf("failed to convert amount to float64: %w", err) } chip, err := strconv.ParseFloat(message.View.State.Values["block_id_chip"]["action_id_chip"].Value, 64) if err != nil { return nil, fmt.Errorf("failed to convert amount to float64: %w", err) } amountText := slack.NewTextBlockObject("mrkdwn", "*Total amount :moneybag:*\n$ "+strconv.FormatFloat(amount+chip, 'f', 2, 64), false, false) amountTextSection := slack.NewSectionBlock(amountText, nil, nil) // Blocks blocks := slack.MsgOptionBlocks( titleTextSection, dividerBlock, sMenuTextSection, sSteakTextSection, sNoteTextSection, dividerBlock, amountTextSection, ) return blocks, nil }
最後に送信は、PostMessageです!
go_interactive_message/confirmation_modal_submission.go/handleConfirmationModalSubmissionRequest// - Post a message api := slack.New(tokenBotUser) if _, _, err := api.PostMessage(privateMeta.ChannelID, option); err != nil { return events.APIGatewayProxyResponse{StatusCode: 200}, fmt.Errorf("failed to send a message: %w", err) }
長かったですが、これがModalアプリのシンプルな例。
ソースの全量は、GitHubに置いてあるので、適宜改良して使ってみてください。
爆速で開発したいときは
さて、最後にですが、今回の例でModalアプリを作るにはSlackのAPIを理解・学習することが必須ということがおわかりいただけたかと思います。
でも、1人で趣味で作る分には、勉強だ!で済むかもしれませんが、数十人のチームで開発をしているときに、メンバー全員にこの量を覚えてもらうのは骨ですよね。
というわけで、**Slack連携アプリの開発をサポートしてくれるOSS「SlackHub」**を作りました!
具体的には、
- リクエストの認証(自分のWorkspaceからのリクエストかどうか)
- リクエストの認証(呼び出し元イベントがなにか)
- リクエストをStructにマッピング
- Modalを作成して送信
といったSlack APIを使ったやり取り部分を、すべて代行してくれます。
ツールを作るみなさんは、本当にやりたい処理部分だけを実装すればOK、というわけ!
MITライセンスで、こちらに公開していますので、ぜひご活用ください。
日本語版紹介記事はこちら。
ChatOpsを加速させる
Modalアプリが作れるようになると、ツール作成の選択肢がぐぐっと増します。
HTMLやJavascriptをゴリゴリかかなくても、GUIツールがサクッと作れるのは魅力的です。
みなさんも自分のチームの運用をラクにしちゃってください!
最後までご覧いただきありがとうございました。
Jimon(@Jimon_s)でした。