気晴らしに作るLINEBOT～病院行けBOT～

お疲れさまです、みやもとです。

ここまで何個かLINEBOTをいじってきましたが、今回はちょっとだけ凝ったものを作ってみました。
「病院行けBOT」といいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/3c8e6a43-7d51-066a-bd27-dff6f8b68c29.jpeg" width="30%">

話しかけたら体調に関する質問をいくつかしてきて、最終的には「病院行け」と返すBOTです。
最初は上の図のようにGoogleMapの検索リンクを返すようにしていましたが、作ってちょっと動かした後に「カルーセルメッセージ使ってみたい」とか「GoogleMapのAPI使ってみたい」と思いついたため下図のような検索結果を返す形に変更しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/1983454a-c8bb-8e8a-44a5-a95a80ee3b9d.jpeg" width="30%">

::: note info
この記事に出てくるコードはGoogle Cloud Functionsで動作を確認しています。
・環境：第2世代
・トリガー：HTTPS
・認証：未認証の呼び出しを許可
・割り当てメモリ：256MiB
・ランタイム：Python 3.8
:::
::: note warn
質問の選択肢および選択肢に関連付けられた診療科等については、あくまでみやもとの持つイメージから設定していいます。
医学的な根拠には乏しく間違いも含まれている可能性もありますので、もしコードを使用される際にはその点ご留意の上でお願いします。
:::

## 言っても仕方がないストレスをBOTにする
ソースについて書く前に作ったきっかけを少しだけ。

<b>とにかく親が病院に行かない。</b>
何十年の付き合いになる持病に関してはちゃんと通院するので全く行かないとは言えないのですが、突発的な体調不良や近年わいて出た持病による発作に関して本当に病院に行ってくれない。
私が指摘しても「そんなしんどくないけどなぁ」と症状に対する自覚があるかすら怪しく、それでもあからさまに様子がおかしいと指摘して病院に行かせようとすると癇癪を起して拗ねる。
本人がつらくないと言う以上はあまり無理強いもできませんし、病院に行く行かないの判断は個人の価値観とか経験とかに左右されるところも大きい印象ですからさじ加減が難しいところです。
私は私でちょっとした体調の変化が気になってすぐ病院に行く方なので、多分この点において考え方が対照的すぎる親と相性が悪いのでしょう。
老いた親への対応というのは難しいと半ばあきらめて、どうしようもなくなる一歩手前で無理やりにでも病院に引きずっていかないといけないんだろうな…とあれこれ言うのをこらえています。

ほぼ愚痴になってしまって申し訳ない気持ちですが、そんな感じで叱り飛ばして泣かせてでも病院に押し込みたい気持ちをどうにか発散したくて作ったのがこれです。

## 機能概要および参照記事
ではここからBOTそのものに関しての情報です。

今回のBOTで必要なのはだいたい以下３点。
* 会話がどの段階にあるかを判定する
* 位置情報をもとにGoogleMapの検索結果を取得する
* 取得した検索結果をカルーセルメッセージで返す

上記実装のために以下の記事を参考にしました。

まずLINEBOTの基本的な設定と会話のステータス管理については前回作ったBOT記事

https://qiita.com/Mymt_aggw2208/items/d4ff366d3aa3a331dafc

LINEからの位置情報取得やGoogleMap（GooglePlaceAPI）ので検索については以下の記事

https://qiita.com/siganai_poteto/items/c3863ec05a8a6517f167

https://qiita.com/mgmgmogumi/items/f160d28a0643f683afeb

上記記事を参考に

* LINE Developersの登録、LINE Messaging APIチャネルの作成
* Google Cloud Platformの登録、Google Cloud Functionsの作成
* requirements.txtの作成

までを実施します。

今回はPlaceAPIを使用するので、ランタイム環境変数としてLINE Messaging API用のLINE_CHANNEL_SECRETとLINE_CHANNEL_ACCESS_TOKENのほかにPlaceAPI用のキー設定用変数としてPLACE_API_KEYを追加しました。
また、PythonのGoogleMapライブラリを使用しているのでrequirements.txtにgooglemapsを追加しています。

## 病院行けBOTの中身
ではここからコードです。
前回同様全体は折りたたんで部分ごとに書いていきます。

