4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ぷりぷりあぷりけーしょんずAdvent Calendar 2019

Day 9

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

Last updated at Posted at 2019-12-08

この記事はぷりぷりあぷりけーしょんず Advent Calendar 2019の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

実装

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

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

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

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が持っている形になります。

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

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エンドポイントから取得できます」と書いてあります。

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

line-bot-sdk-goのclient.goを見るとAPIEndpointGetMessageContentというのがあるのがわかります

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

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

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

    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に保存して今回のユースケースは完了になります

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 について」になります。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?