API Gateway + Lambda + LINE messaging API で食堂のメニュー通知Botを作ってみた

こんにちは。あかいです。
この記事は、勉強を兼ねてAPI Gateway + Lambda + LINE messaging API で食堂のメニュー通知Botを作ってみた際の備忘録です。
作成に当たって必要な要素を概観するようなものを目的とします。
AWSやLINEへの登録については、今回はスキップします。

#作成物
今回作成したのは以下のようなLINE Botです。
「今日」、「今週」などと入力すると、メニューが返信されます。
返信には「今日」「明日」「今週」「来週」のようなクイックリプライがついています。
![line_movie1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/dac16436-3c8e-c588-499b-bb5a5fd89f75.gif)

#背景
今回のメニュー通知Botのもととなっているのは、私が利用している寮の食堂のメニュー通知です。
メニューを知るには、

 1. 専用のWebシステムにアクセス
 2. ログイン
 3. 今月のメニュー表(Excel)をダウンロード
 4. メニュー表を開いて確認

する必要があります。
以下はメニュー表ですが、これで毎回確認するのは、（個人的には）なかなかつらいものがあります。
![menu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/cc56f844-4dc0-9f5a-eb58-f268769946c3.png)

これを簡単に知る手段はないか、というのが今回の動機です。
「今日のご飯なんだっけな？」と手軽に確認するのにLINEが最適と判断しました。

#構成
以下に構成を示します。
基本的には、LINEのMessaging APIからWebhookして、API Gatewayで受け付け、Lambdaで処理して返信する流れです。
Webhookのたびに食堂Webシステムにアクセスするのは非効率なので、メニューデータファイルをS3に保存しておきます。
Lambdaがキックされたとき、S3のメニューデータファイルにアクセスして該当するメニューがなければ、最新のメニュー表を食堂Webシステムから取得します。Excel形式のメニュー表を扱いやすいJSON形式のメニューデータファイルに変換してS3に保存します。
![構成概要1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/e0e902ff-a5bc-8798-4130-75f52b02cd17.png)

#LINE Messaging APIについて
今回使用するのはLINE Messaging APIの応答メッセージです。

ポイントは5点です。

　1. Botがメッセージを受け取ると、Webhookが送られる
　2. Webhookのリクエストを`x-line-signature`および`channelsecret`で検証する
　3. Webhookの呼び出しもとには200で空オブジェクト{}のレスポンスを返す
　4. Botが受け取ったメッセージへの返信は、返信用URLにPOSTする
　5. Botの返信のPOSTには`channel access token`および`reply token`を利用する

