📝

sam、lambda 、Go 、slackbot でメモ管理アプリを作る

2023/02/20に公開

概要

メモ管理アプリbot を作って、slack bot に入門します。
今回は、
lambda 、Go 、slackbot でタスク管理アプリ を ローカル環境 で利用できるまで
といった内容で作成します。 デプロイはしないのでお金は1円もかかりません。
全く実用的ではありませんが、勉強がてらに作成します。

きっかけ

slackBot の練習がしたかったため。

作るもの

メモリストの表示(メモ無し)

メモの追加


メモリストの表示(メモ有り)

メモの削除(完了)

リポジトリ

https://github.com/tokatu4561/memo-app-use-lambda

前提

・Dockerインストール済
・aws cliインストール済
・sam cliインストール済
インストール手順は下記にメモしてます。
https://zenn.dev/tokatu/articles/a005a6103f94a0

ローカルで実行できる lambda を作成

samで go テンプレートから lambda を作る

以下で雛形を作成します。

sam init --runtime go1.x --name aws-golang 

enter で進んでいくと
下記のようなディレクトリ、ファイル群が作成されます。

├── Makefile
├── README.md
├── hello-world
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── main_test.go
└── template.yaml

slack と アプリ の通信のための環境を用意

slack と ローカルで起動した アプリ間 の通信を行うために、
ngrok を使って アプリを 一時的に公開 してテストします。

下記の手順で公開できます。

  1. sam で ローカルで lamdaを起動
sam build
sam local start-api --env-vars env.json
  1. ngrok で公開
    sam で起動した ローカルの 3000番ポートが使われるので、
    ローカルの 3000 番を public にアクセスできるようにします。
ngrok http 3000

参考
https://qiita.com/miriwo/items/8c1e6550a5ab279d60b5

slack アプリの作成 と URLの検証1

  1. slack 側 のアプリ の作成し、Token などを生成 します。
  2. こちら側で用意する メモ管理する アプリケーションの URL (ngrok で 公開時に 作成された一時URL) の疎通ができるか検証する必要があります。

この2点に関しては、下記記事でまとまっているので省略。
https://qiita.com/frozenbonito/items/cf75dadce12ef9a048e9

上記を参考に実装した main.go を下記のように編集。

リクエストのjsonに格納されているchallengeというキーの値を、text形式でステータスコード200
で返す 必要があります。

package main

import (
	"encoding/json"
	"net/http"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/slack-go/slack"
	"github.com/slack-go/slack/slackevents"
)

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// slack からのリクエストかどうかを検証する
	err := Verify(ConvertHeaders(request.Headers), []byte(request.Body))
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 400}, err
	}
	
	body := request.Body
	eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 500}, err
	}

	// 受け取ったslack のイベントに応じて処理
	switch eventsAPIEvent.Type {
		case slackevents.URLVerification:
			// slack に登録する の コールバック url(こちら側で処理するアプリ側のURL) が有効かどうか確かめるため
			res, err := HandleURLVerification(body)
			if err != nil {
				return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 500}, err
			}
			return events.APIGatewayProxyResponse{
				Body:       res.Challenge,
				StatusCode: 200,
			}, nil
	}
	
	return events.APIGatewayProxyResponse{
		Body:       "",
		StatusCode: 200,
	}, nil
}

func ConvertHeaders(headers map[string]string) http.Header {
    h := http.Header{}
    for key, value := range headers {
        h.Set(key, value)
    }
    return h
}

func HandleURLVerification(body string) (*slackevents.ChallengeResponse ,error) {
	var res *slackevents.ChallengeResponse
	if err := json.Unmarshal([]byte(body), &res); err != nil {
		return nil, err
	}

	return res, nil
}

// slack からのリクエストかを検証 外部からのリクエストを受け付けないように
// ヘッダー、body、Signing Secretで検証
func Verify (header http.Header, body []byte) error {
	verifier, err := slack.NewSecretsVerifier(header, os.Getenv("SLACK_SIGNING_SECRET"))
	if err != nil {
		return err
	}

	verifier.Write(body)
	if err := verifier.Ensure(); err != nil {
		return err
	}

	return nil
}

func main() {
	lambda.Start(handler)
}

slack から メモを追加する

やること

メモの追加部分を実装します。

https://github.com/slack-go/slack

画面イメージ

実装

go.mod があるリポジトリで slack 操作用のパッケージをインストールします。
go の場合は下記のリポジトリがメジャーなようです。

go get github.com/slack-go/slack

先ほどの main.go を 下記のように編集します。

