💬

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

2020/09/25に公開

どんな記事?

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

一言でいうと、複数の画面遷移を伴うアプリでも、Slack上でラクに実装できちゃう仕組み。

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

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

と、ひととおりのUIコンポーネントが揃っているので、日常使うチーム内アプリぐらいならだいたい作れるようになってます。

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

今回のサンプル

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

ざっくりと、

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

という仕様。Modalを使ったアプリの基本である

  • 呼び出し
  • トリガーメッセージ送信
  • Modal新規作成
  • Modal更新
  • バリデーション
  • 通知送信

をすべて網羅した作りになっているので、これをベースにカスタムしていただければと思います。

処理の流れ

出前アプリの処理内容を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の完成イメージはこんな感じ。

コアとなる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
}

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

Discussion