<details><summary>main.py</summary>

```Python:main.py
import os
import base64, hashlib, hmac, urllib

from flask import abort, jsonify

import googlemaps
import requests

from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, LocationMessage, TextSendMessage,StickerSendMessage, 
    TemplateSendMessage, ButtonsTemplate, MessageAction, LocationAction, 
    CarouselTemplate, CarouselColumn, QuickReply, QuickReplyButton, CarouselTemplate, URIAction
)

# 質問用クラス
class Question:
    def __init__(self, text, choices):
        self.text = text
        self.choices = choices

# 質問群
q1 = Question("具体的には？", 
                      {"頭が痛い":"脳神経内科",
                       "息苦しい":"呼吸器科",
                       "歯が痛い":"歯科",
                       "目がかゆい":"眼科"})
q2 = Question("具体的には？", 
                      {"おなかが痛い":"消化器科",
                       "腰が痛い":"整形外科",
                       "肩こりがひどい":"マッサージ",
                       "動悸がする":"循環器科"})
q3 = Question("具体的には？",
                      {"手が痛い": "整形外科",
                       "手荒れがひどい":"皮膚科",
                       "足が痛い": "整形外科",
                       "足がむくんでいる":"内科"})
q4 = Question("具体的には？",
                      {"熱っぽい":"内科",
                       "だるい":"内科",
                       "眠れない":"内科",
                       "いらいらする":"心療内科"})
q5 = Question("どこがつらい？",
                      {"首から上":q1,
                       "手とか足":q3,
                       "それ以外":q2,
                       "全部":q4})
q6 = Question("無理してない？",
                      {"してない":[8515,16581242],
                       "してる":q5})
qaSet = Question("元気？",
                      {"元気！":q6,
                       "いまいち":q5,
                       "つらい":q5})
# ステータス保存
status = {}

def main(request):
    channel_secret = os.environ.get('LINE_CHANNEL_SECRET')
    channel_access_token = os.environ.get('LINE_CHANNEL_ACCESS_TOKEN')
    place_api_key = os.environ.get('PLACE_API_KEY')
    google_map_url = "https://www.google.com/maps/search/?api=1"


    line_bot_api = LineBotApi(channel_access_token)
    parser = WebhookParser(channel_secret)

    body = request.get_data(as_text=True)
    hash = hmac.new(channel_secret.encode('utf-8'),
        body.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash).decode()

    if signature != request.headers['X_LINE_SIGNATURE']:
        return abort(405)

    try:
        events = parser.parse(body, signature)
    except InvalidSignatureError:
        return abort(405)
    
    for event in events:
        if isinstance(event, MessageEvent):
            # メッセージを受信した場合、返信データ編集用の変数を用意
            reply_data = []

            # ユーザーIDを取得する
            userid = event.source.user_id
            if isinstance(event.message, LocationMessage):                
                if userid in status:
                    # 位置情報を取得した場合、GooglePlaceAPIで周辺検索
                    search_word = status[userid]
                    if search_word.endswith("科"):
                        search_word = '病院　' + search_word
                    map_client = googlemaps.Client(place_api_key)
                    loc = {'lat': event.message.latitude, 'lng': event.message.longitude}
                    place_result = map_client.places_nearby(keyword=search_word, location=loc, radius=1000, language='ja')
                    
                    # レスポンスからデータを取得
                    datas = place_result.get('results')
                    columns = []
                    # カルーセルメッセージ作成(最大10件)
                    for data in datas[:10]:
                        try:
                            photo_reference = data['photos'][0]['photo_reference']
                            # photo_referenceをもとにplaces_photo取得
                            response = requests.get('https://maps.googleapis.com/maps/api/place/photo?photoreference=' + photo_reference + '&maxwidth=400&key=' + place_api_key)
                            image_url = response.url
                            shop_name = data['name']
                            like_num = data['rating']
                            place_id = data['place_id']
                            user_ratings_total = data['user_ratings_total']
                            # 対象の場所におけるgooglemapのURLを取得
                            place_detail = map_client.place(place_id=place_id, language='ja') 
                            map_url = place_detail['result']['url']
                            # カルーセルメッセージオブジェクトを作成
                            columns.append(
                                CarouselColumn(
                                    thumbnail_image_url=image_url,
                                    title=shop_name,
                                    text=f"評価：{like_num} / {user_ratings_total}件",
                                    actions=[
                                        URIAction(
                                            label='GoogleMap',
                                            uri=map_url
                                        )
                                    ]
                                )
                            )
                        except:
                            continue
                    # データがなかった場合
                    if len(columns) == 0:
                        reply_data.append(TextSendMessage(text='近くに見当たらないみたいだ'))
                    else:
                        reply_data.append(TextSendMessage(text='探したよ'))
                        reply_data.append(TemplateSendMessage(
                            alt_text='検索結果',
                            template=CarouselTemplate(
                                columns=columns
                            )))
                    # ステータスを削除する
                    del status[userid]
                else:
                    # ユーザーIDが存在しない場合はやり直し
                    reply_data.append(TextSendMessage(text='ごめん\n何話してたっけ？'))
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            elif isinstance(event.message, TextMessage):
                if userid in status:
                    # ユーザーIDが存在する場合、回答メッセージが質問セットの回答選択肢にあるか確認
                    if event.message.text in status[userid].choices:
                        # 存在する場合は次の質問があるか確認
                        next_action = status[userid].choices[event.message.text]
                        if isinstance(next_action, Question):
                            # 次の質問がある場合は質問セットを更新
                            status[userid] = next_action
                            # セットし直した質問セットをもとにボタンテンプレートを作成
                            reply_data.append(make_button_template(next_action))
                        elif isinstance(next_action, list):
                            # リストの場合はスタンプメッセージを編集
                            reply_data.append(
                                StickerSendMessage(
                                    package_id=next_action[0], sticker_id=next_action[1]
                                ))
                            # ステータスを削除する
                            del status[userid]
                        else:
                            # どちらでもない場合（文字列の場合）、テキストの末尾を判定
                            if next_action.endswith('科'):
                                messageText = '病院行け'
                            else:
                                messageText = next_action + '行け'
                            # ボタンに位置情報を返すアクションを設定する
                            location = [QuickReplyButton(action=LocationAction(label="位置情報を送る"))]
                            reply_data.append(
                                TextSendMessage(text=messageText, quick_reply=QuickReply(items=location)))
                            # ステータスを更新する
                            status[userid] = next_action
                    else:
                        # メッセージが選択肢に存在しない場合、もう一度聞き直す
                        reply_data.append(TextSendMessage(text='はぐらかさない'))
                        reply_data.append(make_button_template(status[userid]))
                else:
                    # ユーザーIDが存在しない場合、質問セットを設定
                    status[userid] = qaSet
                    # 質問セットをもとにボタンテンプレートを作成
                    reply_data.append(make_button_template(qaSet))

                # メッセージを返す
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            else:
                continue

    return jsonify({ 'message': 'ok'})

def make_button_template(questions):
    # ボタンをリスト化する
    button_list = []
    # question内の回答選択肢dictからキーを全件取得
    for key in questions.choices:
        button_list.append(
            MessageAction(
                label=key,
                text=key
            )
        )
    # questionのtextを質問メッセージとして設定
    message_template = TemplateSendMessage(
        alt_text=questions.text,
        template=ButtonsTemplate(
            text = questions.text,            
            actions=button_list
        )
    )
    return message_template
```
</details>

