Serverless Frameworkを使ってAWS Lambda + API GatewayでLINE botを作る

なんとなくAPI gatewayとLambdaを使ってみたかったので、遊びとしてServerless Frameworkを使ってLINE botを作った。
どんなbotかと言うと

- 画像を送るとgoogle cloud vision APIを使って文章を検出
- 検出された文章をgoogle cloud translate APIを使って日本語に翻訳して返す

# 準備

awsのアカウントとかaws cliとかの準備、設定は割愛。
google cloud visionとかのgoogle cloud translateの使い方とかも割愛。

## Serverless Frameworkインストール
```bash
$ npm install -g serverless
$ serverless --version
1.26.1
```
## serviceの作成

今回はpython3を使います。

```bash
$ serverless create --template aws-python3 --name translate_bot --path translate_bot
$ cd translate_bot
```
## Serverless Frameworkのプラグインインストール

今回、google cloud visionやgoogle cloud translateのパッケージの中でAmazon linuxの環境でコンパイルしないといけないモジュールが一部あった（具体的には `grpcio`）ので、その解決方法として[serverless-python-requirements](https://github.com/UnitedIncome/serverless-python-requirements)を使います。


```bash
$ npm init
$ npm install --save serverless-python-requirements
```

## pythonの必要パッケージのインストール

```bash
$ pip install requests google-cloud-vision google-cloud-translate line-bot-sdk
```

# LINE botのコードを書く

```handler.py
import os
import json
import hmac
import base64
import hashlib
import requests
import logging.config
from google.cloud import vision
from google.cloud import translate
from google.cloud.vision import types
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage

# load Line API setting
with open('./conf/line.json', 'r') as f:
    line_conf = json.loads(f.read())
# set env
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = './conf/google_service.json'

# logging setting
logging.config.fileConfig('./conf/logging.conf')
logger = logging.getLogger()
logger.setLevel(10)

def translate_from_image(url):
    vision_client    = vision.ImageAnnotatorClient()
    translate_client = translate.Client()
    target_language  = 'ja'

    headers = {'Content-Type':  'application/json',
               'Authorization': 'Bearer ' + line_conf.get('channelAccessToken')}

    try:
        res = requests.get(url, headers=headers)
    except:
        error_message = 'Failed to fetch the image resource: ' + str(url)
        logger.error(error_message)
        return (False, 500, error_message)

    content = res.content
    image = types.Image(content=content)

    try:
        res = vision_client.text_detection(image=image)
    except:
        error_message = 'Failed to access to google cloud vision API'
        logger.error(error_message)
        return (False, 500, error_message)

    text = res.full_text_annotation.text

    if not text:
        return (True, 200, 'Can not detect any texts from the image')

    try:
        translated_text = translate_client.translate(text, target_language=target_language).get('translatedText')
        return (True, 200, translated_text)
    except:
        error_message = 'Failed to access to google cloud translate API'
        logger.error(error_message)
        return (False, 500, error_message)

def lambda_handler(request, context):
    body                 = request.get('body',    '')
    headers              = request.get('headers', '')
    channel_access_token = line_conf.get('channelAccessToken')
    channel_secret       = line_conf.get('channelSecret')
    line_bot_api         = LineBotApi(channel_access_token)
    handler              = WebhookHandler(channel_secret)
    hash                 = hmac.new(channel_secret.encode('utf-8'), body.encode('utf-8'), hashlib.sha256).digest()
    signature            = base64.b64encode(hash).decode('utf-8')

    if signature != headers.get('X-Line-Signature'):
        logger.info('Invalid header: X-Line-Signature: ' + headers.get('X-Line-Signature'))
        return {'statusCode': 400, 'body': '{}'}

    event           = json.loads(body).get('events')[0]
    message_type    = event.get('message').get('type')
    message_id      = event.get('message').get('id')
    reply_token     = event.get('replyToken')
    image_url       = 'https://api.line.me/v2/bot/message/' + str(message_id) + '/content'

    if message_type != 'image':
        logger.info('The message type is not image')
        return {'statusCode': 400, 'body': '{}'}

    response = translate_from_image(image_url)
    ret      = response[0]
    status   = response[1]
    message  = response[2]

    if ret:
        try:
            line_bot_api.reply_message(reply_token, TextSendMessage(message))
            return {'statusCode': status, 'body': '{}'}
        except:
            error_message = 'Can not reply to a talk channel'
            logging.error(error_message)
            return {'statusCode': 500, 'body': '{}'}
    else:
        return {'statusCode': status, 'body': '{}'}
```

上記のコードではLINEとgoogle APIを使用するためのAPI keyが書かれたファイルを下記の通り作る必要がある。

- LINE API key

```conf/line.json
{
    "channelAccessToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "channelSecret":      "xxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

- GCPサービスアカウントキー

```conf/google.json
{
  "type": "service_account",
  "project_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "client_email": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "auth_uri": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_uri": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "auth_provider_x509_cert_url": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "client_x509_cert_url": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

自分で作っておいてですが、このシークレットの管理の仕方はものすごくダメですね。
コードと一緒にこれらがs3にアップロードされてしまうので、万が一bucketのアクセス公開範囲とか権限周りを間違ってたらと思うとちょっと危ない。
ssmのparameter storeとかlambdaの環境変数で管理する方がいいと思います。
各APIの使い方とかは公式ドキュメントさんに丸投げします。
- [LINE developers](https://developers.line.me/ja/docs/messaging-api/getting-started/)
- [google cloud vision](https://cloud.google.com/vision/?hl=ja)
- [google cloud translate](https://cloud.google.com/translate/?hl=ja)

# デプロイ

デプロイに必要なファイルを作成

```bash
$ vim serverless.yml
```

```
service: translateBot
provider:
  name: aws
  runtime: python3.6
functions:
  hello:
    handler: handler.lambda_handler
    events:
      - http:
          path: woobhook
          method: post
plugins:
    - serverless-python-requirements
custom:
    pythonRequirements:
        dockerizePip: non-linux
```

```
$ pip freeze > requirements.txt
$ cat requirements.txt
```

```
cachetools==2.0.1
certifi==2018.1.18
chardet==3.0.4
future==0.16.0
google-api-core==1.1.0
google-auth==1.4.1
google-cloud-core==0.28.1
google-cloud-translate==1.3.1
google-cloud-vision==0.30.1
googleapis-common-protos==1.5.3
grpcio==1.10.0
idna==2.6
line-bot-sdk==1.5.0
protobuf==3.5.2.post1
pyasn1==0.4.2
pyasn1-modules==0.2.1
pytz==2018.3
requests==2.18.4
rsa==3.4.2
six==1.11.0
urllib3==1.22
```

準備が整ったのでデプロイします！
リージョンはap-northeast-1（東京）を指定します。

```
$ serverless deploy -r ap-northeast-1

Serverless: Installing required Python packages with python3.6...
Serverless: Docker Image: lambci/lambda:build-python3.6
Serverless: Linking required Python packages...
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Unlinking required Python packages...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (20.82 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
........................
Serverless: Stack update finished...
Service Information
service: translateBot
stage: dev
region: ap-northeast-1
stack: translateBot-dev
api keys:
  None
endpoints:
  POST - https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/dev/webhook
functions:
  hello: translateBot-dev-hello
```

AWSのコンソールからも新しいAPI gatewayのエンドポイントとLambda関数が作成されたことが確認できます。
また、上記の出力の中のendpointsの値をwebhookのURLとしてLINE developersのコンソールから設定する。

![スクリーンショット 2018-03-24 17.13.06.png](https://qiita-image-store.s3.amazonaws.com/0/122645/83a18b24-678b-f827-2d47-5cf7186bf1eb.png)

ここまでできたら動くはず。
試しに画像を送ってみる。
これはAWSのブログの一部のスクリーンショット
![スクリーンショット 2018-03-24 17.19.34.png](https://qiita-image-store.s3.amazonaws.com/0/122645/a16115c8-4dd9-2fe2-7c16-e07ac1ff4976.png)
これはネットで拾った適当なロシア語の文章
![スクリーンショット 2018-03-24 17.20.03.png](https://qiita-image-store.s3.amazonaws.com/0/122645/9099eef4-e8ea-5d7e-3320-66273dda2684.png)

うまく動いてるようです。