LINE Messaging APIを利用してグループメンバーの誕生日を祝うbotを作成してみた

# 概要
LINEグループで友達の誕生日を祝うとき、たまに誕生日だったの忘れてた...って場面が何度かあり、その問題を解消するためbotが知らせてくれるようにしてみました。
(LINEは友達に誕生日を公開する設定にしていればホームに表示されますが、僕はほとんど見ないんですよね...)

結論からいうと下記画像のようにピッカーで誕生日を登録しておくと、誕生日当日の正午に知らせてくれるようにしました。

|登録ピッカー  |誕生日通知  |
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224949/9c736110-145d-9773-d41e-b2fdeab8412b.jpeg" width=300>  |<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224949/9327945f-0a36-7c46-18c0-73503b81646d.jpeg" width=300>  |

# 実装方法
## 1. まずはMessaging APIを利用するためにDevelopersに登録する
まだログインしていない場合は下記Messaging APIのページにある「今すぐはじめよう」をクリックすると、ログインページに飛ぶのでアカウントを作成しましょう。
身内の間で利用するだけなら個人アカウントの利用で十分かと思います。
https://developers.line.biz/ja/services/messaging-api/

## 2. チャネルの作成
ログイン後は画像のようなチャネル作成画面が表示されるので、入力必須項目を埋めましょう。
例)
1. 種類: Messaging API
2. プロバイダー: 自分の名前
3. チャネル名: 誕生日bot
4. チャネル説明: 誕生日をお知らせします
![スクリーンショット 2021-08-28 15.50.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224949/575afe9f-25fe-17fe-89b8-9cfca1192377.png)

## 3. 実装
今回のbotアプリはpythonで実装してHeroku上で動かし、
誕生日情報はcsvに書き出してFirebase Storageで管理しました。
このあたりはbotのサンプル実装や他の方のbot作成を参考にさせてもらったので、レンタルサーバーやローカルで動かす場合は同じサーバーでファイルを管理してもいいかと思います。 
外部のクラウドストレージで管理しているのはHerokuでは一時的なファイルシステムがあるだけで、そこに保存してもすぐ消えるためです。

**ディレクトリ構成**
まずは適当な名前のディレクトリを作成します。
構成は以下の通りです。

```zsh:~/LineBirthDayBot
├── .profile
├── Procfile // 起動時に実行するコマンドを指定するもの
├── main.py // アプリケーションファイル
├── requirements.txt // moduleのインストール
├── runtime.txt // python実行バージョン
└── scheduler.py // 定期実行ファイル
```

### 3-1. Herokuの設定
Heroku公式ページの方法でHeroku CLIをインストールします。

```zsh
$ brew tap heroku/brew && brew install heroku
```
インストールできたらログインします。

```zsh
$ heroku login
```
次にアプリケーションを登録します。

```zsh
$ heroku create アプリケーション名
```

次に後のソースコードで利用する環境変数を登録しておきます。
下記からコンソールに移動し、チャネルシークレットとアクセストークンを確認し、登録します。
https://developers.line.biz/console/

```zsh
$ heroku config:set YOUR_CHANNEL_SECRET="Channel secret" --app アプリケーション名
$ heroku config:set YOUR_CHANNEL_ACCESS_TOKEN="アクセストークン" --app アプリケーション名
```

次に、誕生日情報ファイル管理するFirebase Storage関連の環境変数を登録します。
firebase storageの詳細な利用方法は割愛します。(Storage rulesでファイルデータにアクセスできるのは管理者だけとなるようにします)
用意するのは認証情報とバケットURLです。
新しく今回のbot用の新規プロジェクトを作成して、プロジェクトの設定 → 秘密キー生成から認証情報を取得してください。
また、バケットURLは`gs://`の部分になります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224949/8f7b0e76-3533-992a-6e9e-aaed01855265.png" width=500>

そして、以下の情報を環境変数に登録します。
ここでうまく認証できずエラーになってつまづいたのですが、参考にあるブログのおかげで解決しました。ありがとうございます。

以下を登録します。

```zsh
$ heroku config:set GOOGLE_CREDENTIALS="$(< ダウンロードした認証情報jsonのパス)" --app アプリケーション名
$ heroku config:set GOOGLE_APPLICATION_CREDENTIALS=/app/google-credentials.json --app アプリケーション名
$ heroku config:set YOUR_CHANNEL_ACCESS_TOKEN="バケットURL" --app アプリケーション名
```

ソースコード上では`GOOGLE_APPLICATION_CREDENTIALS`の環境変数を取得します。
Heroku上の`/app/google-credentials.json`に認証情報が生成されるように.profileに`GOOGLE_CREDENTIALS`の内容のjsonを書き出すようにします。

```zsh:.profile
echo ${GOOGLE_CREDENTIALS} > /app/google-credentials.json
```