### 質問用クラス「Question」および質問定義
```Python:main.py
# 質問用クラス
class Question:
    def __init__(self, text, choices):
        self.text = text
        self.choices = choices

# 質問群
q1 = Question("具体的には？", 
                      {"頭が痛い":"脳神経内科",
                       "息苦しい":"呼吸器科",
                       "歯が痛い":"歯科",
                       "目がかゆい":"眼科"})
q2 = Question("具体的には？", 
                      {"おなかが痛い":"消化器科",
                       "腰が痛い":"整形外科",
                       "肩こりがひどい":"マッサージ",
                       "動悸がする":"循環器科"})
q3 = Question("具体的には？",
                      {"手が痛い": "整形外科",
                       "手荒れがひどい":"皮膚科",
                       "足が痛い": "整形外科",
                       "足がむくんでいる":"内科"})
q4 = Question("具体的には？",
                      {"熱っぽい":"内科",
                       "だるい":"内科",
                       "眠れない":"内科",
                       "いらいらする":"心療内科"})
q5 = Question("どこがつらい？",
                      {"首から上":q1,
                       "手とか足":q3,
                       "それ以外":q2,
                       "全部":q4})
q6 = Question("無理してない？",
                      {"してない":[8515,16581242],
                       "してる":q5})
qaSet = Question("元気？",
                      {"元気！":q6,
                       "いまいち":q5,
                       "つらい":q5})
```
まず質問用クラスとして、質問とそれに対する選択肢を保持するクラスを定義しました。
続いて実際に表示する内容を定義していきますが、最初の質問を一番最後にして後ろから順に定義していきます。
これは次の質問がある場合に選択肢と次の質問クラスをセットにするためで、セットの質問を先に定義しないとエラーになります。
また、q6の選択肢に数値2つのリストがありますが、これは体調不良がない場合にLINEのスタンプを返して終了するためです。

