3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

在室している人を返す Slack ボットを作る

Last updated at Posted at 2019-12-17

この記事は,室蘭工業大学データサイエンス研究室の DSL Advent Calendar 2019 17日目の記事です。

おうちから誰が研究室にいるか見られたら,研究室に行くかどうかの参考になりますよね(そんなの気にせずに行けよっていうのは今回置いておいて)。
そこで,Google Cloud と LINE Beacon を使って研究室に在室している人を返す slack ボットを作ります。
応用すれば,会社の出勤者一覧とかも作れるかもしれません。

movie.gif

前提条件

  • (通常の利用範囲であれば)無料で使えるようにする

環境

  • LINE
  • Slack
  • Google Cloud Platform (GCP)
    • Cloud Functions
    • Datastore
  • LINE Beacon
    • 今回 Raspberry Pi を LINE Simple Beacon にします

システムの概略

image.png

上のような組み合わせでシステムを実現します。

具体的なケースごとの手順は以下の通りに設定します。

新たにメンバーを追加するとき

  1. LINE アカウントを友達登録
  2. LINE のユーザ識別子 (LINE UID) をトークに送信
  3. 新メンバーは LINE UID を Slack BOT に渡す
  4. Slack BOT は Datastore に LINE UID を保存する

LINE UID は Slack のユーザ識別子 (Slack UID) と紐づけるために使用します。
Slack BOT には Slack Slash Commands を利用して LINE UID を渡します (/lab_now set <LINE UID>)。

メンバーが研究室に入室したとき

  1. 入室者のスマートフォンで LINE Beacon を受信
  2. LINE は入室を検知
  3. LINE の Webhook を利用して Google Cloud Functions を呼び出す
  4. Google Cloud Functions は入室者 LINE UID と入室時刻を Datastore に保存する
    5. Beacon の受信内容をそのまま記録 (Datastore key: Beacon)
    6. LINE UID から Slack UID に変換して入室を記録 (Datastore key: RoomStayRecord)

メンバーが研究室から退出したとき

  1. 退出者のスマートフォンに LINE Beacon の信号が途絶える
  2. LINE は退出を検知
  3. LINE の Webhook を利用して Google Cloud Functions を呼び出す
  4. Google Cloud Functions は退出者 LINE UID と退出時刻を Datastore に保存する
    5. Beacon の受信内容をそのまま記録 (Datastore key: Beacon)
    6. LINE UID から Slack UID に変換して入室を記録 (Datastore key: RoomStayRecord)

メンバーの脱退フロー

未実装

Slack で今いるメンバーを確認する

  1. Slack 上でコマンドを打ち込む (/lab_now)
  2. Slack Slash Commands の Webhook を利用して Google Cloud Functions を呼び出す
  3. 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 ビジネスアカウントが必要です。

  1. LINE Developers にアクセス
  2. 右上または画面中央部にある「ログイン」をクリック
  3. LINE アカウントまたは LINE ビジネスアカウントでログイン
  4. (初回のみ)開発者登録
  5. 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」をクリックしてください。

image.png

下のように「Create a channel」が表示されるので指示に従い順に入力します。

image.png

「Create」をクリックして次に進むと「情報利用に関する同意について」が表示されるので内容を確認して「同意する」をクリックしてください。

下のような画面になれば Channel 作成は完了です。
image.png

次に 「Messaging API」のタブを開き,ページ下部に移動し「Auto-reply messages」の横の「Edit」というリンクをクリックしてください。
「Auto-reply messages」,「Greeting messages」は無効に,「Webhook」は有効にするため,下のように設定します。

image.png

それぞれクリックするだけで保存されるので閉じて,LINE Developers に戻ったら,ページ一番下部の「Channel access token」の「Channel access token (long-lived)」から「Issue」ボタンをクリックしてください。

image.png

上の図から下の図のように変わると思います。

image.png

ここに表示されている 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 プラットフォーム にアクセスしてください。

ログインすると初めての方は以下のような画面になると思います。

image.png

利用規約に同意のチェックをつけて,「同意して続行」すると次のような画面になります。

image.png

上にある,「プロジェクトの選択」をクリックして,下の画面の「新しいプロジェクト」をクリックしてください。