slackevents.ParseEventの戻り値でイベントを
slackevents.URLVerificationslackevents.CallbackEvent
判定して各々処理します

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	slackApi := slack.New(os.Getenv("SLACK_OAUTH_TOKEN"))

	err := Verify(ConvertHeaders(request.Headers), []byte(request.Body))
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 400}, err
	}
	
	body := request.Body
	eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 500}, err
	}

	switch eventsAPIEvent.Type {
		case slackevents.URLVerification:
			res, err := HandleURLVerification(body)
			if err != nil {
				return events.APIGatewayProxyResponse{Body: "slack conection error", StatusCode: 500}, err
			}
			return events.APIGatewayProxyResponse{
				Body:       res.Challenge,
				StatusCode: 200,
			}, nil
		// ↓ 追加したコード 
		case slackevents.CallbackEvent:
			innerEvent := eventsAPIEvent.InnerEvent
			switch event := innerEvent.Data.(type) {
			case *slackevents.AppMentionEvent:
				msg := strings.Split(event.Text, " ")
				cmd := msg[1]
				ctl := di.NewSlackMemoController()

				switch cmd {
				case "memo":
					// 新しくメモを追加する ここは使用するDB に合わせて自由に変更してください
					memo, err := ctl.CreateMemo(msg[2])
					if err != nil {
						return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
					}

					responseMsg := fmt.Sprintf("%sを追加しました!", memo.Title)
					_, _, err = slackApi.PostMessage(event.Channel, slack.MsgOptionText(responseMsg, false))
					if err != nil {
						return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
					}
				}
			}
			// ↑ 追加したコード
	}
	
	return events.APIGatewayProxyResponse{
		Body:       "",
		StatusCode: 200,
	}, nil
}

slack で 保存されている全てのメモを取得

やること

DB から 保存されている メモのリストを 全て取得し
1件づつ、slack に ボタン式のメッセージを送信します。

今回は ボタン式の メッセージで メモの 完了継続 を表現します。
下記の リポジトリ で 他にも色々な メッセージタイプ の実装例があるので参考にできます。
https://github.com/slack-go/slack

画面イメージ

実装

switch event := innerEvent.Data.(type) {
	case *slackevents.AppMentionEvent:
		msg := strings.Split(event.Text, " ")
		cmd := msg[1]
		ctl := di.NewSlackMemoController()

		switch cmd {
		case "memo":
			memo, err := ctl.CreateMemo(msg[2])
			if err != nil {
				return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
			}

			responseMsg := fmt.Sprintf("%sを追加しました!", memo.Title)
			_, _, err = slackApi.PostMessage(event.Channel, slack.MsgOptionText(responseMsg, false))
			if err != nil {
				return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
			}
		// ↓ 追加したコード 
		case "list":
			// 保存されているメモのリストを通知する

			// 全てのメモを取得 DB に合わせて実装してください
			memos, err := ctl.GetMemos()
			if err != nil {
				return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
			}
			
			if (len(memos) == 0) {
				_, _, err = slackApi.PostMessage(event.Channel, slack.MsgOptionText("メモがありません。", false))
			}

			// slack へメモのリストを 1つづつ通知
			for _, memo := range memos {
				attachment := slack.Attachment{
					Pretext:    "pretext",
					Fallback:   "We don't currently support your client",
					CallbackID: "accept_or_reject",
					Color:      "#3AA3E3",
					Actions: []slack.AttachmentAction{
						{
							Name:  "continue",
							Text:  "継続",
							Type:  "button",
							Value: memo.ID,
						},
						{
							Name:  "complete",
							Text:  "完了",
							Type:  "button",
							Value: memo.ID,
							Style: "success",
						},
					},
				}
				message := slack.MsgOptionAttachments(attachment)
				_, _, err = slackApi.PostMessage(event.Channel, slack.MsgOptionText("", false), message)
			}

			if err != nil {
				return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
			}
		}
	// ↑ 追加したコード 
	}

slack で 保存されているメモをリストから削除する

やること

  • slack 側で メモの 完了継続 ボタンの内、
    完了 を押した際に、対象のメモを DB から 削除します。

  • 削除後、 slack の表示側では 削除対象のメッセージを
    リストから削除しました! と編集するようにします。

画面イメージ

実装

  1. slack アプリケーションに Interactive メッセージ用の エンドポイントを追加

slack からの ボタンのメッセージに 反応するエンドポイントを
slack アプリケーションに登録します。

Interactivity & Shortcuts メニューの
Request URL に 登録したエンドポイントに slack からのリクエストが飛びます。

  1. slack から のリクエストを処理する lambda を作成
    template.yaml に 新しく lambda 関数の 定義を追加します。
SlackInteractiveFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: slack-bot-memo-interactive/
      Handler: slack-bot-memo-interactive
      Runtime: go1.x
      Architectures:
        - x86_64
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: /slack-bot/interactive
            Method: POST
      Environment:
        Variables:
          SLACK_OAUTH_TOKEN: !Ref SlackOauthToken
          SLACK_SIGNING_SECRET: !Ref SlackSigingSecret
  1. リクエストを処理する lambda の実装
    template.yamlCodeUrislack-bot-memo-interactive をしたので指定した、
    slack-bot-memo-interactive/ 配下に 新たに main.go を 作成します。

application/x-www-form-urlencoded形式でリクエストが飛んでくるので、
ローカルのアプリ(go)側 で エンコードして処理します。

リクエストの body の詳細な内容に関しては、
slack の公式か、下記の和訳記事があり、参考になります。
https://qiita.com/hypermkt/items/b2ffaf610ac92235c4d6

package main

import (
	"encoding/json"
	"fmt"
	"net/url"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/slack-go/slack"
	"github.com/tokatu4561/memo-app-use/pkg/application/di"
)

type Actions struct {
	Name 	   string `json:"name"`
	Value 	   string `json:"value"`
	ActionType string `json:"type"`
}

type MessageActionPayload struct {
	Actions    []*Actions `json:"actions"`
	CallbackId string  `json:"callback_id"`
	Channel struct {
		Id   string `json:"id"`
		Name string `json:"name"`
	} `json:"channel"`
	User struct {
		Id   string `json:"id"`
		Name string `json:"name"`
	} `json:"User"`
	ActionTs 	 string `json:"action_ts"`
	MessageTs 	 string `json:"message_ts"`
	AttachmentId string `json:"attachment_id"`
}

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	slackApi := slack.New(os.Getenv("SLACK_OAUTH_TOKEN"))

	// body を json にエンコード
	q, _ := url.ParseQuery(request.Body)
	qPayload := q.Get("payload")

	// JSONを struct に読み取る
	var payload MessageActionPayload
	err := json.Unmarshal([]byte(qPayload), &payload)
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
	}

	// continue 継続であれば削除処理 せずに レスポンス返す
	if (payload.Actions[0].Name == "continue") {
		return events.APIGatewayProxyResponse{Body: "", StatusCode: 200 }, nil
	}
	
	// 対象のメモを削除する 使用するDBに合わせて 実装してください。
	ctl := di.NewSlackMemoController()
	ctl.DeleteMemo(payload.Actions[0].Value)

	// 削除したことをslackのチャンネルに通知する 対象のメッセージに対しを編集する
	responseMsg := fmt.Sprintf("リストから削除しました!")
	_, _, _, err = slackApi.UpdateMessage(payload.Channel.Id, payload.MessageTs, slack.MsgOptionText(responseMsg, false))
	if err != nil {
		return events.APIGatewayProxyResponse{Body: "bad request", StatusCode: 400}, err
	}
	
	return events.APIGatewayProxyResponse{
		Body:       "",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}

詰まったところ

  1. lambda から dynamodb に接続できない
    自分は ローカルで検証する用の db に dynamodb ローカル を使用していたのですが、
    sam で起動する docker で動くlambda とdynamodDb が同じネットワーク内ある必要がありました。

docker network create で docker 上に ネットワークを作成し、
dynamodbコンテナの ネットワークを指定

dynamodb:
        image: amazon/dynamodb-local
        command: -jar DynamoDBLocal.jar -sharedDb -dbPath . -optimizeDbBeforeStartup
        volumes:
            - dynamodb:/home/dynamodblocal
        ports:
            - 8000:8000
        networks:
            - lambda-local

local start-api 時に --docker-networkで 指定 する必要がありました。

sam local start-api --env-vars env.json --docker-network lambda-local

参考
https://future-architect.github.io/articles/20200323/

  1. slack のリクエストを json で受け取れない
    slack の interactive メッセージで アプリ側に リクエストを投げてくれるのですが
    application/x-www-form-urlencoded形式 のJSON文字列 でした。
    application/json 形式だと思って、 普通に json を読み取ろうとすると、失敗します。

調べてみると
api gatway で json に変換 して lambda に渡す ような方法が多く見つかりました。
sam template だと api gatway リソースに 下記 を追加すると良さそうでした。

 x-amazon-apigateway-integration:
                responses:
                  default:
                    statusCode: 200
                httpMethod: POST
                type: aws_proxy
                contentHandling: CONVERT_TO_TEXT
                requestTemplates:
                  application/x-www-form-urlencoded: "{\"body\": $util.urlDecode($input.json(\"$\"))}"

参考
https://xp-cloud.jp/blog/2017/11/02/2110/
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html

しかし、sam で定義した リソースは基本的に lambda 関数しか検証できません。
今回は ローカルで試してみたく、
sam local start-api で 起動した lambda では
api gatway を通した 検証は 行えません。

記事で書いたように go 側で エンコード する対応としました。

感想

slackbotへの理解が深まって良かった

Discussion