ちなみに冒頭でも警告をつけましたが、<b>症状と診療科の組み合わせはあくまでみやもとのイメージによるもので医学的な根拠に基づいたものではないのでご留意ください。</b>

## 関数定義「make_button_template」
```Python:main.py
def make_button_template(questions):
    # ボタンをリスト化する
    button_list = []
    # question内の回答選択肢dictからキーを全件取得
    for key in questions.choices:
        button_list.append(
            MessageAction(
                label=key,
                text=key
            )
        )
    # questionのtextを質問メッセージとして設定
    message_template = TemplateSendMessage(
        alt_text=questions.text,
        template=ButtonsTemplate(
            text = questions.text,            
            actions=button_list
        )
    )
    return message_template
```
質問用クラスを引数にして作成したテンプレートメッセージを返す処理です。

質問用クラスは質問文とそれに対する選択肢のdictがセットになっているので、選択肢のキー部分を全件取得してボタンを作成します。
ボタンはタップすると選択肢を返信するように設定しています。

### 関数定義「main」
```Python:main.py
    for event in events:
        if isinstance(event, MessageEvent):
            # メッセージを受信した場合、返信データ編集用の変数を用意
            reply_data = []

            # ユーザーIDを取得する
            userid = event.source.user_id
            if isinstance(event.message, LocationMessage):                
                if userid in status:
                    # 位置情報を取得した場合、GooglePlaceAPIで周辺検索
                    search_word = status[userid]
                    if search_word.endswith("科"):
                        search_word = '病院　' + search_word
                    map_client = googlemaps.Client(place_api_key)
                    loc = {'lat': event.message.latitude, 'lng': event.message.longitude}
                    place_result = map_client.places_nearby(keyword=search_word, location=loc, radius=1000, language='ja')
                    
                    # レスポンスからデータを取得
                    datas = place_result.get('results')
                    columns = []
                    # カルーセルメッセージ作成(最大10件)
                    for data in datas[:10]:
                        try:
                            photo_reference = data['photos'][0]['photo_reference']
                            # photo_referenceをもとにplaces_photo取得
                            response = requests.get('https://maps.googleapis.com/maps/api/place/photo?photoreference=' + photo_reference + '&maxwidth=400&key=' + place_api_key)
                            image_url = response.url
                            shop_name = data['name']
                            like_num = data['rating']
                            place_id = data['place_id']
                            user_ratings_total = data['user_ratings_total']
                            # 対象の場所におけるgooglemapのURLを取得
                            place_detail = map_client.place(place_id=place_id, language='ja') 
                            map_url = place_detail['result']['url']
                            # カルーセルメッセージオブジェクトを作成
                            columns.append(
                                CarouselColumn(
                                    thumbnail_image_url=image_url,
                                    title=shop_name,
                                    text=f"評価：{like_num} / {user_ratings_total}件",
                                    actions=[
                                        URIAction(
                                            label='GoogleMap',
                                            uri=map_url
                                        )
                                    ]
                                )
                            )
                        except:
                            continue
                    # データがなかった場合
                    if len(columns) == 0:
                        reply_data.append(TextSendMessage(text='近くに見当たらないみたいだ'))
                    else:
                        reply_data.append(TextSendMessage(text='探したよ'))
                        reply_data.append(TemplateSendMessage(
                            alt_text='検索結果',
                            template=CarouselTemplate(
                                columns=columns
                            )))
                    # ステータスを削除する
                    del status[userid]
                else:
                    # ユーザーIDが存在しない場合はやり直し
                    reply_data.append(TextSendMessage(text='ごめん\n何話してたっけ？'))
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            elif isinstance(event.message, TextMessage):
                if userid in status:
                    # ユーザーIDが存在する場合、回答メッセージが質問セットの回答選択肢にあるか確認
                    if event.message.text in status[userid].choices:
                        # 存在する場合は次の質問があるか確認
                        next_action = status[userid].choices[event.message.text]
                        if isinstance(next_action, Question):
                            # 次の質問がある場合は質問セットを更新
                            status[userid] = next_action
                            # セットし直した質問セットをもとにボタンテンプレートを作成
                            reply_data.append(make_button_template(next_action))
                        elif isinstance(next_action, list):
                            # リストの場合はスタンプメッセージを編集
                            reply_data.append(
                                StickerSendMessage(
                                    package_id=next_action[0], sticker_id=next_action[1]
                                ))
                            # ステータスを削除する
                            del status[userid]
                        else:
                            # どちらでもない場合（文字列の場合）、テキストの末尾を判定
                            if next_action.endswith('科'):
                                messageText = '病院行け'
                            else:
                                messageText = next_action + '行け'
                            # ボタンに位置情報を返すアクションを設定する
                            location = [QuickReplyButton(action=LocationAction(label="位置情報を送る"))]
                            reply_data.append(
                                TextSendMessage(text=messageText, quick_reply=QuickReply(items=location)))
                            # ステータスを更新する
                            status[userid] = next_action
                    else:
                        # メッセージが選択肢に存在しない場合、もう一度聞き直す
                        reply_data.append(TextSendMessage(text='はぐらかさない'))
                        reply_data.append(make_button_template(status[userid]))
                else:
                    # ユーザーIDが存在しない場合、質問セットを設定
                    status[userid] = qaSet
                    # 質問セットをもとにボタンテンプレートを作成
                    reply_data.append(make_button_template(qaSet))

                # メッセージを返す
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            else:
                continue
```
メイン処理が長いのでメッセージイベントの処理部分のみ書きます。

