LoginSignup
4
4

More than 3 years have passed since last update.

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

Posted at

n番煎じですがLINEBot(LINE公式アカウント)とAWS lambdaとかを使ってサービスを作りました
ver1.0はこちら

わかりやすいアレ

スクリーンショット 2019-06-06 13.07.41.png

したいこと

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

使ったもの

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

詳しく

Messaging API

腐る程記事があるので割愛
アクセストークンメモってWebhookにLambdaのトリガーURLを設定する(後述)
プランが2019年の春に変更されたので乗り換えました。詳細
これにより以前のDeveloper Trialプランで課題だった友達制限がなくなり、フリープランでは使えなかったPushメッセージが使えるようになりました(制限あり)。
小規模サービスをチマチマ作ってる人間にはありがたいです。

イベント例

公式リファレンス
今回はmessageとpostbackを使いました。

messageイベント
{
    "destination": "xxxxxxxxxx",
    "events": [
        {
            "replyToken": "hogehoge",
            "type": "message",
            "timestamp": 1462629479859,
            "source": {
                "type": "user",
                "userId": "U4af4980629..."
            },
            "message": {
                "id": "325708",
                "type": "text",
                "text": "Hello, world"
            }
        }
    ]
}
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 の違い)

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

参考
公式ガイド
こちらもたくさん記事があるので詳しくは割愛
登録データは

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

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

API Gateway

こちらの記事とかを参考にごにょごにょやったらできます
LineDevelopersの設定画面でWebhookのエンドポイントをGatewayのurlにする。

KMS

LINEのアクセストークンとGASのurlを暗号化するために利用

Lambda

環境変数には

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

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

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にデータを出し入れする関数

参考

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とデータをやり取りする関数

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→返す時

としました。

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を使うことでかっこよい感じで。

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

鍵の報告

ここではクイックリプライを使ってスムーズに。
(※PC版は対応してないようなので注意)

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

利用状況照会

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メッセージ送信

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します。

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)

完成物

設定
LINE_capture_581487032.305074.JPG
鍵の報告
スクリーンショット 2019-06-06 14.13.39.png
利用状況確認
LINE_capture_581487472.957566.JPG

終わりに

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

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

おしまい

公式リンク

Messaging API 公式ドキュメント
Lambda開発者ガイド
DynamoDB開発者ガイド
API Gatewayドキュメントとか
KMSドキュメントとか
GAS(Spreadsheet)のリファレンス

4
4
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
4