「新しいプロジェクト」という画面が出てくるので,プロジェクト名を入力するなどしたら,「作成」をクリックしてプロジェクトを作成してください。

作成中のプログレスバーが表示されるので,完了するのを待ってください。
完了したら,先ほどの「プロジェクトを選択」から新しく作成したプロジェクトを選択して「開く」をクリックしてください。

プロジェクトを開いたら,画面上部の検索バーにサービス名を入力するとそのサービスのページが開かれるので,Datastore と Cloud Functions を有効化しましょう。

Datastore の有効化

画面上部の検索バーに「Datastore」と入力します。

下のようになるので,1つ目の Datastore をクリックしてください。
image.png

下の画面になったら,今回は「Datastore モード」を選択します。

image.png

ネイティブ モードと Datastore モードの違いなどは Google Cloud: Cloud Datastore ドキュメント: ネイティブ モードと Datastore モードからの選択 を確認してください。

次は,ロケーションの選択画面が出てくるので,今回は「asia-northeast1(Tokyo)」を選択してデータベースを作成します。
image.png

Cloud Functions の有効化

画面上部の検索バーに「Cloud Functions」と入力します。
下の図でいう2番目の検索結果をクリックします。
image.png

すると,下のようになりましたでしょうか?
image.png

もし,「Cloud Functions を利用するためには無料トライアルにご登録ください」と表示されたら

下のように出た場合には,「無料トライアルに登録」をクリックしてください。
image.png

すると下のような画面になると思います。
image.png

どうやら,支払いの情報がないと Cloud Functions は使えないようなのです。
指示に従い入力・操作します。

すると,「ようこそ」とトライアル有効化の案内が出るので,閉じれば下の画面になります。
image.png

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

以下のように設定します。
image.png

LineSlackID

以下のように設定します。
image.png

Beacon

以下のように設定します。
image.png

RoomStayRecord

以下のように設定します。
image.png

インデックスの設定

複合インデックスと呼ばれる,複数のプロパティからなるインデックスを作成します。
Google Datastore では,クエリ検索するためには条件にあったインデックスが必要なためです。

gcloud コマンドのインストール

今回は macbook 上で作業していますので HomeBrew を使ってインストールします。
Linux でも HomeBrew がインストールされていれば,同様の手順で可能だと思います。
(Windows の方はウェブ検索してもらうと出てくると思います。)

$ brew install gcloud

gcloud ログイン

以下のコマンドを入力してください:

$ gcloud auth login

するとブラウザが立ち上がり,ログイン画面が出てくると思うので,通常通りログインしてください。
ログインが完了するとコマンドも終了します。

複合インデックスの設定

実際に複合インデックスの設定をします。
複合インデックスの設定は yaml ファイルで記述します。

index.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 を開いてください。
image.png

「関数を作成」をクリックして関数の作成画面を出してください。
それぞれの項目は以下のように設定します:

  • 名前: line_webhook
  • 割り当てられるメモリ: 128 MB
  • トリガー: HTTP
  • 「認証」未承認の呼び出しを許可する: チェック
  • ソースコード: インライン エディタ
  • ランタイム: Python 3.7
  • 実行する関数: on_request(ソースコードの入力欄の下です)

main.py はプログラムの本体です:

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 のクライアントライブラリを指定します:

requirements.txt
# Function dependencies, for example:
# package>=version
google-cloud-datastore==1.7.3

その後,さらに下の「環境変数、ネットワーキング、タイムアウトなど」をクリックしてください。
image.png

一番下の「環境変数を追加」をクリックします。
入力フォームが表示されるので,下のように設定します:

  • 名前: access_token
  • 値: LINE Developers 上で発行した LINE messaging API の Channel access token

image.png

ここまで終わったらページ上部に戻り,URL をコピーしてください。
最後にページ一番下の「作成」ボタンをクリックします。

LINE Developers に戻り,Channel の Messaging API の Webhook settings にある Webhook URL の Edit ボタンを押し,コピーした URL を貼り付けたら,Update をクリックしてください。

image.png

Update をクリックすると,「Use webhook」が表示されるので,右横のスイッチをクリックして有効にしてください。

