LINEに投稿した写真をS3に保存していく

この記事は[ぷりぷりあぷりけーしょんず Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ppap)の9日目の記事です。 

# はじめに
現在個人でLINEMessagingAPIを使ったアプリ開発をしており、メッセージから画像を取得しAWS S3に保存していく、という工程をAWS Lambdaで実装したのでその話をしていきます。

ちなみに今回LambdaのRuntimeはGoで書いたのですが当方Goでちゃんと実装するのは初めてのコードになるので見にくい箇所もあると思いますが、もし心優しい方いましたら丸めの鉞（指摘）を頂けますと幸いです。

# 使用技術
LINE Messaging API
AWS Lambda 
AWS APIGateWay
AWS S3
Go 1.13

# 設計

![Untitled Diagram.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227898/0af34db7-b8bf-6541-a259-01f00ab73384.jpeg)

# 実装

実際に実装している内容丸々、というよりは個人的に大変だった話を備忘録兼ねて記載していきます。

## APIGatewayProxyRequestをline-bot-sdk-go/linebot.Eventにパース

今回LINEMessagingAPIを使うにあたって[line-bot-sdk-go](https://github.com/line/line-bot-sdk-go)を使用しました。
このライブラリ内の`webhook.go`にはLINE上で発生したイベント情報をhttpRequestからパースして`linebot.Event`を取り出してくれる便利メソッドが提供されています。

```go:webhook.go
// ParseRequest func
func ParseRequest(channelSecret string, r *http.Request) ([]*Event, error) {
	defer r.Body.Close()
	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}
	if !validateSignature(channelSecret, r.Header.Get("X-Line-Signature"), body) {
		return nil, ErrInvalidSignature
	}

	request := &struct {
		Events []*Event `json:"events"`
	}{}
	if err = json.Unmarshal(body, request); err != nil {
		return nil, err
	}
	return request.Events, nil
}
```

しかし、残念ながら今回はAPIGatewayを使っているため`body`などの情報は[github.com/aws/aws-lambda-go/events.APIGatewayProxyRequest](https://github.com/aws/aws-lambda-go/blob/master/events/apigw.go)が持っている形になります。

今回は`line-bot-sdk-go/linebot/webhook.go`を参考に`events.APIGatewayProxyRequest`からイベント情報をパースする処理をラップするような形で実装しました。
LINEMessagingAPIのWebhook対象をAPIGatewayに設定してLambdaで実装するケースは多そうなので汎用的に使えそうです。（今回のユースケースではイベント情報の最初の情報だけ取得できれば良く、そのあとの実装で意識したくなかったので`request.Events[0]`を返しています）

```go:org-line.go
package infrastructure

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/line/line-bot-sdk-go/linebot"
)

var (
	LineChannelSecret      = os.Getenv("LINE_CHANNEL_SECRET")
	LineChannelAccessToken = os.Getenv("LINE_CHANNEL_ACCESS_TOKEN")
)

type Line interface {
	GetClient() (*linebot.Client, error)
	ParseRequest(r events.APIGatewayProxyRequest) (*linebot.Event, error)
}

type line struct{}

func (l *line) GetClient() (*linebot.Client, error) {
	return linebot.New(LineChannelSecret, LineChannelAccessToken)
}

func (l *line) ParseRequest(r events.APIGatewayProxyRequest) (*linebot.Event, error) {

	if !validateSignature(r.Headers["X-Line-Signature"], []byte(r.Body)) {
		return nil, linebot.ErrInvalidSignature
	}

	request := &struct {
		Events []*linebot.Event `json:"events"`
	}{}
	err := json.Unmarshal([]byte(r.Body), request)
	if err != nil {
		return nil, err
	}

	return request.Events[0], nil
}

func validateSignature(signature string, body []byte) bool {
	decoded, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		return false
	}
	hash := hmac.New(sha256.New, []byte(LineChannelSecret))

	_, err = hash.Write(body)
	if err != nil {
		return false
	}

	return hmac.Equal(decoded, hash.Sum(nil))
}

func NewLineClient() *line {
	return &line{}
}
```

## イベント情報からコンテント（画像）を取得

リクエスト情報からLINEのイベント情報を取得することはできましたがそこにはコンテントは含まれていません。
公式リファレンスを確認すると「[バイナリの画像データは`content`エンドポイントから取得できます](https://developers.line.biz/ja/reference/messaging-api/#wh-image)」と書いてあります。

公式リファレンスの対象のエンドポイントを確認すると`GET https://api-data.line.me/v2/bot/message/{messageId}/content
`であることがわかります。

[line-bot-sdk-goの`client.go`を見ると`APIEndpointGetMessageContent`というのがあるのがわかります](https://github.com/line/line-bot-sdk-go/blob/master/linebot/client.go#L35)

参照元に`linebot#GetMessageContent`というのがあるのでこれを使えばうまくいきそうな気がします。

```go:controller.go
package api

import (
	"famiphoto/src/infrastructure"
	"famiphoto/src/usecase"
	"fmt"

	"github.com/line/line-bot-sdk-go/linebot"

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

type Controller interface {
	Handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)
}

type controller struct {
	line   infrastructure.Line
    image  usecase.Image
}

func (c *controller) Handler(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

	l, err := c.line.ParseRequest(req)
	if err != nil {
		err = fmt.Errorf("Failed parse request: %v ", err)
		return errorResponse(err), err
	}

	lc, err := c.line.GetClient()
	if err != nil {
		err = fmt.Errorf("Failed get line client: %v ", err)
		return errorResponse(err), err
	}

	switch l.Message.(type) {
	case *linebot.TextMessage:
		fmt.Println("start reply text")
		rm := linebot.NewTextMessage("Message is not supported")
		if _, err := lc.ReplyMessage(l.ReplyToken, rm).Do(); err != nil {
			err = fmt.Errorf("Failed reply Text Message: %v ", err)
			return errorResponse(err), err
		}
	case *linebot.ImageMessage:
		fmt.Printf("start store image: %v ", l)
		content, err := lc.GetMessageContent(l.Message.(*linebot.ImageMessage).ID).Do()
		if err != nil {
			err = fmt.Errorf("Failed get content: %v ", err)
			return errorResponse(err), err
		}
		if err := c.image.StoreContent(content.Content); err != nil {
			err = fmt.Errorf("Failed store content: %v ", err)
			return errorResponse(err), err
		}
	}

	return events.APIGatewayProxyResponse{
		StatusCode:      200,
		Body:            "Success",
		IsBase64Encoded: false,
	}, nil
}

func NewController(line infrastructure.Line, image usecase.Image) Controller {
	return &controller{
		line:   line,
        image: image,
	}
}

```

実際にコンテント取得処理の主な箇所は以下になります。

```go
    switch l.Message.(type) {
    ....
    case *linebot.ImageMessage:
        fmt.Printf("start store image: %v ", l)
        content, err := lc.GetMessageContent(l.Message.(*linebot.ImageMessage).ID).Do()
        if err != nil {
            err = fmt.Errorf("Failed get content: %v ", err)
            return errorResponse(err), err
        }
        if err := c.image.StoreContent(content.Content); err != nil {
            err = fmt.Errorf("Failed store content: %v ", err)
            return errorResponse(err), err
        }
    }
```

取得したイベント情報のMessageは実装がないため`*linebot.ImageMessage`にキャストしIDを取得したものを`linebot.Client.GetMessageContent`に渡してあげてDoメソッドを呼び出すとコンテントの取得ができます。
(Goにはスマートキャスト的なものないのかな...)

## contentをS3に保存

最後にS3に保存して今回のユースケースは完了になります

```go:image_repository.go
func (i *imageRepository) StoreContent(content io.ReadCloser) error {
	filePath := strconv.FormatInt(time.Now().Unix(), 10)
	_, err := i.s3uploader.Upload(&s3manager.UploadInput{
		Bucket:       aws.String(BucketName),
		Key:          aws.String(filePath),
		Body:         content,
		CacheControl: aws.String("max-age=31536000"),
	})
	return err
}
```

# 終わりに

実際のソースから抜粋して命名など変更して記載している為上記のソース通りで実際にはうまく動くかわかりませんが大体この感じでLINEMessagingAPIからWebhookでAPIGateWayを叩きLambdaでコンテントを取得しS3に保存するということが実現できるかと思います。

先にも書いた通りまだGoでの実装経験はほとんどないため、もしおかしな箇所がありましたらご指摘頂けますと嬉しい限りです。

明日は@shiminori0612さんの「TypeScript での DI について」になります。