(Webhookについて：https://developers.line.biz/ja/reference/messaging-api/#webhooks)
(x-line-signatureの検証について：https://developers.line.biz/ja/reference/messaging-api/#signature-validation)
(応答メッセージについて：https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)

#作成の流れ
 1. Lambda関数作成
 2. AWS設定（S3、API Gateway、IAMロール、Lambda設定）
 3. テスト

#1. Lambda関数作成
##関数
作成したLambda関数は3つです。すべてpythonで作成しました。

| # | Lambda関数名   | 内容                                                                                 |
|:-:|:---------------|:------------------------------------------------------------------------------------|
| 1 | parse_message  | API Gatewayから呼び出される関数。メッセージを受け取り返信する。                        |
| 2 | get_menu       | parse_messageから呼び出される関数。S3のメニューデータファイルから必要な情報を取得する。 |
| 3 | update_menu    | get_menuから呼び出される関数。get_menuでメニューを取得できなかった場合に食堂Webシステムからメニュー表を取得し、JSON形式に変換してメニューデータファイルとしてS3に格納する。 |

###①parse_message
####プロセスの呼び出し
Lambdaをpythonで作成する場合は、最初に呼び出される関数(ハンドラ)を定義します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/3ab7f21a-57e3-6367-7199-920f3ee4d5f3.png)
デフォルトでは`lambda_handler(event, context)`で、今回はデフォルトのままとしました。

| 引数名   | 内容           |
|:--------:|:-------------:|
| event    | イベントデータ |
| context  | ランタイム情報 |
(参考：https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html)

lambda関数が実行されると、`lambda_handler`が呼ばれます。
parse_messageでの呼び出しの様子を示します。併せて必要なライブラリをimportしています。

```python:プロセスの呼び出し
import json
import boto3
import re
import datetime
import urllib
import os
import base64
import hashlib
import hmac
import copy
def process(event):
    # Response to the request source.
    res = { 
        "isBase64Encoded": False,
        "statusCode": 200,
        "headers": {},
        "body": "{}"
        }
    # Init for messaging api.
    tmp = init_messaging_api(event)
    event = tmp
    if event is None:
        # Initできなければ何もせずリターン
        return res
    # Get text.
    text = get_text(event)
    # Parse text.
    s_date, e_date = parse_text(text)
    if s_date is None or e_date is None:
        text = "認識できません。\n以下から選択してください。"
        message = {
            "type": "text",
            "text": text
        }
    else:
        # Get menu.
        menu = get_menu(s_date, e_date)
        # Make message.
        message = make_message(menu, "flex")
        if message is None:
            text = "データが存在しません。\n以下から選択してください。"
            message = {
                "type": "text",
                "text": text
            }
    # Set quick reply.
    message = set_quick_reply(message)
    # Make return params.
    params = make_params([message], event)
    if params is not None:
        # Send response to the reply URI of messaging api.
        response(params)
    return res

def lambda_handler(event, context):
    return process(event)
```

parse_menu関数では、ハンドラから`process(event)`なる関数を呼び出しています。
process内では、以下を実施します。

1. LINEのmessaging_api用の初期処理
2. LINEのメッセージテキストを取得
3. メッセージテキストを解析
4. メニューデータを取得
5. LINEの返信用URLにレスポンスをPOST
6. API Gatewayからのメッセージに返信

####LINEのmessaging_api用の初期処理
初期処理で実施するのは次です。

1. 応答メッセージ用のURL, METHOD, HEADERSを設定
2. eventからbody, headerを取得
3. bodyから計算したsignatureとheaderのx-line-signatureが等しいことを確認する

lambda_handlerに渡されるeventから、リクエストのヘッダとボディは次のように取り出せます。
ヘッダ=`event[headers]`
ボディ=`event[body]`
(参考：https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format)

LINE Messaging APIからのリクエストボディには、Webhookイベントのリスト(events)が含まれています。
ですので、1つのWebhookイベントは次のように取り出せます。
Webhookイベント=`json.loads(event[body])['events'][0]`
(参考：https://developers.line.biz/ja/reference/messaging-api/#request-body)

```python:LINEのmessaging_api用の初期処理
# Init setting and parse event.
def init_messaging_api(event):
    # Set vars for Bot reply.
    global URL
    global METHOD
    global HEADERS
    URL = f"https://api.line.me/v2/bot/message/reply"
    METHOD = "POST"
    HEADERS = {
        'Authorization': "Bearer " + os.environ['ACCESSTOKEN'],
        'Content-Type': 'application/json'
    }
    # raw event: event that api gateway wrap was removed.
    raw_event = None
    # Output event from api gateway.
    print("Event from API Gateway: ", event)
    # Get raw json data from event.
    body = event['body'] # String
    header = event['headers'] # Json
    # Get signature from body with channelsecret.
    hash = hmac.new(os.environ['CHANNELSECRET'].encode('utf-8'), \
        body.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash)
    # Get signature from header.
    if  'X-Line-Signature' in header:
        line_signature = header['X-Line-Signature'].encode('utf-8')
    if  'x-line-signature' in header:
        line_signature = header['x-line-signature'].encode('utf-8')
    # Compare signature and if matched, get raw_data as body in json.
        if line_signature == signature:
            raw_event = json.loads(body)['events'] # In webhook payload, there are events list.
            # If events not empty, get event.
            if len(raw_event) > 0:
                raw_event = raw_event[0]
    print("Event from Line:", raw_event)
    return raw_event
```
`channelsecret`や`channel access token`はBotアカウントに紐づくセンシティブな情報です。
そのため、ソースコードに直書きは避けたいものです。
DBに格納する、S3にファイルで格納するなど考えられますが、ここでは、Lambdaの環境変数を使用できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/d9b40ca6-d008-596c-5d66-35a22d8e6ac8.png)

環境変数へのアクセスは、通常の環境変数同様、`os.environ['ACCESSTOKEN']`のように可能です。


####LINEのメッセージテキストを取得
本Botはメッセージテキストのみを受け付けます。
したがって、以下のようなリクエストボディを想定します。

```json:受け付けるWebhookイベントのリクエストボディ(一部略)
"events": [
  {
    "type": "message",
    "message": {
      "type": "text",
      "text": "今日"
    }
  }
]
```
(参考：https://developers.line.biz/ja/reference/messaging-api/#message-event)

次に示す関数では、上記に該当するメッセージを受け取った場合には、Botが受け取ったtextを、該当しない場合はNoneを返します。

```python:LINEのメッセージテキストを取得
# Get message.
def get_text(raw_event):
    if raw_event is None:
        return None
    rc = 0
    text = None
    # Get type.
    if "type" in raw_event:
        type = raw_event["type"]
    else:
        rc = 8
    # Get message.
    if rc == 0:
        if type == "message":
            message = raw_event['message']
        else:
            rc = 8
    # Get text.
    if rc == 0:
        if "text" in message:
            text = message['text']
        else:
            rc = 8
    return text
```

####メッセージテキストを解析
「今日」や「今週」などがtextに含まれている場合に、メニュー取得の開始日と終了日を取得します。
それ以外の場合はNoneを返します。

```python:メッセージテキスト解析
# Parse text. This returns the start date and end date of the menu.
TODAY = datetime.datetime.utcnow() + datetime.timedelta(hours=9)
def parse_text(text):
    if text is None:
        return None, None
    if re.search("今日", text) or re.search("きょう", text):
        s_date = TODAY
        e_date = TODAY
    elif re.search("明日", text) or re.search("あした", text) or re.search("あす", text):
        s_date = TODAY+ datetime.timedelta(days=1)
        e_date = TODAY+ datetime.timedelta(days=1)
    elif re.search("明後日", text) or re.search("あさって", text):
        s_date = TODAY+ datetime.timedelta(days=2)
        e_date = TODAY+ datetime.timedelta(days=2)
    elif re.search("今週", text) or re.search("こんしゅう", text):
        s_date = TODAY- datetime.timedelta(days=TODAY.weekday())
        e_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=6)
    elif re.search("来週", text) or re.search("らいしゅう", text):
        s_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=7)
        e_date = TODAY- datetime.timedelta(days=TODAY.weekday()) + datetime.timedelta(days=13)
    else:
        return None, None
    s_date = datetime.datetime.strftime(s_date, '%Y/%m/%d')
    e_date = datetime.datetime.strftime(e_date, '%Y/%m/%d')
    print(s_date, e_date)
    return s_date, e_date
```

####メニューデータを取得
別のLambda関数、get_menuを呼び出します。
Lambdaでは、AWS SDKがデフォルトで使用可能です。
pythonであれば、boto3が使えます。
以下では、boto3でget_menuなる別途定義したLambda関数をinvokeしています。
正常にinvokeできた場合、開始日、終了日を満たすメニューデータが返されます。

(参考：https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html#Lambda.Client.invoke)

```python:メニューデータを取得
# Get menu between start date and end date.
# return: menu
#         None(Cannot get menu)
def get_menu(s_date, e_date):
    if s_date is None or e_date is None:
        return None
    client = boto3.client('lambda')
    response = client.invoke(
        FunctionName='get_menu',
        InvocationType='RequestResponse',
        Payload = json.dumps({"start_date": s_date, "end_date": e_date}))
    res_from_called_function = "no"
    if response['StatusCode'] == 200:
        res_from_called_function = response['Payload'].read().decode('utf-8')
        res_from_called_function = json.loads(res_from_called_function)
        print("this is res_from_called_function : ", res_from_called_function)
        if res_from_called_function['statusCode'] == 200:
            menu = json.loads(res_from_called_function['body'])['message']
            menu = json.loads(menu) if menu != "" else None
        else:
            menu = None
    return menu
```

####LINEの返信用URLにレスポンスをPOST
レスポンスを返すにあたって、メッセージを作成する必要があります。
メッセージはFlex Messageで作成します。
(参考：https://developers.line.biz/ja/reference/messaging-api/#flex-message)

Flex Messageは以下のページから簡単に作成できます(LINEアカウントが必要です)。
https://developers.line.biz/flex-simulator/

メニューを表示するためのFlex Messageのテンプレートを定義して、メニューに応じてreplaceします。
<details><summary>Flex Messageのテンプレート</summary><div>

```python:Flexメッセージテンプレート
FLEX_TEMPLATE = \
{
  "type": "box",
  "layout": "vertical",
  "contents": [
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": "type1",
          "size": "md",
          "style": "italic",
          "weight": "bold",
          "decoration": "underline",
          "color": "#ffffff"
        }
      ],
      "backgroundColor": "color0"
    },
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {"type": "text","text": "menu0","size": "xs"},
        {"type": "text","text": "menu1","size": "xs"},
        {"type": "text","text": "menu2","size": "xs"},
        {"type": "text","text": "menu3","size": "xs"},
        {"type": "text","text": "menu4","size": "xs"},
        {"type": "text","text": "menu5","size": "xs"},
        {"type": "text","text": "menu6","size": "xs"},
        {"type": "text","text": "menu7","size": "xs"},
        {"type": "text","text": "menu8","size": "xs"},
        {"type": "text","text": "menu9","size": "xs"}
      ],
      "backgroundColor": "color1"
    },
    {
      "type": "separator",
      "color": "#808080"
    },
    {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "energy : energy1","size": "xxs"}
          ]
        },
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "protein : protein1","size": "xxs"},
            {"type": "text","text": "lipid : lipid1","size": "xxs"}
          ]
        },
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {"type": "text","text": "carbohydrate : carbohydrate1","size": "xxs"},
            {"type": "text","text": "salt : salt1","size": "xxs"}
          ]
        }
      ],
      "backgroundColor": "color2"
    },
    {
      "type": "spacer"
    }
  ]
}
FLEX_COLORS_DAY = ["#ff8c00", "#fffbe6", "#fffbe6"]
FLEX_COLORS_NIGHT = ["#000080", "#e6fbff", "#e6fbff"]
FLEX_BG_TODAY_COLOR = "#ffd1ff"
FLEX_BG_COLOR = "#ffffff"
```
</div></details>

次の通りに日ごとにFlexメッセージの要素(bubble)を作成して、レスポンス用にまとめます。
<details><summary>Flexメッセージ作成のスクリプト</summary><div>

```python:Flexメッセージ作成
# Replace or delete target value in json.
def process_json(json, action, value, new_value = None):
    key = ""
    if type(json) == dict:
        for k, v in json.items():
            if v == value:
                    key = k
                    break
            if type(v) == dict or type(v) == list:
                process_json(v, action, value, new_value)
    elif type(json) == list:
        for i in range(len(json)):
            j = json[i]
            if j == value:
                key = i
            process_json(j, action, value, new_value)
    else:
        return
    if key != "":
        if action == "delete":
            del json[key]
        if action == "replace":
            json[key] = new_value
    return

# Parse menu per day.
def parse_menu(menu_per_day, template, data_type="flex", options=[]):
    if "date" not in menu_per_day:
        return None
    else:
        if data_type == "flex":
            bubble = \
            {
              "type": "bubble",
              "body": {
                "type": "box",
                "layout": "vertical",
                "contents": [
                  {
                    "type": "box",
                    "layout": "vertical",
                    "contents": [
                      {
                        "type": "text",
                        "text": menu_per_day["date"],
                        "size": "xl",
                        "weight": "bold",
                        "style": "italic",
                        "decoration": "underline"
                      }
                    ]
                  }
                ]
              }
            }
        if "todayColor" in options:
            color = options["todayColor"]
            bubble["styles"]={"body": {"backgroundColor": color}}
    if "all" not in menu_per_day:
        return None
    keys = ["type", "menu", "energy", "protein", "lipid", "carbohydrate", "salt"]
    for menu in menu_per_day["all"]:
        # Check keys.
        if set(keys) != set(menu.keys()):
            return None
        # Get information.
        day_type = "Morning" if menu["type"] == "morning" else "Supper" if menu["type"] == "supper" else None
        if day_type is None:
            return None
        menu_list = menu["menu"]
        energy = menu["energy"]
        protein = menu["protein"]
        lipid = menu["lipid"]
        carbohydrate = menu["carbohydrate"]
        salt = menu["salt"]
        # Insert template.
        if data_type == "flex":
            tmp = copy.deepcopy(template)
            for i in range(10):
                if i < len(menu_list):
                    value = {"type": "text","text": "menu"+str(i),"size": "xs"}
                    new_value = {"type": "text","text": menu_list[i],"size": "xs"}
                    process_json(tmp, "replace", value, new_value = new_value)
                else:
                    value = {"type": "text","text": "menu"+str(i),"size": "xs"}
                    process_json(tmp, "delete", value)
            process_json(tmp, "replace", "type1", new_value = day_type)
            process_json(tmp, "replace", "energy : energy1", new_value = "energy : " + energy)
            process_json(tmp, "replace", "protein : protein1", new_value = "protein : " + protein)
            process_json(tmp, "replace", "lipid : lipid1", new_value = "lipid : " + lipid)
            process_json(tmp, "replace", "carbohydrate : carbohydrate1", new_value = "carbohydrate : " + carbohydrate)
            process_json(tmp, "replace", "salt : salt1", new_value = "salt : " + salt)
 
            for i in range(len(FLEX_COLORS_DAY)):
                if menu["type"] == "morning":
                    color = FLEX_COLORS_DAY[i]
                elif menu["type"] == "supper":
                    color = FLEX_COLORS_NIGHT[i]
                process_json(tmp, "replace", "color"+str(i), new_value=color)
            bubble["body"]["contents"].append(tmp)
    return bubble 

# Make bubbles.
def make_bubbles(menu):
    bubbles = [parse_menu(menu_per_day, FLEX_TEMPLATE, options={"todayColor": FLEX_BG_TODAY_COLOR}) \
               if datetime.datetime.strptime(menu_per_day["date"], '%Y/%m/%d').date() == TODAY.date() \
               else parse_menu(menu_per_day, FLEX_TEMPLATE) for menu_per_day in menu]
    bubbles = [bubble for bubble in bubbles if not bubble is None]
    return bubbles

# Make message.
def make_message(menu, type):
    if menu is None or type is None:
        return None
    if type == "flex":
        message['altText'] = "flex"
        contents = {}
        bubbles = make_bubbles(menu)
        if bubbles is not None:
            if len(bubbles) == 1:
                contents = bubbles[0]
            else:
                contents['type'] = "carousel"
                contents['contents'] = bubbles
            message['contents'] = contents
        else:
            return None
    return message
```
</div></details>

以下でクイックリプライを付与します。
<details><summary>クイックリプライを付与</summary><div>

```python:クイックリプライ付与
def set_quick_reply(message):
    quickReply = {"items": []}
    item = {
        "type": "action",
        "action": {
            "type": "message",
            "label": "", 
            "text": ""
        }
    }
    labels = ["今日", "明日", "今週", "来週"]
    for label in labels:
        temp = copy.deepcopy(item)
        temp["action"]["label"] = label
        temp["action"]["text"] = label
        quickReply["items"].append(temp)
    message["quickReply"] = quickReply
    return message
```
</div></details>

レスポンスは形式が決まっており、それに従って作成しPOSTします。([LINE Messaging APIについて(ページ内リンク)](#line-messaging-apiについて))
POSTはurllibで行います。

<details><summary>レスポンスを作成してPOST</summary><div>

```python:レスポンスを作成してPOST
# Make return params.
def make_params(messages, raw_event):
    if messages is None or raw_event is None:
        return None
    if "replyToken" not in raw_event:
        return None
    params = {
        "replyToken": raw_event['replyToken'],
        "messages": messages
    }
    print("params : ", params)
    return params

# Response by urllib.
def response(params):
    request = urllib.request.Request(URL, json.dumps(params).encode("utf-8"), method=METHOD, headers=HEADERS)
    try:
        with urllib.request.urlopen(request) as res:
            body = res.read()
    except urllib.error.HTTPError as err:
        print("Error Response: ", err.code)
    except urllib.error.URLError as err:
        print("Error Respones: ", err.reason)
```
</div></details>

###②get_menu
get_menuではS3からメニューデータファイルを取得して解析します。
ここではスクリプト例を載せませんが、以下がポイントとなります。

1. S3へのアクセスはboto3を利用(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Bucket.download_file)
2. lambdaでは/tmpを一時ファイル領域として利用可能(https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html)

なお、/tmp領域を使用する場合は、環境が再利用されている可能性を考慮してLambda関数の開始・終了時に削除処理を入れる方がよいと思われます。

###③update_menu
update_menu関数では、`Selenium`を使用して食堂Webシステムからメニュー表を取得します。
ポイントは以下です。
1. Seleniumの組み込みはLambda Layerを利用する
2. Seleniumの初期設定はクセがある
2. Seleniumの依存関係に注意を払う

1は後述します([Lambda Layerの作成(ページ内リンク)](#lambda-layerの作成))。
2について、Seleniumの設定には癖があるようです。動いている設定を以下に示します。

```python
from selenium import webdriver
def init_driver():
    options = webdriver.ChromeOptions()
    options.binary_location = "/opt/python/headless_chrome/bin/headless-chromium"
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--single-process")
    options.add_experimental_option("prefs", {"download.default_directory" : DOWNLOAD_DIR})
    driver = webdriver.Chrome(
        executable_path="/opt/python/headless_chrome/bin/chromedriver",
        chrome_options=options
    )
    driver.implicitly_wait(10)
    driver = enable_download_headless(driver, DOWNLOAD_DIR)
    return driver

def enable_download_headless(driver, dir):
    driver.command_executor._commands["send_command"] = ("POST", '/session/$sessionId/chromium/send_command')
    params = {'cmd': 'Page.setDownloadBehavior', 'params': {'behavior': 'allow', 'downloadPath': dir}}
    driver.execute("send_command", params)
    return driver
```

3について、以下で動きました。
SERVERLESS_CHROME_URL=https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-55/stable-headless-chromium-amazonlinux-2017-03.zip
CHROME_DRIVER_URL=https://chromedriver.storage.googleapis.com/2.41/chromedriver_linux64.zip
RUNTIME＝Python 3.7

(参考：https://qiita.com/konomochi/items/eb5ff389a405a665430e)

##Lambda Layersの作成
LambdaではZIPファイルで作成したLambda関数をアップロードできます。
ここにはライブラリを含めることができますが、容量制限があります。
そこで、ライブラリはLambda Layersに設定します。
ローカルで作成したLambda Layers用ディレクトリをZIPしてアップロードします。
docker-lambdaでの検証も考慮して、以下のような階層を作成します(検証時に必要な形にしておきます)。
作業はWSL2(Ubuntu)で実施していますが、Linux環境であれば問題ないものと思います。

```
作業用ディレクトリ
├── fn
│   └── lambda_function.py
└── layers
    └── python
        ├── headless_chrome
        │   └── bin
        │       ├── chromedriver
        │       └── headless-chromium
        └── lib
            └── python3.7
                └── site-packages
```

①cd 作業用ディレクトリ
②以下のget-binaries.shを実施(参考に記載の[konomichi様](https://qiita.com/konomochi)の記事をもとにしました。）

<details><summary>get-binaries.sh</summary><div>

```bash:get-binaries.sh
#!/bin/bash -x

SERVERLESS_CHROME_URL=https://github.com/adieuadieu/serverless-chrome/releases/download/v1.0.0-37/stable-headless-chromium-amazonlinux-2017-03.zip
CHROME_DRIVER_URL=https://chromedriver.storage.googleapis.com/2.37/chromedriver_linux64.zip

# get headless_chrome
HEADLESS_LIB=./headless_chrome/python/headless_chrome/bin/
rm -r ${HEADLESS_LIB}
mkdir -p ${HEADLESS_LIB}

wget $SERVERLESS_CHROME_URL
unzip stable-headless-chromium-amazonlinux-2017-03.zip -d ${HEADLESS_LIB}
rm stable-headless-chromium-amazonlinux-2017-03.zip

wget $CHROME_DRIVER_URL
unzip chromedriver_linux64.zip -d ${HEADLESS_LIB}
rm chromedriver_linux64.zip

# get selenium
#  /opt/python, または /opt/python/lib/python3.7/site-packages に展開されるように配置する
rm -r ./selenium

"""
# python3.6
PYTHON_VER="python3.6"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
cp ./requirements.txt ./selenium/
docker run --rm \
	-v ${PWD}/selenium:/opt \
	"lambci/lambda:build-${PYTHON_VER}" \
	/bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}
"""

# python3.7
PYTHON_VER="python3.7"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
docker run --rm \
	-v ${PWD}/selenium:/opt \
	"lambci/lambda:build-${PYTHON_VER}" \
	/bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}

"""
# python3.8
PYTHON_VER="python3.8"
PYTHON_LIB=python/lib/${PYTHON_VER}/site-packages
mkdir -p ./selenium/${PYTHON_LIB}
docker run --rm \
	-v ${PWD}/selenium:/opt \
	"lambci/lambda:build-${PYTHON_VER}" \
	/bin/sh -c "pip install selenium pandas xlrd -t /opt/${PYTHON_LIB}; exit"
#docker image rm lambci/lambda:build-${PYTHON_VER}
"""
# make zip file
cd ./headless_chrome
zip -r headless_chrome.zip python > /dev/null
cd ../selenium
zip -r selenium.zip python > /dev/null
cd ../
mv ./headless_chrome/headless_chrome.zip ./
mv ./selenium/selenium.zip ./
rm -R ./headless_chrome
rm -R ./selenium

# make test
DIR=test_dir
rm -R ./${DIR}/layers
mkdir -p ./${DIR}/layers
cp ./headless_chrome.zip ./${DIR}/layers
cp ./selenium.zip ./${DIR}/layers
cd ./${DIR}/layers
unzip headless_chrome.zip > /dev/null
unzip selenium.zip > /dev/null
sudo rm headless_chrome.zip
sudo rm selenium.zip
mkdir ./fn
```
</div></details>

③headless_chrome.zipファイル、selenium.zipファイル、test_dirディレクトリが存在することを確認する

(参考：https://qiita.com/konomochi/items/eb5ff389a405a665430e, 
　　　 https://www.yamamanx.com/aws-lambda-layer-chrome-headless-layer/, 
　　　 https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-layer-simulated-docker/)


##Lambda関数のローカル検証
まずは試しに作成した関数をローカルで検証しました。
ローカルでの検証には、docker-lambdaを使用しました。
(参考：https://qiita.com/anfangd/items/bb448e0dd30db3894d92)

ローカル検証時には、Lambda Layersの環境を使用するため、[Lambda Layersの作成(ページ内リンク)](#lambda-layersの作成)で作成したtest_dirを使用します。

1. cd 作業用ディレクトリ/test_dir
2. 作業用ディレクトリ/test_dir/fn配下に任意の名前で検証対象のpythonファイルを作成する(ここではlambda_function.py)
3. lambda_function.pyにlambda_handler関数を作成
4. 以下コマンドでテスト実行

`docker run --rm -v "\$PWD"/fn:/var/task -v "\$PWD"/layers:/opt lambci/lambda:python3.7 lambda_function.lambda_handler`

―vオプションで、コンテナ環境のディレクトリをローカルのディレクトリと対応付けています。
すなわち、Lambdaでは(pythonの場合は)、実行時に以下のように動作すると思われます。

- /opt/pythonにLambda Layers内のライブラリを展開
- /var/taskでLambda関数を実行

#2. AWS設定（S3、API Gateway、IAMロール、Lambda設定）
##S3設定
S3は任意のバケットを作成するのみです。
なお、アクセスポリシーでパブリックアクセスはブロックします。
作成時のバケット名、オブジェクト名(フォルダまで固定値)は、Lambda関数での呼び出し値に合わせます。

##API Gateway設定
REST APIを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/75cf9059-9994-2120-00a0-63bd6405b474.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/784d9d75-e4dc-49ec-8cd4-4f599e83b6c5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/a6621c30-6bea-224a-3e2d-c36ab7a54bf9.png)

ルートにPOSTメソッドを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/4e9e4414-5326-4556-462c-30c285dbe8c9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/198258d4-17ad-03e3-b500-e14529028aaa.png)

- 統合タイプ：Lambda関数
- Lambdaプロキシ統合の仕様：チェック
- Lambda関数：作成した関数(ex. parse_message)

を指定し保存します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/34198d10-f53c-8d41-2641-e286b64a5c5f.png)

##IAMロール設定
次のポリシーを作成します(簡単のため雑な指定になっていますが、必要に応じてAction、Resourceを絞るべきです)。
・CloudwatchLogsPolicy
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/03fb3705-24a4-3446-ed1a-90c40996c284.png)
・LambdaInvokePolicy
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/a75c145f-8a0d-7dd3-fd56-3c569a69591a.png)
・S3AccessPolicy
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/b762c461-b238-bd2e-b89b-5a649bf9ce5c.png)


以下のIAMロールを作成します。
・lambda-s3-role
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/50ada120-5cd9-0508-da1d-727b3ed67660.png)
・lambda-role
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/737f1050-1969-f254-a70d-d8b00e57c30e.png)
・s3-role
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/5961c742-c3a7-0c12-8998-629f5d573485.png)

##Lambda設定
###レイヤーの作成
[Lambda Layersの作成(ページ内リンク)](#lambda-layersの作成)で作成したZIPファイルごとにレイヤーを作成する。
　- 名前：ZIPファイル名と同じ
　- アップロード：[Lambda Layersの作成(ページ内リンク)](#lambda-layersの作成)で作成したZIPファイル
　- 互換性のあるランタイム：Python 3.7
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/175490ce-d773-3c1f-dd7c-460aef656e2b.png)

###関数の作成
関数の作成を選択します。
　オプション：1から作成
　関数名：parse_messageなど
　ランタイム：python3.7
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/3c648d79-f0fb-888e-25a8-79306baf9ccc.png)

次の通りロールを設定します。

| Lambda関数    | IAMロール       |
|:-------------:|:--------------:|
| parse_message | lambda-role    |
| get_menu      | lambda-s3-role |
| update_menu   | s3-role        |
アクセス情報を設定し関数の作成を押下します。
　実行ロール：既存のロールを使用する
　既存のロール：表参照
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/d08e0df2-9f43-3b30-e9ec-a7555a420f46.png)

次の通りカスタムレイヤーを設定します。

| Lambda関数    | カスタムレイヤー           |
|:-------------:|:-------------------------:|
| parse_message | なし                      |
| get_menu      | なし                      |
| update_menu   | headless_chrome、selenium |
作成した関数を選択し、レイヤーを設定します。
　- レイヤー：カスタムレイヤー
　- カスタムレイヤー：表参照
　- バージョン：最新のもの
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/0fd8b1a8-e436-9130-3dda-2e573d9b84fb.png)


#3. テスト
Lambda関数単体のテストは、個々のLambda関数から実行できます。
ただし、テストのためのリクエストを明記する必要があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/683b9462-9647-3bb9-4ad3-4aade4e08faf.png)

API Gatewayを経由しての、LINEと連携したテストを行います。
[IAMロール設定(ページ内リンク)](#iamロール設定)で設定した、CloudWatchLogsPolicyをlambdaに付与しているため、CloudWatchからlambdaの出力を確認できます。

CloudWatch>CloudWatch Logs>Log groups>/aws/lambda/parse_message のようにログストリームを参照できます。
Lambda関数中でprintするとここに出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325677/852f5199-64d2-d70d-4e3c-50ec2abc878d.png)