この記事はぷりぷりあぷりけーしょんず 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
設計
実装
実際に実装している内容丸々、というよりは個人的に大変だった話を備忘録兼ねて記載していきます。
APIGatewayProxyRequestをline-bot-sdk-go/linebot.Eventにパース
今回LINEMessagingAPIを使うにあたってline-bot-sdk-goを使用しました。
このライブラリ内のwebhook.go
にはLINE上で発生したイベント情報をhttpRequestからパースしてlinebot.Event
を取り出してくれる便利メソッドが提供されています。
// 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]
を返しています)
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
というのがあるのでこれを使えばうまくいきそうな気がします。
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に保存して今回のユースケースは完了になります
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 について」になります。