ちょっとやりとりするだけのLINEBOTを作ってみる

お疲れ様です。みやもとです。

また懲りずにLINEBOTを作ってみました。
今回は
* 初回のメッセージは固定
* 2回目以降のメッセージは相手の回答に合わせて変える
という、簡単な会話ができるBOTを作るのが目標です。

:::note
この記事に出てくるコードはGoogle Cloud Functionsで動作を確認しています。
・環境：第2世代
・トリガー：HTTPS
・認証：未認証の呼び出しを許可
・割り当てメモリ：256MiB
・ランタイム：Python 3.8
:::

## 前提：参考記事
今回も前回までのLINEBOTと同じような手順で作成しています。
基本的なところは以下の記事を参考にしていますので、LINEBOTを作ってみたい方はこちらを先にご確認ください。

https://note.com/nekoniii3/n/n0a01ec6731b5

https://qiita.com/spre55/items/da2ded18ac4652abb936

上記記事を参考に
* LINE Developersの登録、LINE Messaging APIチャネルの作成
* Google Cloud Platformの登録、Google Cloud Functionsの作成
* requirements.txtの作成

までを実施します。

また、今回は「初回の会話かどうか」「どの質問をしたか」を保持しておく必要があるということで、以下の記事も確認しました。

https://qiita.com/shima-218/items/643406e5a8c0ed3b2176

JavaとPythonで言語は違いますが、まあどうにかなるでしょう。たぶん。

## 対話BOTを作ろう

準備ができたらコードです。
基本の部分は前回までのBOTと同じなので、今回新たに書いた部分だけちょこちょこ説明書きしていこうと思います。

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

```Python:main.py

import os
import base64, hashlib, hmac
import urllib.parse

from flask import abort, jsonify

from linebot import (
    LineBotApi, WebhookParser
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage, 
    TemplateSendMessage,ButtonsTemplate,MessageAction,URIAction
)

conversation_status = {}
questions = ['ご予定は？','何食べたい？','何したい？','どこ行きたい？']
selections = [{'ごはん':1,'あそび':2,'メンテ':3},
              ['和食','洋食','中華'],
              ['運動','ゲーム','読書','映画'],
              ['マッサージ','美容院','病院']]

def main(request):
    channel_secret = os.environ.get('LINE_CHANNEL_SECRET')
    channel_access_token = os.environ.get('LINE_CHANNEL_ACCESS_TOKEN')

    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):
            if isinstance(event.message, TextMessage):
                reply_data = []
                # IDを取得する
                userid = event.source.user_id
                if userid not in conversation_status:
                    # 会話履歴がない場合、会話開始
                    conversation_status[userid] = 0
                    reply_data.append(make_button_template(0))
                else:
                    # 会話履歴が残っている場合、返答内容をチェック
                    status = conversation_status[userid]
                    # 選択肢から返信しているかどうかチェック
                    if event.message.text in selections[status]:
                        # 選択肢から返信している場合、ステータスが0なら次の質問へ
                        if status == 0:
                            for key, value in selections[0].items():
                                if key == event.message.text:
                                    conversation_status[userid] = value
                                    reply_data.append(make_button_template(value))
                        else:
                            #ステータスが0以外の場合、キーワードを指定してGoogleMapへ
                            reply_data.append(
                                TemplateSendMessage(
                                    alt_text='探したよ！',
                                    template=ButtonsTemplate(
                                        text = '探したよ！',
                                        actions=[
                                            URIAction(
                                                label='GoogleMap',   
                                                uri='https://www.google.co.jp/maps/search/' + urllib.parse.quote(event.message.text) + '?openExternalBrowser=1'
                                            )
                                        ]
                                    )))
                            # ステータスを削除
                            del conversation_status[userid]
                    else:
                        # 選択肢から返信していない場合、質問を再送信
                        reply_data.append(TextSendMessage(text='ボタンから選んでね'))
                        reply_data.append(make_button_template(conversation_status[userid]))
                    
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            else:
                continue

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

def make_button_template(idx):
    # ボタンをリスト化する
    button_list = []
    # selectionsから取得した内容がdictの場合
    if isinstance(selections[idx],dict):
        # 全てのキーを取得してメッセージアクションを作成
        for key in selections[idx]:
            button_list.append(
                MessageAction(
                    label=key,
                    text=key
                )
            )
    # selectionsから取得した内容がlistの場合
    else:
        # 全要素を取得してメッセージアクションを作成
        for item in selections[idx]:
            button_list.append(
                MessageAction(
                    label=item,
                    text=item
                )
            )
    message_template = TemplateSendMessage(
        alt_text=questions[idx],
        template=ButtonsTemplate(
            text = questions[idx],            
            actions=button_list
        )
    )
    return message_template
```
</details>

