💬

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

2020/09/25に公開

STEP2 お店情報のModalを送付

このSTEPでは、トリガーメッセージ内のボタンが押された際に飛んでくるリクエストを受けとって、対応するお店の注文Modalを送付します。

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

ざっくりとやることは、

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

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

先に述べた通り、STEP2〜4は、すべて同じURLにリクエストが送られてきます。今回は、途中でリクエストタイプを判別して、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を新規作成して送信します。
作り方は

  • SDKで部品を生成して組み合わせる
  • 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</code>

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)
}

ここまでで、STEP2は終了です!

Discussion