鍵管理システム(ver2.0)を作った話

n番煎じですがLINEBot(LINE公式アカウント)とAWS lambdaとかを使ってサービスを作りました
[ver1.0はこちら](https://qiita.com/ingk_124/items/228b44bbcb926b50174e)

#わかりやすいアレ
<img width="1219" alt="スクリーンショット 2019-06-06 13.07.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205561/f0e38016-4574-1a3d-c237-83ccffd9a766.png">

したいこと

- 借用返却の管理
- ログを残す
- 希望者には通知を送る
- 鍵を返し忘れてたら通知する

##使ったもの

- Messaging API
- AWS
    - Lambda
    - DynamoDB
- GoogleSheets(GoogleAppsScript)



#詳しく
##Messaging API
腐る程記事があるので[割愛](https://developers.line.biz/ja/services/messaging-api/)
アクセストークンメモってWebhookにLambdaのトリガーURLを設定する(後述)
プランが2019年の春に変更されたので乗り換えました。[詳細](https://linecorp.com/ja/pr/news/ja/2018/2517)
これにより以前のDeveloper Trialプランで課題だった友達制限がなくなり、フリープランでは使えなかったPushメッセージが使えるようになりました(制限あり)。
小規模サービスをチマチマ作ってる人間にはありがたいです。

###イベント例
[公式リファレンス](https://developers.line.biz/ja/reference/messaging-api/#common-properties)
今回はmessageとpostbackを使いました。

```json:messageイベント
{
    "destination": "xxxxxxxxxx",
    "events": [
        {
            "replyToken": "hogehoge",
            "type": "message",
            "timestamp": 1462629479859,
            "source": {
                "type": "user",
                "userId": "U4af4980629..."
            },
            "message": {
                "id": "325708",
                "type": "text",
                "text": "Hello, world"
            }
        }
    ]
}
```
```json:postbackイベント
{
    "destination": "xxxxxxxxxx",
    "events": [
        {
            "type":"postback",
            "replyToken":"b60d432864f44d079f6d8efe86cf404b",
            "source":{
                "userId":"...UserId...",
                "type":"user"
            },
            "timestamp":1513669370317,
            "postback":{
                "data":"12345-abcde",
                "params":{
                    "datetime":"2017-12-25T01:00"
                }
            }
        }
    ]
}
```


##GoogleSheetsとGoogleAppsScript
ログをGoogleSheetsに残すためLambdaからAPIで操作しようとするも失敗したので、GASでAPIを作ってJSONをやり取りすることにしました。
作りたいSheetは以下

| 借用 | 返却 |
|:-:|:-:|
| 2019/6/5 10:12,User1  | 2019/6/5 19:35,User2  |
| 2019/6/6 12:24,User2  | 2019/6/6 20:15,User3  |
| 2019/6/7 11:03,User1  |   |

最終行の返却列が空白⇔部屋が空いてる

###GASのコード
今回は1つのSpreadsheetしか操作しないのでContainer Bound Scriptで作ります。
([Standalone Script と Container Bound Script の違い](https://qiita.com/t_imagawa/items/47fc130a419b9be0b447#standalone-script-と-container-bound-script-の違い))

```Js:key_api.gs
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); //Spreadsheetの取得
var sheets = spreadsheet.getSheets(); //Sheetの取得

function doPost(e) { //POST
  //受け取ったデータをシートに追記
  var date = new Date();
  var event = JSON.parse(e.postData.contents);
  var room = event.room
  var name = event.name
  var key = event.key
  var sheet = sheets[room]
  var value = Utilities.formatDate( date, 'Asia/Tokyo', 'yyyy/MM/dd,HH:mm') + "," + name;
  
  var lastrow = sheet.getLastRow();
  var latest = sheet.getRange(lastrow, 2).getValue();
  
  if (key == "take") {
    if (latest == "") {
      sheet.getRange(lastrow, 1).setValue(value);
    } else {
      sheet.getRange(lastrow+1, 1).setValue(value);
    }
  } else if (key == "return") {
    sheet.getRange(lastrow, 2).setValue(value);
  }
  
  hideRows(room);
   
  var json = {
    status:"OK!"
  }
   
  return ContentService
      .createTextOutput(JSON.stringify(json))
      .setMimeType(ContentService.MimeType.JSON);
}


function doGet(e) { //GET
  //各シートの一番下のデータを送信
  var rooms =[0,1,2];
  var data =[];
  for (var room in rooms){
    var sheet = sheets[room];
    var lastrow = sheet.getLastRow();
    data.push(sheet.getRange(lastrow,1,1, 2).getValues()[0]);
  }
  
  var json = {
    data:data,
  }

  return ContentService
      .createTextOutput(JSON.stringify(json))
      .setMimeType(ContentService.MimeType.JSON);
}


function hideRows(room){ //見にくいから非表示にする
  var sheet = sheets[room];
  var lastrow = sheet.getLastRow();
  if (lastrow > 4){
    sheet.hideRows(2, lastrow-4);
  }
}
```

##AWS
いっぱいあるサービスの中から今回はAWSを選択(記事が多かったから)

- Lambda(Botを動かす)
- API GateWay(Lambdaのトリガー)
- DynamoDB(ユーザーの登録)
- KMS(環境変数の暗号化)

###DynamoDB
[参考](https://qiita.com/ykhirao/items/04a921f79574c46f61c0)
[公式ガイド](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Introduction.html)
こちらもたくさん記事があるので詳しくは割愛
登録データは

- UserId
- notice(通知設定の値)

の2つ
パーティションキーを入学年度、ソートキーをUserId、
UserId,noticeのそれぞれをパーティションキーにしたGSIを作成。
(前者はソートキーなし,後者はUserIdを設定)

###API Gateway
[こちらの記事](https://qiita.com/G-awa/items/fc803c285cb560dab796)とかを参考にごにょごにょやったらできます
LineDevelopersの設定画面でWebhookのエンドポイントをGatewayのurlにする。
###KMS
LINEのアクセストークンとGASのurlを暗号化するために利用
###Lambda
環境変数には

- LINEのアクセストークン
- GASのURL

を入れとく
本当は並列処理とかも考えるべきなんでしょうが、そんなに大人数で使わないので考えませんでした。

```python:lambda_function.py
import requests
import os
import json
import boto3
from boto3.dynamodb.conditions import Key, Attr
from base64 import b64decode
import datetime
from decimal import Decimal

dynamoDB = boto3.resource('dynamodb')
kms = boto3.client('kms')


def kms_dec(text):  # Decode environment
    dec_text = kms.decrypt(CiphertextBlob=b64decode(text))['Plaintext']
    return dec_text.decode('utf-8')

LINE_TOKEN = kms_dec(os.environ["LINE_TOKEN"])
sheet_api = kms_dec(os.environ["SHEET_ID"])

# Statics
reply_url = "https://api.line.me/v2/bot/message/reply"
richmenu_url = "https://api.line.me/v2/bot/richmenu"
profile_url = "https://api.line.me/v2/bot/profile/"
broadcast_url = "https://api.line.me/v2/bot/message/broadcast"
multicast_url = "https://api.line.me/v2/bot/message/multicast"

post_header = {'Content-Type': "application/json", 'Authorization': "Bearer " + LINE_TOKEN}
get_header = {'Authorization': "Bearer " + LINE_TOKEN}
rooms = ["部屋A", "部屋B", "部屋C"]
```

####DynamoDBにデータを出し入れする関数
[参考](https://qiita.com/estaro/items/b329deafdfef790aa355)

```python:lambda_function.py
def get_users(table, key, value, index=None):  # Get data from DynamoDB
    Table = dynamoDB.Table(table)
    if index:
        res = Table.query(
            IndexName=index,
            KeyConditionExpression=Key(key).eq(value)
        )
    else:
        res = Table.query(
            KeyConditionExpression=Key(key).eq(value)
        )
    response=[]
    for item in res["Items"]:
        d = {}
        for k, v in item.items():
            d[k] = int(v) if isinstance(v, Decimal) else v
        response.append(d)
    return response


def put_data(table, item):  # Put data in DynamoDB
    Table = dynamoDB.Table(table)
    try:
        Table.put_item(
            Item=item
        )
    except Exception as e:
        return e


```
DynamoDBのNumberがDecimalで返ってきたのがびっくりポイントでした。

####GASとデータをやり取りする関数

```python:lambda_function.py
def update_sheet(room, key, name):
    headers = {'Content-Type': 'application/json'}
    data = {
        "room": room,
        "key": key,
        "name": name,
    }
    r = requests.post(url=sheet_api, headers=headers, data=json.dumps(data)).json()["status"]  # レスポンスを{ status: OK! }にしておきそのまま返信に使った
    return r


def get_sheet():
    latest_data = requests.get(url=sheet_api).json()["data"]
    # latest_data = [
    #     [
    #         "sheet1のA列最終行",
    #         "sheet1のB列最終行"
    #     ],
    #     [
    #         "sheet2のA列最終行",
    #         ""←最終行にA列しかない(B列が空)の時は空文字になる
    #     ],
    #     [
    #         "sheet3のA列最終行",
    #         "sheet3のA列最終行"
    #     ]
    # ]
    return latest_data
```

####messageとpostbackの対応
messageではキーワード毎に、

- setting→通知設定
- key→鍵の報告
- status→利用状況の照会

postbackではハイフンで区切って``data[0]``が

- setting→設定
- take→借りる時
- return→返す時


としました。

```python:lambda_function.py
def message_f(events):  # Function which runs if event is message
    reply_token = events["replyToken"]
    userid = events["source"]["userId"]
    text = events["message"]["text"]

    payload = {
        "replyToken": reply_token,
        "messages": []
    }

    msg = {}

    if text == "key":  # take or return key

        try:
            is_table = get_users(table="users", key="userid", value=userid, index="userid-index")
            key_reply = {
                "items": [
                    {
                        "type": "action",
                        "action": {
                            "type": "postback",
                            "label": "借りる",
                            "text": "借りる",
                            "data": "take"
                        }
                    },
                    {
                        "type": "action",
                        "action": {
                            "type": "postback",
                            "label": "返す",
                            "text": "返す",
                            "data": "return"
                        }
                    },
                ],
            }

            msg = {"type": "text", "text": "借りる？返す？", "quickReply": key_reply}
        except:
            msg = {"type": "text", "text": "設定をして下さい(._.)"}
        payload["messages"].append(msg)

    elif text == "setting":  # setup notice
        payload["messages"].append(setting_f([]))

    elif text == "status":
        payload["messages"].append(status_f())

    elif text == "id":
        msg = {"type": "text", "text": userid}
        payload["messages"].append(msg)

    else:
        # payload["messages"].append({"type": "text", "text": text + " " + events["type"]})
        pass

    return payload


def postback_f(events):  # Function which runs if event is postback
    reply_token = events["replyToken"]
    data = events["postback"]["data"].split("-")
    userid = events["source"]["userId"]
    payload = {
        "replyToken": reply_token,
        "messages": []
    }

    if data[0] == "setting":
        msg = setting_f(data[1:], userid)

    elif data[0] == "take":
        msg = take_f(data[1:], userid)

    elif data[0] == "return":
        msg = return_f(data[1:], userid)

    else:
        msg = {
            "type": "text",
            "text": data[0] + userid,
        }

    payload["messages"].append(msg)
    return payload
```
####通知設定
通知設定には[flex message](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/)を使うことでかっこよい感じで。

```python:lambda_function.py
def setting_f(data, userid=None):
    if len(data) == 3:
        # take|return -> flag
        #  0  | 0     -> 0
        #  0  | 1     -> 1
        #  1  | 0     -> 2
        #  1  | 1     -> 3
        notice = int(data[1]) * 2 + int(data[2])
        user_data = {
            "year": int(data[0]),
            "userid": userid,
            "notice": notice
        }
        put_data(table='users', item=user_data)

        msg = {
            "type": "text",
            "text": "設定しました〜！",
        }

    elif data:
        timing = "返却時" if data[1:] else "借用時"
        msg = {
            "type": "flex",
            "altText": "通知設定",
            "contents": {
                "type": "bubble",
                "header": {
                    "type": "box",
                    "layout": "vertical",
                    "contents": [
                        {
                            "type": "text",
                            "size": "lg",
                            "text": "設定"
                        },
                        {
                            "type": "separator",
                            "color": "#000000"
                        },
                        {
                            "type": "text",
                            "text": timing + "の通知"
                        },
                    ]
                },
                "body": {
                    "type": "box",
                    "layout": "vertical",
                    "spacing": "sm",
                    "contents": [
                        {
                            "type": "button",
                            "style": "link",
                            "action": {
                                "type": "postback",
                                "label": "通知あり",
                                "data": "setting-" + "-".join(data) + "-1",
                            }
                        },
                        {
                            "type": "button",
                            "style": "link",
                            "action": {
                                "type": "postback",
                                "label": "通知なし",
                                "data": "setting-" + "-".join(data) + "-0",
                            }
                        },
                    ]
                }
            },
        }

    else:
        year = datetime.date.today().year
        contents = [
            {
                "type": "button",
                "style": "secondary",
                "action": {
                    "type": "postback",
                    "label": str(year - i),
                    "data": "setting-" + str(year - i),
                }
            }
            for i in range(3)]
        msg = {
            "type": "flex",
            "altText": "通知設定",
            "contents": {
                "type": "bubble",
                "header": {
                    "type": "box",
                    "layout": "vertical",
                    "contents": [
                        {
                            "type": "text",
                            "size": "lg",
                            "text": "設定"
                        },
                        {
                            "type": "separator",
                            "color": "#000000"
                        },
                        {
                            "type": "text",
                            "text": "入学年度は？"
                        },
                    ]
                },
                "body": {
                    "type": "box",
                    "layout": "vertical",
                    "spacing": "sm",
                    "contents": contents
                }
            },
        }

    return msg
```

####鍵の報告
ここでは[クイックリプライ](https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/)を使ってスムーズに。
(※PC版は対応してないようなので注意)

```python:lambda_function.py
def take_f(data, userid):
    if data:
        name = requests.get(url=profile_url + userid, headers=get_header).json()["displayName"]
        text = update_sheet(room=data[0], key="take", name=name)
        msg = {"type": "text", "text": text}
        postmsg_f(flag="take", room=rooms[int(data[0])])
    else:
        key_reply = {
            "items": [
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋A",
                        "text": "部屋A",
                        "data": "take-0"
                    }
                },
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋B",
                        "text": "部屋B",
                        "data": "take-1"
                    }
                },
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋C",
                        "text": "部屋C",
                        "data": "take-2"
                    }
                },
            ],
        }
        msg = {"type": "text", "text": "どこの鍵？", "quickReply": key_reply}

    return msg


def return_f(data, userid):
    if data:
        name = requests.get(url=profile_url + userid, headers=get_header).json()["displayName"]
        text = update_sheet(room=data[0], key="return", name=name)
        msg = {"type": "text", "text": text}
        postmsg_f(flag="return", room=rooms[int(data[0])])
    else:
        key_reply = {
            "items": [
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋A",
                        "text": "部屋A",
                        "data": "return-0"
                    }
                },
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋B",
                        "text": "部屋B",
                        "data": "return-1"
                    }
                },
                {
                    "type": "action",
                    "action": {
                        "type": "postback",
                        "label": "部屋C",
                        "text": "部屋C",
                        "data": "return-2"
                    }
                },
            ],
        }
        msg = {"type": "text", "text": "どこの鍵？", "quickReply": key_reply}

    return msg
```

####利用状況照会
```python:lambda_function.py
def status_f():
    latest = get_sheet()
    text = "利用状況をお知らせします！\n--------------------------\n"

    for i in range(3):
        if latest[i][1]:
            text += rooms[i] + " : closed"
        else:
            text += rooms[i] + " : open"
        if i < 2:
            text += "\n"

    return {"type": "text", "text": text}
```

####Pushメッセージ送信
```python:lambda_function.py
def postmsg_f(flag, room=None):
    payload = {
        "messages": [mk_postmsg(room, flag)]
    }
    post_url = broadcast_url
    to = []
    if flag == "take":
        target = get_users(table="users", key="notice", value=2, index="notice-userid-index")
        target.extend(get_users(table="users", key="notice", value=3, index="notice-userid-index"))
        # target =[
        #     {'notice': 2, 'year': 2017, 'userid': '...UserId_1...'}, 
        #     {'notice': 3, 'year': 2019, 'userid': '...UserId_2...'}, 
        #     {'notice': 3, 'year': 2017, 'userid': '...UserId_3...'}
        # ]
        to = [item["userid"] for item in target]
        payload["to"] = to
        post_url = multicast_url

    elif flag == "return":
        target = get_users(table="users", key="notice", value=1, index="notice-userid-index")
        target.extend(get_users(table="users", key="notice", value=3, index="notice-userid-index"))
        # target =[
        #     {'notice': 1, 'year': 2017, 'userid': '...UserId_4...'}, 
        #     {'notice': 3, 'year': 2019, 'userid': '...UserId_2...'}, 
        #     {'notice': 3, 'year': 2017, 'userid': '...UserId_3...'}
        # ]
        to = [item["userid"] for item in target]
        payload["to"] = to
        post_url = multicast_url

    r = requests.post(url=post_url, data=json.dumps(payload), headers=post_header)

def mk_postmsg(room, timing):
    text = ""

    if timing == "take":
        text = room + "の鍵が借りられました。"
    elif timing == "return":
        text = room + "の鍵が返却されました。"
    elif timing == "remind":
        latest = get_sheet()
        for i in range(3):
            if not(latest[i][1]):
                text += rooms[i] + "の鍵は返却しましたか？\n"
        text += "返却済でしたら操作を行ってください。" if text else ""
    else:
        pass
    return {"type": "text", "text": text}
```

####lambda_handler
Lambdaによって実行される関数です。
remindはIFTTTで起動させることにしました。
IFTTTは指定の時刻で``{"events": [{ "type": "remind"}]}``というJSONをPOSTします。

```python:lambda_function.py
def lambda_handler(event, context):
    events = event["events"][0]
    event_type = events["type"]
    data = {}
    if event_type == "message":
        data = message_f(events)
    elif event_type == "postback":
        data = postback_f(events)
    elif event_type == "remind":
        postmsg_f(flag="remind")
    else:
        others(events)
    r = requests.post(url=reply_url, data=json.dumps(data), headers=post_header)
```
#完成物
設定
<img width="375" alt="LINE_capture_581487032.305074.JPG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205561/8b2653c4-0c52-a7be-749e-987af8358361.jpeg">
鍵の報告
<img width="352" alt="スクリーンショット 2019-06-06 14.13.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205561/47a26ec7-fc12-af25-1327-28eb7c9b60a8.png">
利用状況確認
<img width="375" alt="LINE_capture_581487472.957566.JPG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205561/6aff4f9b-3d9a-edf0-7337-df134e642a10.jpeg">


#終わりに
今回初めてLambdaを使ってみましたが案外簡単に使えると感じました。Pythonが動くのは本当にいいですよね。
GASは前回も使ったのでサクッと書けてよかったです。

最後に、所属団体の皆さんは今回も僕の遊び&自己満足にお付き合いいただきありがとうございました。

おしまい

#公式リンク
[Messaging API 公式ドキュメント](https://developers.line.biz/ja/docs/messaging-api/)
[Lambda開発者ガイド](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html)
[DynamoDB開発者ガイド](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Introduction.html)
[API Gatewayドキュメントとか](https://docs.aws.amazon.com/ja_jp/apigateway/?id=docs_gateway)
[KMSドキュメントとか](https://docs.aws.amazon.com/ja_jp/kms/?id=docs_gateway)
[GAS(Spreadsheet)のリファレンス](https://developers.google.com/apps-script/reference/spreadsheet/)