この記事は,室蘭工業大学データサイエンス研究室の DSL Advent Calendar 2019 17日目の記事です。
おうちから誰が研究室にいるか見られたら,研究室に行くかどうかの参考になりますよね(そんなの気にせずに行けよっていうのは今回置いておいて)。
そこで,Google Cloud と LINE Beacon を使って研究室に在室している人を返す slack ボットを作ります。
応用すれば,会社の出勤者一覧とかも作れるかもしれません。
前提条件
- (通常の利用範囲であれば)無料で使えるようにする
環境
- LINE
- Slack
- Google Cloud Platform (GCP)
- Cloud Functions
- Datastore
- LINE Beacon
- 今回 Raspberry Pi を LINE Simple Beacon にします
システムの概略
上のような組み合わせでシステムを実現します。
具体的なケースごとの手順は以下の通りに設定します。
新たにメンバーを追加するとき
- LINE アカウントを友達登録
- LINE のユーザ識別子 (LINE UID) をトークに送信
- 新メンバーは LINE UID を Slack BOT に渡す
- Slack BOT は Datastore に LINE UID を保存する
LINE UID は Slack のユーザ識別子 (Slack UID) と紐づけるために使用します。
Slack BOT には Slack Slash Commands を利用して LINE UID を渡します (/lab_now set <LINE UID>
)。
メンバーが研究室に入室したとき
- 入室者のスマートフォンで LINE Beacon を受信
- LINE は入室を検知
- LINE の Webhook を利用して Google Cloud Functions を呼び出す
- Google Cloud Functions は入室者 LINE UID と入室時刻を Datastore に保存する
5. Beacon の受信内容をそのまま記録 (Datastore key:Beacon
)
6. LINE UID から Slack UID に変換して入室を記録 (Datastore key:RoomStayRecord
)
メンバーが研究室から退出したとき
- 退出者のスマートフォンに LINE Beacon の信号が途絶える
- LINE は退出を検知
- LINE の Webhook を利用して Google Cloud Functions を呼び出す
- Google Cloud Functions は退出者 LINE UID と退出時刻を Datastore に保存する
5. Beacon の受信内容をそのまま記録 (Datastore key:Beacon
)
6. LINE UID から Slack UID に変換して入室を記録 (Datastore key:RoomStayRecord
)
メンバーの脱退フロー
未実装
Slack で今いるメンバーを確認する
- Slack 上でコマンドを打ち込む (
/lab_now
) - Slack Slash Commands の Webhook を利用して Google Cloud Functions を呼び出す
- Google Cloud Functions は Datastore の登録データを元に在室者一覧を生成し,Slack に投稿する(ただし未登録ユーザには返さない)
その他 Slack Commands
-
/lab_now status
: 現在のステータス(デバッグ用,動作しない機能あり) -
/lab_now logs
: 直近10回分の入退室情報(デバッグ用)
準備
LINE Developers の登録と Channel の作成
LINE Beacon を使うため,LINE Developers の登録が必要です。
LINE Developers の登録には LINE アカウントまたは LINE ビジネスアカウントが必要です。
- LINE Developers にアクセス
- 右上または画面中央部にある「ログイン」をクリック
- LINE アカウントまたは LINE ビジネスアカウントでログイン
- (初回のみ)開発者登録
- LINE Developers の console が表示される
日本語表示に変更できますが,この記事執筆開始時点では気づいていないため,LINE Developers 上での作業の説明は全て英語版になっています。
DSL Advent Calendar 2019 16日目,LINE Messaging APIのテンプレートメッセージでチャットBOTを作る では日本語の画面で説明してあり,中身も同じなのでそちらも参照してください。
console が表示されたらまずは,「Provider」を作成しましょう(Provider がない場合)。
既にある Provider を使う場合は Provider 名をクリックします。
Provider を選択したら 「Channel」 を作成します。
「Create a new channel」をクリックすると下のように Channel の種類を聞かれるので,真ん中の「Messaging API」をクリックしてください。
下のように「Create a channel」が表示されるので指示に従い順に入力します。
「Create」をクリックして次に進むと「情報利用に関する同意について」が表示されるので内容を確認して「同意する」をクリックしてください。
次に 「Messaging API」のタブを開き,ページ下部に移動し「Auto-reply messages」の横の「Edit」というリンクをクリックしてください。
「Auto-reply messages」,「Greeting messages」は無効に,「Webhook」は有効にするため,下のように設定します。
それぞれクリックするだけで保存されるので閉じて,LINE Developers に戻ったら,ページ一番下部の「Channel access token」の「Channel access token (long-lived)」から「Issue」ボタンをクリックしてください。
上の図から下の図のように変わると思います。
ここに表示されている access token は後ほど使うので,コピーしておくか,あとで同じページを開けるようにしてください。
access token はこの BOT からの投稿であることを示すために使うものなので,外部に公開しないでください(この記事の access token は記事作成後,access token を無効化し,BOT を削除しています)。
Channel の作成方法についてはぜひ DSL Advent Calendar 2019 16日目,LINE Messaging APIのテンプレートメッセージでチャットBOTを作る も参照してください。
Slack
既に Slack のワークスペースはあるものとして話を進めます。
Slack Custom Integrations の Slash Commands を用いるので追加などができるように権限をもらうか権限のある人に操作してもらってください。
GCP: Google Cloud Platform
Google Cloud プラットフォーム にアクセスしてください。
ログインすると初めての方は以下のような画面になると思います。
利用規約に同意のチェックをつけて,「同意して続行」すると次のような画面になります。
上にある,「プロジェクトの選択」をクリックして,下の画面の「新しいプロジェクト」をクリックしてください。
「新しいプロジェクト」という画面が出てくるので,プロジェクト名を入力するなどしたら,「作成」をクリックしてプロジェクトを作成してください。
作成中のプログレスバーが表示されるので,完了するのを待ってください。
完了したら,先ほどの「プロジェクトを選択」から新しく作成したプロジェクトを選択して「開く」をクリックしてください。
プロジェクトを開いたら,画面上部の検索バーにサービス名を入力するとそのサービスのページが開かれるので,Datastore と Cloud Functions を有効化しましょう。
Datastore の有効化
画面上部の検索バーに「Datastore」と入力します。
下のようになるので,1つ目の Datastore をクリックしてください。
下の画面になったら,今回は「Datastore モード」を選択します。
ネイティブ モードと Datastore モードの違いなどは Google Cloud: Cloud Datastore ドキュメント: ネイティブ モードと Datastore モードからの選択 を確認してください。
次は,ロケーションの選択画面が出てくるので,今回は「asia-northeast1(Tokyo)」を選択してデータベースを作成します。
Cloud Functions の有効化
画面上部の検索バーに「Cloud Functions」と入力します。
下の図でいう2番目の検索結果をクリックします。
もし,「Cloud Functions を利用するためには無料トライアルにご登録ください」と表示されたら
Google Datastore
エンティティと呼ばれる,SQL なデータベースで言うところのテーブルのようなものを作成します。
はじめに Google Datastore を開きます。
ページ上部の「エンティティを作成」をクリックして作成画面を開きます。
以下の4つのエンティティを作成します:
-
Line
(LINE UID と友達登録の状態管理) -
LineSlackID
(LINE UID と Slack UID,Slack Team ID の紐付け) -
Beacon
(LINE Beacon のイベント記録) -
RoomStayRecord
(LINE Beacon のイベントを元に Slack UID ごとのイベントに直したものを記録)
Line
LineSlackID
Beacon
RoomStayRecord
インデックスの設定
複合インデックスと呼ばれる,複数のプロパティからなるインデックスを作成します。
Google Datastore では,クエリ検索するためには条件にあったインデックスが必要なためです。
gcloud
コマンドのインストール
今回は macbook 上で作業していますので HomeBrew を使ってインストールします。
Linux でも HomeBrew がインストールされていれば,同様の手順で可能だと思います。
(Windows の方はウェブ検索してもらうと出てくると思います。)
$ brew install gcloud
gcloud ログイン
以下のコマンドを入力してください:
$ gcloud auth login
するとブラウザが立ち上がり,ログイン画面が出てくると思うので,通常通りログインしてください。
ログインが完了するとコマンドも終了します。
複合インデックスの設定
実際に複合インデックスの設定をします。
複合インデックスの設定は yaml ファイルで記述します。
indexes:
- kind: LineSlackID
properties:
- name: slack_team_id
- name: line_id
- name: created_at
direction: desc
- kind: LineSlackID
properties:
- name: line_id
- name: slack_id
- name: created_at
direction: desc
- kind: RoomStayRecord
properties:
- name: slack_id
- name: created_at
direction: desc
- kind: RoomStayRecord
properties:
- name: slack_id
- name: timestamp
direction: desc
- kind: RoomStayRecord
properties:
- name: slack_id
- name: timestamp
設定ファイルを作成したら,反映しましょう:
$ gcloud config set project <プロジェクトID>
$ gcloud datastore indexes create index.yaml
Google Cloud Functions: LINE API 向け
Google Cloud Functions を開いてください。
「関数を作成」をクリックして関数の作成画面を出してください。
それぞれの項目は以下のように設定します:
- 名前:
line_webhook
- 割り当てられるメモリ: 128 MB
- トリガー: HTTP
- 「認証」未承認の呼び出しを許可する: チェック
- ソースコード: インライン エディタ
- ランタイム: Python 3.7
- 実行する関数:
on_request
(ソースコードの入力欄の下です)
main.py
はプログラムの本体です:
import os
from logging import basicConfig, getLogger, DEBUG
from datetime import datetime, timezone, timedelta
import requests
import json
# Imports the Google Cloud client library
from google.cloud import datastore
basicConfig(level=DEBUG)
logger = getLogger(__name__)
dc = datastore.Client()
def on_request(request):
"""Responds to any HTTP request.
Args:
request (flask.Request): HTTP request object.
Returns:
The response text or any set of values that can be turned into a
Response object using
`make_response <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>`.
"""
logger.info(request)
logger.info(request.method)
request_json = request.get_json(force=False, silent=True, cache=True) # リクエストをパース
logger.info(request_json)
if len(request_json['events']) == 1:
event = request_json['events'][0]
if event['type'] == 'beacon':
# Beacon の記録を保存
item_key = dc.key("Beacon")
jst = timezone(timedelta(0, 32400))
item = datastore.Entity(key=item_key)
item['line_id'] = event['source']['userId']
item['timestamp'] = event['timestamp']
item['type'] = event['beacon']['type']
item['created_at'] = datetime.now(jst)
item['updated_at'] = datetime.now(jst)
dc.put(item)
# LINE UID から Slack UID を求める
query = dc.query(kind='LineSlackID')
query.add_filter('line_id', '=', str(event['source']['userId']))
query.distinct_on = ['slack_id']
query.order = ['slack_id', '-created_at']
slack_id_list = [i.get('slack_id') for i in query.fetch()]
item_key = dc.key("RoomStayRecord")
for slack_id in slack_id_list:
# Beacon のデータを元に記録をつける
item = datastore.Entity(key=item_key)
item['slack_id'] = slack_id
item['timestamp'] = event['timestamp']
item['type'] = event['beacon']['type']
item['created_at'] = datetime.now(jst)
item['updated_at'] = datetime.now(jst)
dc.put(item)
elif event['type'] == 'follow' or event['type'] == 'unfollow':
# LINE の友達追加・解除時
# イベントを記録
item_key = dc.key("Line")
jst = timezone(timedelta(0, 32400))
item = datastore.Entity(key=item_key)
item['line_id'] = event['source']['userId']
item['timestamp'] = event['timestamp']
item['type'] = event['type']
item['created_at'] = datetime.now(jst)
item['updated_at'] = datetime.now(jst)
dc.put(item)
if event['type'] == 'follow':
# 友達追加時
# メッセージを送信
r = requests.post(
'https://api.line.me/v2/bot/message/reply',
json.dumps({
'replyToken': event['replyToken'],
'messages': [
{
"type": "text",
"text": f"あなたの userId は"
},
{
"type": "text",
"text": f"{event['source']['userId']}"
},
{
"type": "text",
"text": f"です。\nLine Beacon の設定をオンにし,slack 側からも登録操作を行ってください。"
}
]
}),
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {os.getenv("access_token")}'
}
)
logger.info(f'Line follow message: {r.status_code}')
return f''
requirements.txt
は Google Cloud Datastore のクライアントライブラリを指定します:
# Function dependencies, for example:
# package>=version
google-cloud-datastore==1.7.3
その後,さらに下の「環境変数、ネットワーキング、タイムアウトなど」をクリックしてください。
一番下の「環境変数を追加」をクリックします。
入力フォームが表示されるので,下のように設定します:
- 名前:
access_token
- 値: LINE Developers 上で発行した LINE messaging API の Channel access token
ここまで終わったらページ上部に戻り,URL をコピーしてください。
最後にページ一番下の「作成」ボタンをクリックします。
LINE Developers に戻り,Channel の Messaging API の Webhook settings にある Webhook URL の Edit ボタンを押し,コピーした URL を貼り付けたら,Update をクリックしてください。
Update をクリックすると,「Use webhook」が表示されるので,右横のスイッチをクリックして有効にしてください。
これで LINE messaging API 周りは完了です。
ちなみに,Verify をクリックすれば,ちゃんと status code が 200 で成功した旨が表示されるはずなので,達成感を味わってみてください。
あと,友達登録をするとちゃんとメッセージが届くはずです。
LINE Simple Beacon
LINE Beacon は対応のデバイスが必要ですが,LINE Simple Beacon なら逸般の誤家庭には必ずある Raspberry Pi を使って LINE Beacon の一部機能で遊ぶことができます。
- LINE Official Account Manager: LINE Beacon にアクセス
- 「LINE Simple BeaconのハードウェアIDを発行」をクリック
- 「アカウントリスト」が表示されるので今回用いるアカウントを「選択」
- 「ハードウェアIDを発行」をクリック
すると以下のように 10桁のハードウェアID (HWID) が発行されます。
あとは LINE ENGINEERING: ラズベリーパイでLINE Beaconが作成可能に!「LINE Simple Beacon」仕様を公開しました にしたがって Raspberry Pi をセットアップしてください。
Google Cloud Functions: Slack API 向け
Google Cloud Functions: LINE API 向け と同じような手順で作成します。
Google Cloud Functions を開いてください。
「関数を作成」をクリックして関数の作成画面を出してください。
それぞれの項目は以下のように設定します:
- 名前:
slack_webhook
- 割り当てられるメモリ: 128 MB
- トリガー: HTTP
- 「認証」未承認の呼び出しを許可する: チェック
- ソースコード: インライン エディタ
- ランタイム: Python 3.7
- 実行する関数:
on_request
(ソースコードの入力欄の下です)
from logging import basicConfig, getLogger, DEBUG
from datetime import datetime, timezone, timedelta
import re
import json
# Imports the Google Cloud client library
from google.cloud import datastore
basicConfig(level=DEBUG)
logger = getLogger(__name__)
dc = datastore.Client()
def stay_room_time(slack_id, days=1):
"""
在室時間を秒数で返す.
days: 計算日数
1: last day
3: 3 days
"""
jst = timezone(timedelta(0, 32400))
today = datetime.fromisoformat(datetime.now(jst).date().isoformat())
start_dt = today.replace(tzinfo=jst, hour=4) - timedelta(days=days)
end_dt = today.replace(tzinfo=jst, hour=4) - timedelta(days=0, microseconds=1)
query = dc.query(kind='RoomStayRecord')
query.add_filter('slack_id', '=', slack_id)
query.add_filter('timestamp', '<=', int(start_dt.timestamp() * 1000))
query.add_filter('timestamp', '>=', int(end_dt.timestamp() * 1000))
query.order = ['timestamp']
prev_type = ''
prev_time = 0
times = 0
for i in query.fetch():
if not i.get('type') == prev_type:
if i.get('type') == 'leave' and not prev_type == '':
times = times + ((i.get('timestamp') - prev_time) // 1000)
prev_time = i.get('timestamp')
prev_type = i.get('type')
return times # seconds
def on_request(request):
"""Responds to any HTTP request.
Args:
request (flask.Request): HTTP request object.
Returns:
The response text or any set of values that can be turned into a
Response object using
`make_response <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>`.
"""
if request.method == 'POST':
logger.info(request.form)
if request.form['command'] == '/lab_now':
# コマンドが叩かれたとき (`/lab_now` 以外にする場合は要変更)
if request.form['text'] == '':
# `/lab_now`
query = dc.query(kind='LineSlackID')
query.add_filter('slack_team_id', '=', str(request.form['team_id']))
query.distinct_on = ['line_id']
query.order = ['line_id', '-created_at']
slack_id_list = [i.get('slack_id') for i in query.fetch()]
if request.form['user_id'] not in slack_id_list:
# 未登録メンバーは見れないようにする
return "撃っていいのは撃たれる覚悟のある奴だけだ"
query = dc.query(kind='RoomStayRecord')
query.distinct_on = ['slack_id']
query.order = ['slack_id', '-timestamp']
return "現在の在室メンバーは" + "".join([f"<@{i.get('slack_id')}>" for i in query.fetch() if i.get('type', '') == 'enter' and i.get('slack_id') in slack_id_list]) + "です!"
elif request.form['text'].startswith('set '):
# `/lab_now set <LINE_UID>`
m = re.match(r'set (U[0-9a-f]{32})', request.form['text']) # LINE UID を抽出
# Slack UID と LINE UID を紐付けする
item_key = dc.key("LineSlackID")
jst = timezone(timedelta(0, 32400))
item = datastore.Entity(key=item_key)
item['line_id'] = m.group(1)
item['slack_id'] = str(request.form['user_id'])
item['slack_team_id'] = str(request.form['team_id'])
item['created_at'] = datetime.now(jst)
item['updated_at'] = datetime.now(jst)
dc.put(item)
return '登録しました!'
elif request.form['text'] == 'logs':
# `/lab_now logs`
# 直近10回分の入退室記録を返す
query = dc.query(kind='RoomStayRecord')
query.add_filter('slack_id', '=', str(request.form['user_id']))
query.order = ['-created_at']
return "直近の記録です\n" + "\n".join(map(lambda i: f"{datetime.fromtimestamp(i.get('timestamp')/1000).strftime('%Y/%m/%d %H:%M')} {'入室' if i.get('type') == 'enter' else '退出'}", query.fetch(limit=10)))
elif request.form['text'] == 'status':
# `/lab_now status`
# 自分の現在の入退室ステータスと
# 直近7日間,30日間の在室時間(動作しない)を返す
query = dc.query(kind='RoomStayRecord')
query.add_filter('slack_id', '=', str(request.form['user_id']))
query.order = ['-created_at']
sec_7days = stay_room_time(str(request.form['user_id']), 7)
sec_30days = stay_room_time(str(request.form['user_id']), 30)
hour = 60*60
minute = 60
for i in query.fetch(limit=1):
return f"Now:\t{'入室' if i.get('type') == 'enter' else '退出'}\n"\
+ f"7 days:\t{sec_7days // hour}:{(sec_7days % hour)//minute}\n"\
+ f"30 days:\t{sec_30days // hour}:{(sec_30days % hour)//minute}\n"
return "記録がありません"
return ''
# Function dependencies, for example:
# package>=version
google-cloud-datastore==1.7.3
こちらは環境変数の設定はありません。
このまま,URLをコピーしたあと,関数を作成してください。
Slack Slash Commands
Slack Custom Integrations の Slash Commands にアクセスしてください。
左側の「Add to Slack」ボタンをクリックしてください。
Choose a Command には用いるコマンド(今回は/lab_now
)を入力して「Add Slash Command Integration」をクリックしてください。
すると設定ページが表示されるので,下の方にスクロールして URL の設定欄に先ほどコピーした URL を貼り付けます。
その他,弊研究室の設定は以下の通りです:
- Customize Name:
在室状況
- Customize Icon: デフォルトのまま
- Autocomplete help text
- Show this command in the autocomplete list: 有効
- Description:
在室状況
- Usage hint:
[set <your line id>|status|logs]
- Escape channels, users, and links: Off
- Descriptive Label: 設定なし
- Translate User IDs
- Translate global enterprise IDs to local workspace IDs: 有効
設定が終わったら「Save Integration」をクリックしてください
おわりに
いかがでしょうか?
今回は GCP を用いました。
- Google Cloud Datastore (1 日あたりの無料利用枠)
- 保存データ: 1GB ストレージ
- エンティティの読み込み数: 50,000
- エンティティの書き込み: 20,000
- エンティティの削除数: 20,000
- Cloud Functions (1ヶ月ごとの無料利用枠)
- 月間呼び出し回数: 200 万回
おそらく研究室ぐらいの規模であれば無料枠の範囲内で済むと思います。
なお,しばらく時間が開くと Functions を起動し直すことになり,時間がかかるため Command がタイムアウトになることがあります。
その場合はもう一度実行し直してください。
今後の課題
LINE Developers: Messaging APIリファレンス - ビーコンイベント で示されているように,今回用いた LINE Beacon のうち,退室を検知するために用いた leave
イベントは 廃止予定 になっています。
そのためか,退室はあまり精度が高くありません。
また,短時間で入退室を繰り返す挙動になることもしばしばあります。
最近,stay
イベントが追加されたようで,これらの課題を解決できるかもしれませんが,LINE for Businessウェブサイトから問い合わせる必要があり,ハードルがあります。
今後,一般利用できるようになると良いですね。
また,Web 上や Slack 上で記録を編集や確認できるようにしたり,未実装であるここ数日間の在室時間を計算できるようになるといろいろ使い道が出るかもしれません(弊学の学部生がつける研究室在室記録の自動化など)。
参考
- Google Cloud: Cloud Functions のドキュメント - Python クイックスタート
- LINE Developers: Messaging APIリファレンス
- エンジニアはこわくない: gcloudコマンドでGCPプロジェクトの切り替え方法
- Datastore モードの Cloud Firestore ドキュメント: インデックスの構成
- Datastore モードの Cloud Firestore ドキュメント: Datastore クエリ
- LINE ENGINEERING: ラズベリーパイでLINE Beaconが作成可能に!「LINE Simple Beacon」仕様を公開しました
- Google Cloud: Google Cloud Platform - アーキテクチャ図用のアイコンのライブラリ
- Isaax Camp: シングルボードコンピューターのピクトグラム
あとがき
なんでこんな超大作になってしまったんだ…
明日も私で,Heroku 上で稼働している Slack APP の開発のお話の予定です。
アドカレ書きながら本番やらかししたやつおる??
— 高木さん (𝔂_𝓴) (@YetAnother_yk) December 16, 2019