### 各種変数

```Python:main.py

conversation_status = {}
questions = ['ご予定は？','何食べたい？','何したい？','どこ行きたい？']
selections = [{'ごはん':1,'あそび':2,'メンテ':3},
              ['和食','洋食','中華'],
              ['運動','ゲーム','読書','映画'],
              ['マッサージ','美容院','病院']]

```

処理内で固定で使用する変数類です。
* conversation_status：ユーザーの会話状況を保持するために使用します。
* questions：会話の質問部分です。
* selections：会話の選択肢部分です。最初だけdictになっているのは、選択肢によって次の質問を切り替えるためです。
2024/2/17 22:10追記：後述の処理修正に伴いdictのkeyとvalueを入れ替えました。

### 関数定義「make_button_template」

```Python:main.py

def make_button_template(idx):
    # ボタンをリスト化する
    button_list = []
    # selectionsから取得した内容がdictの場合
    if isinstance(selections[idx],dict):
        # 全てのキーを取得してメッセージアクションを作成
        for key in selections[idx]:
            button_list.append(
                MessageAction(
                    label=key,
                    text=key
                )
            )
    # selectionsから取得した内容がlistの場合
    else:
        # 全要素を取得してメッセージアクションを作成
        for item in selections[idx]:
            button_list.append(
                MessageAction(
                    label=item,
                    text=item
                )
            )
    message_template = TemplateSendMessage(
        alt_text=questions[idx],
        template=ButtonsTemplate(
            text = questions[idx],            
            actions=button_list
        )
    )
    return message_template

``` 

だいたいコメントで書いてありますが、一応説明。

まず引数idxで指定したselectionの内容がdictの場合は
value
key部分、listの場合は中の値をLINEテンプレートメッセージのボタンラベルに使います。

dictの場合でvalue使ってるのに変数がkeyとはこれいかに、と思われた方はカンがよろしい。
もともとselectionのkeyとvalueを逆に定義していて途中で変えたのでその名残です。あれこれこね回してるうちに入れ替わりました。

2024/2/17 22:10追記：コードの重複を削除するためにkeyとvalueを戻しました。

で、ボタンをリスト化したらTemplateSendMessageにこれまたidxを使って取得したquestionsの質問文をセットし、メッセージテンプレートを呼び出し元に返します。

### 関数定義「main」

実際の動作についてはevent判定部分のみ抜粋します。