位置情報を受信した場合、取得しておいたユーザーIDが状態保持用のdictに残っているかを判定します。残っていない場合は「忘れちゃった💦」みたいなメッセージを返します。
残っている場合はdictに残っている文字列を検索条件に設定してPlaceAPIで検索します。
レスポンスデータがあった場合は最大10件までを使用し、カルーセルメッセージの作成に入ります。
カルーセルメッセージ作成の際に写真と場所のURLが必要になるのでこれについては改めて取得します。

なお、場所のURLについてはPythonのGoogleMapライブラリを使用してplaceメソッドで取得できたのですが、写真についてはどうにもうまく結果が返ってこなかったためURL直書きによって対応しています。
できればPythonのライブラリでどうにかしたかったところです。

検索結果が0件の場合はその旨を伝えるメッセージを返します。

テキストメッセージを受信した場合は、こちらも状態保存用dictにユーザーIDが残っているかを確認します。残っていない場合は最初の質問に戻ります。
残っている場合は質問用クラスがあるはずなので、受信したメッセージが選択肢の内容のどれかと一致するか判定します。一致しない場合は選択肢から選んでいないものとして再度同じ質問を送信します。
一致するものがある場合は該当のメッセージをキーにしてdictの内容を取得し、質問用クラスなら状態保存用dictを更新した上で次の質問をメッセージにして送ります。
質問ではない場合にはさらにリストかどうかを判定し、リストの場合はリスト内の値を使ってLINEスタンプを送信します。
リストでもない場合は選択肢の内容によって「病院行け」のメッセージを作成し、位置情報を送らせるためのクイックリプライとともに返します。

## 作った感想
根本的な解決にはなりません（当たり前）が、形にしただけでも割とすっきりしました。
口に出すと角が立つあれこれをBOTにしてみるの、意外とおすすめかもしれません。