LINEで対話型botを作るときのコツ

こんにちは、株式会社LIFULLの二宮です。私は今年、LINE botのプロトタイプ実装に関わっていました。

LINEの開発では、[Pythonなどの言語でSDKも用意されていて](https://pypi.org/project/line-bot-sdk/)、簡単なbotであれば簡単に作り始められます。ただ、ある程度以上複雑なアプリケーションを作ろうとすると、実装はシナリオの管理や、FlexMessageのjsonの生成など、少し実装に苦戦すると思います。

次に実装する方のために、私が主にLINE botの実装で工夫した点を共有します。


# シナリオを設定ファイルで管理できるようにした

設定ファイルで対話のシナリオをある程度一望できるようにしました。`senario` の中の `trigger_message` で正規表現を設定し、それにマッチした場合に `endpoint` で設定されているコントローラーが呼び出されるような実装しています。


```javascript:config.json
// jsonの仕様上、コメントは入れられませんが、便宜上入れています
{
  "scenario": {
    "initial": {
      "trigger_message": "botを起動する",
      "endpoint": "controllers.initial",
      "possible_replies": [
        "initial"
      ]
    },
    // 商品を検索する応答。postback eventで "method: first_search, target: 本" のような値が動的に送られる想定
    "search_products": {
      "trigger_message": "method: search_products, target: (?P<target>[\\s\\S]+)",
      "endpoint": "controllers.search_products",
      "possible_replies": [
        "selection"
      ]
    },
    // デフォルトの選択肢
    "default": {
      "endpoint": "controllers.default",
      "possible_replies": [
        "default"
      ]
    }
 },
  "replies": {
    "initial": {
      "view": "views.initial",
      "template": [
        {
          "mode": "text",
          "text": "呼び出したい機能を選んでね"
        },
        {
          "mode": "flex",
          "template": "flex_messages/buttons.tpl.json",
          "alt_text": "選択肢"
        }
      ]
    }, 
    "products": {
      "view": "views.search_result",
      "template": [
        {
          "mode": "text",
          "text": "商品を探してみたよ"
        },
        {
          "mode": "flex",
          "template": "flex_messages/products.tpl.json",
          "alt_text": "おすすめの商品"
        }
      ]
    }
  }
}
```

botとの対話の[有限オートマトン](https://ja.wikipedia.org/wiki/%E6%9C%89%E9%99%90%E3%82%AA%E3%83%BC%E3%83%88%E3%83%9E%E3%83%88%E3%83%B3)を考えて、botのメッセージが状態を、ユーザーの次の遷移をボタンで選ぶようなイメージで作ってます。ただ、postback eventの内容を設定ファイルではなくメッセージの中で作ってしまっているので、ユーザーがどんな遷移を行うのかまでは管理していません。

私はPythonで実装したので、コントローラーの呼び出しは[importlib](https://docs.python.org/ja/3/library/importlib.html)を駆使して行いました。また、私はjsonで実装してしまったのですが、コメントができないのがきついので、yamlにするか、[例えばDjangoのディスパッチャ](https://docs.djangoproject.com/en/2.2/topics/http/urls/)のように、入力となるメッセージと実行したいクラスを紐付けるようなスクリプトを書けるようにするほうが良いかもしれません。

`trigger_message` は正規表現でパースして、コントローラーでは以下のように引数として呼び出すようになっています。今回は[postback event](https://developers.line.biz/ja/reference/messaging-api/#postback-event)として「ユーザーがこちらの用意したボタンを押す」ようなものなので正規表現をトリガーにしました(post)が、もし自由文応答するなら[Dialogflow](https://dialogflow.com/)などのツールで、Context（起動するコントローラー）とEntity（その引数）を設定できるようにします。

```python:script/controllers/search_products/__init__.py
from script.controllers._utils import AbstractController, Response, render, UserContext
from script.models import search_model

class Controller(AbstractController):
    """最初の選択肢を出すコントローラー"""

    def call(self, user_context: UserContext, target: str) -> Response:
        """コントローラーの呼び出し処理

        Args:
            user_context: idなどをラップしたクラス
            target: 正規表現の名前付きキャプチャでパースした内容
        Returns:
            ユーザーへの返信

        """
        products = search_model.search(target)
        return render("initial", proc=lambda view: view.render(products=products))
```



# FlexMessageの組み立てをテンプレートエンジン(jinja2)で行った

LINEでは[FlexMessageと呼ばれる自由度の高いレイアウトのメッセージ](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/)を使うことができます。

そのためにはFlexMessageの複雑なjsonを組み立て、LINEのReply APIを送信する必要があります。私は最初Pythonの辞書でFlexMessageを作っていたのですがなかなかキツく、[こちらの記事](https://qiita.com/tag1216/items/1a1ad0d963dc8f7413d5)を参考にテンプレートエンジン(jinja2)のテンプレートを用意し、viewでそれに当てはめる実装をしました。

> ただし Simulator を使ってデザインした場合、これをSDKのモデルに書き直すのは面倒です。ソースも長くなります。
> このような場合にはテンプレートエンジンを使ってJSONに値を埋め込み、結果をSDKのモデルに変換したほうが良さそうです。
> JSONからモデルを構築するにはnew_from_json_dict()というメソッドが用意されています。

私はjinja2で実装しましたが、他の言語やライブラリでもやることはさほど変わらないと思います。

https://palletsprojects.com/p/jinja/

```json
{
  "type": "carousel",
  "contents": [
{% for product in products %}
    {
      "type": "bubble",
      "body": {
        "type": "box",
        "layout": "vertical",
        "contents": [
          {
            "type": "box",
            "layout": "horizontal",
            "contents": [
              {
                "type": "image",
                "url": {{ product.image | json_escape }},
                "size": "full",
                "aspectMode": "cover",
                "action": {
                  "type": "uri",
                  "uri": {{ product.url | json_escape }}
                }
              }
            ]
          },
// 以下略
```

こちらの `json_escape` の実態は `json.dumps` で、「文字列をエスケープして `"` をつけ、正しいjsonが組み立てられるようにする」という処理を行っています。

```python
class JsonTemplete(object):
    ENV = Environment(loader=FileSystemLoader(
        str(Path(__file__).resolve().parent / "../templetes"), encoding='utf8'))
    ENV.filters["json_escape"] = json.dumps

# 以下略
```

これを[ `new_from_json_dict` というメソッド](https://github.com/line/line-bot-sdk-python/blob/master/linebot/models/base.py)でSDKで読み込んで利用します。


```python
    def __build_contents(self, content):
        """FlexSendMssage内のコンテナの型を振り分ける。
        Args:
            content (dict): コンテナの情報
        Returns:
            BubbleContainer/CarouselContainer: FlexSendMessage内のコンテナ
        """
        t = content["type"]
        if t == "bubble":
            return BubbleContainer.new_from_json_dict(content)
        elif t == "carousel":
            return CarouselContainer.new_from_json_dict(content)
        else:
            raise RuntimeError(f"未対応のcontainerです: {t}")
```

ただ「jsonとしては文法が正しいが、LINEのメッセージの仕様は満たしていない」ケースに、デバッグが少し厄介だという問題は残っています。これは良い解決方法を思いつかないので、なにか知見のある方は教えてください。

# その他LINEのプロジェクトで苦戦した点

ユーザーのスマホ機種や設定で、FlexMessageの見た目が変わってしまい（例えば想定より文字が大きくて改行が入ってしまい）、思っていた見た目にならないことがありました。文字サイズも細かく指定できないので、あまり凝ったデザインは目指さないほうが良いです。

また、他の記事でもよく言われていますが、[FlexMssageSimulator](https://developers.line.biz/console/fx/)で見た目を調整し、テンプレートでボタンの内容を埋め込んで実装するとスムーズです。

# さいごに

この記事が、これからチャットボットの実装にとりかかる方の参考になると嬉しいです。

この記事は[LIFULL Advent Calendar](https://qiita.com/advent-calendar/2019/lifull)の1日目の記事です。こちらも引き続きよろしくお願いします。