n番煎じですがLINEBot(LINE公式アカウント)とAWS lambdaとかを使ってサービスを作りました
ver1.0はこちら
したいこと
- 借用返却の管理
- ログを残す
- 希望者には通知を送る
- 鍵を返し忘れてたら通知する
##使ったもの
- Messaging API
- AWS
- Lambda
- DynamoDB
- GoogleSheets(GoogleAppsScript)
#詳しく
##Messaging API
腐る程記事があるので割愛
アクセストークンメモってWebhookにLambdaのトリガーURLを設定する(後述)
プランが2019年の春に変更されたので乗り換えました。詳細
これにより以前のDeveloper Trialプランで課題だった友達制限がなくなり、フリープランでは使えなかったPushメッセージが使えるようになりました(制限あり)。
小規模サービスをチマチマ作ってる人間にはありがたいです。
###イベント例
公式リファレンス
今回はmessageとpostbackを使いました。
{
"destination": "xxxxxxxxxx",
"events": [
{
"replyToken": "hogehoge",
"type": "message",
"timestamp": 1462629479859,
"source": {
"type": "user",
"userId": "U4af4980629..."
},
"message": {
"id": "325708",
"type": "text",
"text": "Hello, world"
}
}
]
}
{
"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 の違い)
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
を入れとく
本当は並列処理とかも考えるべきなんでしょうが、そんなに大人数で使わないので考えませんでした。
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にデータを出し入れする関数
参考
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とデータをやり取りする関数
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→返す時
としました。
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を使うことでかっこよい感じで。
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版は対応してないようなので注意)
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
####利用状況照会
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メッセージ送信
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します。
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)
#終わりに
今回初めてLambdaを使ってみましたが案外簡単に使えると感じました。Pythonが動くのは本当にいいですよね。
GASは前回も使ったのでサクッと書けてよかったです。
最後に、所属団体の皆さんは今回も僕の遊び&自己満足にお付き合いいただきありがとうございました。
おしまい
#公式リンク
Messaging API 公式ドキュメント
Lambda開発者ガイド
DynamoDB開発者ガイド
API Gatewayドキュメントとか
KMSドキュメントとか
GAS(Spreadsheet)のリファレンス