image.png

これで LINE messaging API 周りは完了です。
ちなみに,Verify をクリックすれば,ちゃんと status code が 200 で成功した旨が表示されるはずなので,達成感を味わってみてください。
あと,友達登録をするとちゃんとメッセージが届くはずです。

image.png

LINE Simple Beacon

LINE Beacon は対応のデバイスが必要ですが,LINE Simple Beacon なら逸般の誤家庭には必ずある Raspberry Pi を使って LINE Beacon の一部機能で遊ぶことができます。

  1. LINE Official Account Manager: LINE Beacon にアクセス
  2. 「LINE Simple BeaconのハードウェアIDを発行」をクリック
  3. 「アカウントリスト」が表示されるので今回用いるアカウントを「選択」
  4. 「ハードウェアIDを発行」をクリック

すると以下のように 10桁のハードウェアID (HWID) が発行されます。
image.png

あとは LINE ENGINEERING: ラズベリーパイでLINE Beaconが作成可能に!「LINE Simple Beacon」仕様を公開しました にしたがって Raspberry Pi をセットアップしてください。

Google Cloud Functions: Slack API 向け

Google Cloud Functions: LINE API 向け と同じような手順で作成します。

Google Cloud Functions を開いてください。
image.png

「関数を作成」をクリックして関数の作成画面を出してください。
それぞれの項目は以下のように設定します:

  • 名前: slack_webhook
  • 割り当てられるメモリ: 128 MB
  • トリガー: HTTP
  • 「認証」未承認の呼び出しを許可する: チェック
  • ソースコード: インライン エディタ
  • ランタイム: Python 3.7
  • 実行する関数: on_request(ソースコードの入力欄の下です)
main.py
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 ''
requirements.txt
# Function dependencies, for example:
# package>=version
google-cloud-datastore==1.7.3

こちらは環境変数の設定はありません。
このまま,URLをコピーしたあと,関数を作成してください。

Slack Slash Commands

Slack Custom Integrations の Slash Commands にアクセスしてください。

左側の「Add to Slack」ボタンをクリックしてください。

下のような画面になると思います。
image.png

Choose a Command には用いるコマンド(今回は/lab_now)を入力して「Add Slash Command Integration」をクリックしてください。

すると設定ページが表示されるので,下の方にスクロールして URL の設定欄に先ほどコピーした URL を貼り付けます。
image.png

その他,弊研究室の設定は以下の通りです:

  • 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」をクリックしてください

おわりに

movie.gif

いかがでしょうか?
今回は GCP を用いました。

  • Google Cloud Datastore (1 日あたりの無料利用枠)
    • 保存データ: 1GB ストレージ
    • エンティティの読み込み数: 50,000
    • エンティティの書き込み: 20,000
    • エンティティの削除数: 20,000
  • Cloud Functions (1ヶ月ごとの無料利用枠)
    • 月間呼び出し回数: 200 万回

おそらく研究室ぐらいの規模であれば無料枠の範囲内で済むと思います。

なお,しばらく時間が開くと Functions を起動し直すことになり,時間がかかるため Command がタイムアウトになることがあります。
その場合はもう一度実行し直してください。
movie.gif

今後の課題

LINE Developers: Messaging APIリファレンス - ビーコンイベント で示されているように,今回用いた LINE Beacon のうち,退室を検知するために用いた leave イベントは 廃止予定 になっています。
そのためか,退室はあまり精度が高くありません。
また,短時間で入退室を繰り返す挙動になることもしばしばあります。
最近,stay イベントが追加されたようで,これらの課題を解決できるかもしれませんが,LINE for Businessウェブサイトから問い合わせる必要があり,ハードルがあります。
今後,一般利用できるようになると良いですね。

また,Web 上や Slack 上で記録を編集や確認できるようにしたり,未実装であるここ数日間の在室時間を計算できるようになるといろいろ使い道が出るかもしれません(弊学の学部生がつける研究室在室記録の自動化など)。

参考

あとがき

なんでこんな超大作になってしまったんだ…

明日も私で,Heroku 上で稼働している Slack APP の開発のお話の予定です。

3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?