```Python:main.py

    for event in events:
        if isinstance(event, MessageEvent):
            if isinstance(event.message, TextMessage):
                reply_data = []
                # IDを取得する
                userid = event.source.user_id
                if userid not in conversation_status:
                    # 会話履歴がない場合、会話開始
                    conversation_status[userid] = 0
                    reply_data.append(make_button_template(0))
                else:
                    # 会話履歴が残っている場合、返答内容をチェック
                    status = conversation_status[userid]
                    # 選択肢から返信しているかどうかチェック
                    if event.message.text in selections[status]:
                        # 選択肢から返信している場合、ステータスが0なら次の質問へ
                        if status == 0:
                            for key, value in selections[0].items():
                                if key == event.message.text:
                                    conversation_status[userid] = value
                                    reply_data.append(make_button_template(value))
                        else:
                            #ステータスが0以外の場合、キーワードを指定してGoogleMapへ
                            reply_data.append(
                                TemplateSendMessage(
                                    alt_text='探したよ！',
                                    template=ButtonsTemplate(
                                        text = '探したよ！',
                                        actions=[
                                            URIAction(
                                                label='GoogleMap',   
                                                uri='https://www.google.co.jp/maps/search/' + urllib.parse.quote(event.message.text) + '?openExternalBrowser=1'
                                            )
                                        ]
                                    )))
                            # ステータスを削除
                            del conversation_status[userid]
                    else:
                        # 選択肢から返信していない場合、質問を再送信
                        reply_data.append(TextSendMessage(text='ボタンから選んでね'))
                        reply_data.append(make_button_template(conversation_status[userid]))
                    
                line_bot_api.reply_message(
                    event.reply_token,
                    reply_data
                )
            else:
                continue

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

```

まずeventがMessageEventかどうか、MessageEventだった場合はさらにevent.messageがTextMessageかどうかを判定します。
TextMessageというところまで確認できたら、以下の条件で処理分岐していきます。

1. 会話履歴が残っているかどうか
conversation_statusにuseridが存在するかどうかを確認します。存在しない場合は新規にIDとステータスを設定し、idxが0の場合のメッセージを作成します。
2. 会話のステータスが「0」かどうか
conversation_statusにuseridが存在する場合、すでに会話が開始しているとしてメッセージの内容を判定します。
ステータスが「0」で提示した選択肢から選ばれたメッセージを受信した場合、次の質問メッセージを作成します。
ステータスが「0」以外で提示した選択肢から選ばれたメッセージを受信した場合、受信したメッセージをGoogleMapの検索条件として設定したリンクを作成します。
GoogleMapのリンク生成は私がAndroid使ってるので上記の内容ですが、iOSの場合はまた別だったかもしれません。


ちなみに提示した選択肢から選ばれたメッセージ以外を受信した場合は、ステータスが「0」でもそれ以外でも同様にもう一度同じ質問を返します。
…記事書きながら「if文の分岐まずったな」と思いました。
先に提示した選択肢かどうかを判定してそのあとにステータス「0」かどうかで判定した方が良かったな？

2024/2/17 22:10追記：上記内容を修正して動作確認後に記事に反映しました。
記事書いてるとコード書いてる時は気づかなかったことが目に付いたりするので思考の整理にいいですね！と無理やりポジっていきます。

とにかく、そんな感じでメッセージテンプレートを作って返信します。
GoogleMapのリンクを返した時点で一連の会話終了なので、conversation_statusからIDを削除する処理も入れました。

## 実行結果

上記のような感じで作ったBOTを動かすとこんな感じになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/0a50d845-e538-df7f-799f-517a0d676e96.png" width="50%">

最初のメッセージは何入れても同じ質問が返ってくるので適当に。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3477580/3872f8b7-c884-247d-44cb-84a69bf68e31.png" width="50%">

最後のGoogleMapリンクボタンを押すとこんな感じ。
外出先で動かしてますが申し訳程度に店名とか隠しておく。

## 余談

このBOTを作る直前にVSCodeにGoogleCloudCodeとDuetAIの拡張機能を入れたのですが、コメント入れるとそこそこ的確にコードの候補を出してきてくれて結構便利でした。
私はコーディング中にあんまりコメント入れない方なのですが、こうやって候補出してもらえると楽だし後からコメント見て何してるかわかるし良いなーと今更思ったり。

## 参考：公式ドキュメント

LINE BOTを作るのに使用したLINE Messaging APIのリファレンスとGoogle Cloud Functionsのドキュメントへのリンクを置いて終わりにします。

https://developers.line.biz/ja/reference/messaging-api/

https://cloud.google.com/functions/docs?hl=ja