Goで作るSlack Modal Applicationサンプルコード 2/4
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