### 3-2. 誕生日登録システムの実装
まずは誕生日登録です。
最初は文字列の数字から誕生日を抽出しようかと思ったのですが、概要の画像のようにDatePickerを利用した方がわかりやすいですし、
決まったフォーマットでレスポンスされるので不具合につながりにくいと思いDatePickerで登録するようにしました。

使用するライブラリはrequirements.txtに記載します。
アプリをHeroku上にデプロイすると記載したライブラリがインストールされます。
(バージョンはpip listで確認してください)

```requirements.txt:
Flask==x.x.x
line-bot-sdk==x.x.x
firebase-admin==x.x.x
google-cloud-storage==x.x.x
google-cloud-firestore==x.x.x
```
ローカルでインストールする場合は下記を実行してください。

```zsh
pip install -r requirements.txt
```

LINEからメッセージを受信して、誕生日登録させるアプリケーションは公式サンプルbotと同様にflaskを用いて実装しました。
https://developers.line.biz/ja/docs/messaging-api/building-sample-bot-with-heroku/
全体のコードは以下の通りです。

```python:main.py
from flask import Flask, request, abort
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
    PostbackEvent,TemplateSendMessage,ButtonsTemplate,
    DatetimePickerAction,
)
import firebase_admin
from firebase_admin import credentials
from google.cloud import storage
import os

app = Flask(__name__)
 
# 環境変数
# LINE Messaging APIで設定しているアクセストークンと秘密キー
YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]
# Firebaseの認証情報
GOOGLE_APPLICATION_CREDENTIALS=os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
# Firebaseのバケット先
FIREBASE_STORAGE_BUCKET=os.environ["FIREBASE_STORAGE_BUCKET"]
cred = credentials.Certificate(GOOGLE_APPLICATION_CREDENTIALS)
firebase_admin.initialize_app(cred, {
    'storageBucket': FIREBASE_STORAGE_BUCKET
})

# インスタンス作成
client = storage.Client()
bucket = client.get_bucket(FIREBASE_STORAGE_BUCKET)

line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)
 
@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']
    # リクエストボティを取得する
    body = request.get_data(as_text = True)
    app.logger.info("Request body: " + body)
    try:
        # 署名検証
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return 'OK'
 
# LINEグループからメッセージが送信された場合に実行
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    message = event.message.text # メッセージの内容
    group_id = event.source.group_id # グループID
    user_id = event.source.user_id # ユーザーID
    profile = line_bot_api.get_group_member_profile(group_id, user_id) # ユーザーのプロファイル
    if "誕生日bot" in message and "登録" in message:
        date_picker = TemplateSendMessage(
            alt_text='誕生日を設定',
            template=ButtonsTemplate(
                text=f'{profile.display_name}さんの誕生日を設定します',
                title='誕生日通知システム',
                actions=[
                    DatetimePickerAction(
                        label="誕生日を登録する",
                        data="action=regist&&mode=date",
                        mode="date",
                        initial="2000-01-01",
                        min="1940-01-01",
                        max="2049-12-31"
                        )
                ]
            )
        )
        line_bot_api.reply_message(
            event.reply_token,
            date_picker
        )


# DatePickerから送信されたら実行
@handler.add(PostbackEvent)
def handle_postback(event):
    group_id = event.source.group_id # グループID
    user_id = event.source.user_id # ユーザーID
    profile = line_bot_api.get_group_member_profile(group_id, user_id) # ユーザーのプロファイル
    dateString = event.postback.params['date'] # datePickerから送信された日付
    birthday_triming = dateString.split('-')
    if event.postback.data == 'action=regist&&mode=date':
        registe_birthday(
            group_id,
            profile.display_name,
            user_id,
            dateString
        )
        month = int(birthday_triming[1])
        day = int(birthday_triming[2])
        line_bot_api.reply_message(
            event.reply_token, TextSendMessage(text=f'{profile.display_name}さんが誕生日を登録しました！\n{month}月{day}日に通知します✨'))

# 誕生日情報のファイルを作成/更新し、Firebase Storageにアップロードする
def registe_birthday(group_id,display_name,user_id,birthday):
    file_path = f'birthday/{group_id}.csv' # グループIDをファイル名にする
    blob = bucket.blob(file_path) # ストレージのパスを指定
    # - で分割して年月日を配列に格納
    # (例: birthday_triming[0] = 2021, birthday_triming[1] = 8, birthday_triming[2] = 28)
    birthday_triming = birthday.split('-')
    month = birthday_triming[1]
    day = birthday_triming[2]
    # LINEでの表示名,ユーザー識別ID,月,日を文字列として連結
    write_texts = [f"{display_name},{user_id},{month},{day}"]
    if blob.exists():
        input_file = blob.open()
        datalist = input_file.read().splitlines()
        for line in datalist:
            if f"{user_id}" in line:
                continue
            else:
                write_texts.append(line)
    csv_string_to_upload = "" 
    for text in write_texts:
        csv_string_to_upload += text + "\n"
    # Firebase Storageにアップロードする
    blob.upload_from_string(
        data=csv_string_to_upload,
        content_type='text/csv'
        )

# ポート番号の設定
if __name__ == "__main__":
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```

