Goで作るSlack Modal Applicationサンプルコード

Goで作るSlack Modal Applicationサンプルコード

サンプルコード

どんな記事?
  • Goで作るSlack modal applicationのサンプルコードをご紹介
  • ポップアップで画面が飛び出してくるアレです
  • 基本機能をほぼ入れ込みましたので、forkして自由にカスタムください!

みんな大好きChatOps。
いつも使っているチャットツールから、ササササッとプロダクトを操作するあの感覚は、一度慣れると抜けられない魅力をもっています。

多くのエンジニアにSlackが愛されているのも、こうしたChatOpsツールを簡単に作れる仕組みが充実しているからじゃないでしょうか。アンケートやスクリプトを動かすだけの簡単なアプリだったら、もうわざわざWebでUIを作らなくてもサクッと実現できちゃいますもんね。

とはいえ、やはり限界はあって、作成できるのはほぼ同じ部品の組み合わせでできたアプリのみ。UIに拘ったものは実装できない、というのが現実です。たとえば、画面が次々変わるようなインタラクティブなアプリは流石につくれません。

…と、つい最近まではそんな状況だったんですが、最近リリースされた「Modal」の登場で状況が一変。複雑な画面遷移を伴うアプリもラクに実装できるようになりました!

僕もいくつか自作のアプリをチームのSlackに組み込んでるんですが、まぁこれが便利で便利で。

  • テキストボックス
  • 選択リスト
  • ラジオボタン
  • チェックボックス

と、ひととおりのUIコンポーネントが揃ってるので、作りたいものはだいたい作れるようになりました。

そんなわけで今回は、これからModalアプリを作ってみようと考えられてるあなたに向けて、まだまだ実装例も少ないModalのサンプルコードをご紹介したいと思います。

スポンサーリンク


今回のサンプル

今回作成するのは「出前アプリ」。何はともあれまずは完成形を見てみましょう。

DEMO

ざっくりと、

  1. Botを呼んで
  2. お店を選ぶボタンを押すと
  3. ダイアログ(Modal)がポップアップするので、注文を入力
  4. 支払い確認画面でOKを押すと
  5. 完了通知が飛んでくる

という仕様。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で行うのは、

  1. リクエストの認証(自分のWorkspaceから送付されたメッセージか)
  2. リクエストの認証(呼び出し元イベントがなにか)
  3. トリガーメッセージの送信

の3つです。

「ん?トリガーメッセージ送付?Modalじゃなくて?」と思われた方、そのとおり。はじめはModalでなくメッセージを送るんです。

なんでこんなことになっているかというと、ModalはSlackの仕様上、「Interactive message」つまり「ボタンやセレクトボックスのついたメッセージ」からしか開けないつくりになっています。

Modalをはじめから送れないので、まずはメッセージを送る作りにしてるんですね。

また、

  • はじめのBot呼び出し(STEP1)
  • 以降の処理(STEP2〜4)

で、リクエストが送付されてくるURLが異なるのもポイント。サーバーサイドには受口を2つ作っておきましょう。

STEP1の完成イメージはこんな感じ。

STEP1完成イメージ

コアとなるhandlerのソースはこちらです。(各関数の実装含めた完全版は、GitHub参照)

func 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で利用するので、関数化しておくのがおすすめです。

// 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。

// 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からの呼び出しであることを確認していますが、必要に応じてロジック追加してください。

// 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」を用意してくれているので、そちらで見た目を確認しながら作りましょう。

func 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の実装は以下の部分です。

// 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を送付します。

完成イメージはこんな感じ。

STEP2完成イメージ

ざっくりとやることは、

  • リクエストの認証(自分のWorkspaceからのリクエストかどうか)
  • リクエストをStructにマッピング
  • リクエストタイプを判別して、適切なhandlerにDispatch
  • どのお店が選択されたかの情報取得
  • Modalを作成して送信

の5つです。リクエストの認証は、STEP1と同様なので、説明省きます。

先に述べた通り、STEP2〜4は、すべて同じURLにリクエストが送られてきます。今回は、途中でリクエストタイプを判別して、handlerを分岐させる作りとしてみました(handlerと呼ぶのが適切かはあやしいですが)。

コアとなるhandlerのソースはこちら。(各関数の実装含めた完全版は、GitHub参照)

var(
    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
}
func 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しましょう。

// 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のどのリクエストなのかを判別します。

// 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に選択された値が格納されているので、以下のように取り出してください。

// Get selected value
shop := message.ActionCallback.BlockActions[0].Value

Modalを作成して送信

最後に、Modalを新規作成して送信します。
作り方は

  1. SDKで部品を生成して組み合わせる
  2. JSONを読み込む

の2通りです。

使い分けは、

  • 動的に項目を変える必要があれば、SDK
  • 決めうちの項目でよければ、JSON読み込み

がよいかなと。GUIツールのおかげで、ほぼコピペで作れるので、できるだけJSON読み込みに寄せるのがおすすめです。

サンプルコードでは、両方用意しています。

SDKでイチから生成版
// 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読み込み版
// 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の種類ごとの識別コードのイメージです。今回はリクエストタイプの判別に利用しています。

// - metadata : CallbackID
modal.CallbackID = reqOrderModalSubmission
ExternalID

こちらは種類ごとではなくて、表示されたModal1つ1つの識別コードのイメージ。Workspace内で一意にしておく必要があります。

おすすめは、ユーザー名+タイムスタンプ。
これだと、よほどのことがない限り被らず安心です。

// - metadata : ExternalID
modal.ExternalID = message.User.ID + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
Private Metadata

任意の値をStringで保存しておける便利なフィールドです。

Modalの通信はすべてステートレスのため、なにか次の画面に引き継ぎたい情報がある場合は、このフィールドに保管しておくのがよいと思います。

Goで利用する場合は、専用のStructを定義して、marshalして渡しておくと、のちのち使い勝手がよくおすすめ。

// - 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として保管して、後続に渡してあげる必要があります。

ここまでできれば、あとは送るだけ。以下のコードで送信しましょう。

// 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の入力情報をもとに作成します。

完成イメージはこんな感じ。

STEP3完成イメージ

今回は、注文内容の確認だけでなく、Chipを入れるフィールドも作ってみました。せっかくなので、これを使ってバリデーションの方法も学習しておきましょう(詳細はSTEP4で!)。

このSTEPでやることは、

  • リクエストの認証(自分のWorkspaceからのリクエストかどうか)
  • リクエストをStructにマッピング
  • 注文内容を取得
  • Modalを作成して送信

の4つです。リクエストの認証と、StructにマッピングはSTEP2と同様なので省きます。

handlerのソースはこちら。(各関数の実装含めた完全版は、GitHub参照)

func 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に取り出します。

// 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で入力値に置き換える、だったりをすれば可能は可能です。)

func 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を追加します。

CallbackID
// - metadata : CallbackID
modal.CallbackID = reqConfirmationModalSubmission
ExternalID

こちらもユーザーID+タイムスタンプから変化なし。

// - 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を取り出します。

// - 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に埋め込みましょう。

//   - 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です。

// 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 完了メッセージを送信

では、いよいよ最後です。注文をうけつけたメッセージを送信します。

STEP4完成イメージ

このSTEPでやることは、

  • リクエストの認証(自分のWorkspaceからのリクエストかどうか)
  • リクエストをStructにマッピング
  • 注文内容を取得
  • Chipをバリデーション(数値かどうか)
  • メッセージを作成、送付

の5つ。だいたいやることがテンプレ化されてきましたね。

上3つは、前のSTEPとかぶるので、説明から省きます。

handlerのソースはこちらです。(各関数の実装含めた完全版は、GitHub参照)

func 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を返さないかで判断してみましょう。以下のような関数としてみました。

func 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でレスポンスを生成
  • レスポンスとして返す

の流れでエラーメッセージを表示します。

// 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を取得しましょう。

// 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)
}
func 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です!

// - 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に置いてあるので、適宜改良して使ってみてください。

爆速で開発したいときは

ChatOpsを加速させる!Slack連携アプリをラクに実装するためのOSS「SlackHub」をリリースしました

さて、最後にですが、今回の例でModalアプリを作るにはSlackのAPIを理解・学習することが必須ということがおわかりいただけたかと思います。

でも、1人で趣味で作る分には、勉強だ!で済むかもしれませんが、数十人のチームで開発をしているときに、メンバー全員にこの量を覚えてもらうのは骨ですよね。

というわけで、Slack連携アプリの開発をサポートしてくれるOSS「SlackHub」を作りました!

具体的には、

  • リクエストの認証(自分のWorkspaceからのリクエストかどうか)
  • リクエストの認証(呼び出し元イベントがなにか)
  • リクエストをStructにマッピング
  • Modalを作成して送信

といったSlack APIを使ったやり取り部分を、すべて代行してくれます。

ツールを作るみなさんは、本当にやりたい処理部分だけを実装すればOK、というわけ!

MITライセンスで、こちらに公開していますので、ぜひご活用ください。

日本語版紹介記事はこちら。

ChatOpsを加速させる

Modalアプリが作れるようになると、ツール作成の選択肢がぐぐっと増します。

HTMLやJavascriptをゴリゴリかかなくても、GUIツールがサクッと作れるのは魅力的です。

みなさんも自分のチームの運用をラクにしちゃってください!

最後までご覧いただきありがとうございました。
Jimon(@jimon_s)でした。

-サンプルコード

関連記事



CATEGORY
更新チェックはこちらから!