### 3-2. 誕生日通知システムの実装
次に1日1回、登録された誕生日情報ファイルを取得し、誕生日の日と現在日が一致する人にメッセージを送信するシステムを実装します。
`scheduler.py`はHeroku上でcronで定期実行させます。
Herokuでは様々なアドオンが存在しており、その中に定期実行させる無料アドオンがあるのでそちらを利用します。
下記の**Heroku Scheduler**で画像のようにUTC3時に毎日実行するようにすれば日本のタイムゾーン(Asia/Tokyo)では+9時間なので、正午に`scheduler.py`を実行させることができます。

https://elements.heroku.com/addons/scheduler

![スクリーンショット 2021-08-28 19.02.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224949/218b5569-7603-fcd5-da41-63a15ed2dbee.png)

全体のコードは以下の通りです。

```python:scheduler.py
import os
from datetime import datetime, timedelta, timezone
from linebot import LineBotApi
from linebot.models import TextSendMessage
import firebase_admin
from firebase_admin import credentials
from google.cloud import storage

# 環境変数
# LINE Messaging APIで設定しているアクセストークンと秘密キー
YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
# Firebaseの認証情報
GOOGLE_APPLICATION_CREDENTIALS=os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
# Firebaseのバケット先
FIREBASE_STORAGE_BUCKET=os.environ["FIREBASE_STORAGE_BUCKET"]
cred = credentials.Certificate(GOOGLE_APPLICATION_CREDENTIALS)
firebase_admin.initialize_app(cred, {
    'storageBucket': FIREBASE_STORAGE_BUCKET
})

# インスタンス作成
client = storage.Client()
bucket = client.get_bucket(FIREBASE_STORAGE_BUCKET)
line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)

# 誕生日情報ファイルを取得して該当する人にメッセージを送信する
def checkBirthDay():
    # 日本のタイムゾーンでの現在時刻を取得
    JST = timezone(timedelta(hours=+9), 'JST')
    dt_now = datetime.now(JST)
    now_month = dt_now.month
    now_day = dt_now.day
    for blob in client.list_blobs(FIREBASE_STORAGE_BUCKET,prefix="birthday/"):
        group_id = blob.name.replace('birthday/', '').replace('.csv', '')
        if not group_id:
            continue
        birthday_file = blob.open()
        datalist = birthday_file.read().splitlines()
        for data in datalist:
            try:
                array = data.split(',')
                display_name = array[0]
                month = int(array[2])
                day = int(array[3])
                if now_month == month and now_day == day:
                    pushText = TextSendMessage(text=f"{display_name}さん、誕生日おめでとう！")
                    line_bot_api.push_message(group_id, pushText)
                else:
                    continue
            except:
                continue 
        birthday_file.close()

if __name__ == "__main__":
    checkBirthDay()
```

# 感想
普段はiOSアプリの開発をしているので、bot開発が初めてで面白かったです。
無料で簡単に作成できたので、ぜひ一度試してみてください！

## 苦労した点
- ファイル管理はクラウドストレージで

Heroku上ではファイルを保存することができないことを知らなかったので、ローカルでテストしたときは問題なかったのですが、デプロイしたらうまく動きませんでした。その後は普段アプリ開発で利用しているFirebase Strorage上に保存することにしましたが、認証がうまくいかず苦労しました。

- Pickerの年の部分は必要ないのに消せない
 
誕生日情報は月と日だけしか登録しないようにしているので、
DatePickerで年の部分を消すことができればよかったのですが、その方法がわからなかったです。

## 面白かった点
- LINE Message APIのメッセージタイプが豊富にある

今回のdatePickerはテンプレートメッセージの日時選択アクションを利用しました。
他にもカルーセルだったり、アクションもカメラなど色々とあるのでいろんなことができそうです。

https://developers.line.biz/ja/docs/messaging-api/message-types/

https://developers.line.biz/ja/docs/messaging-api/actions/


## 気になった点

- botをグループ内に招待すると既読がつく
    - グループ内で発言すると、だれもまだ見てないのに既読がつくので少し困ります。。
- privateなbotが作れず、publicな状態なので運用しているbotを他のgroupに招待して利用される可能性がある
    - 家族LINEとかで身近なグループだけでの運用を考えていても、こちらから制限することができないと思いました(やり方がわからないだけかも)

# 参考
https://mahata.gitlab.io/post/2020-07-15-heroku-google-auth/
https://qiita.com/kotamatsuoka/items/c4e651f1cb6c4490f4b8
https://devcenter.heroku.com/ja/articles/getting-started-with-python
https://developers.line.biz/ja/docs/messaging-api/
https://github.com/line/line-bot-sdk-python