FastAPIを使って部活の練習メニューを通知してくれるLINEbotを開発した

# 概要 
本記事は、FastAPIを使ってLINEbotを開発する方法をざっくりお伝えするための記事になります。
2ターン以上会話を続ける方法を書いた記事が少なかったので、おうむ返しbot以上のものを作りたい方のお役に立てれば幸いです。
コードはGitHubにあげているので[こちら](https://github.com/YuyaNakamura0139/line-bot)を参照してください。

# 目次
[1.背景](#1-背景)
[2.完成形](#2-完成形)
[3.全体像](#3-全体像)
[4.実行環境](#4-実行環境)
[5.ディレクトリ構成](#5-ディレクトリ構成)
[6.実装](#6-実装)
[7.テンプレートメッセージ](#7-テンプレートメッセージ)
[8.リッチメニュー](#8-リッチメニュー)
[9.デプロイ](#9-デプロイ)
[10.最後に](#10-最後に)

# 1. 背景
私が当時所属していた大学の部活動の友人から、
- 練習メニューの全体LINEへの通知が毎度面倒
- そもそもメンバーの練習メニューの理解度が低い

といった相談を受けました。そこで、もともとLINEで部活動の連絡が行われていたこともあり、LINEbotを使って練習メニューの管理を行うのはどうかと進言してみたのがきっかけになります。

# 2. 完成形
完成形はこのようになります。
![LINEbot操作画面_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1441421/c1a5cf0c-2a60-de4a-0ca5-de3243a4f03b.gif)
一連の操作が完了したら下の画面のようにグループラインに練習メニューやそのドキュメントが通知されます。
ドキュメントを押すと、練習メニューの目的や内容が記載されたGoogleDocumentのリンクへ飛びます。
![スクリーンショット 2022-04-21 16.52.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1441421/daef44ca-95b9-ac0c-f40b-53c1dbb5c6f8.png)

ちなみにこの画面は[LINEBotDesigner](https://developers.line.biz/ja/services/bot-designer/)というツールを用いて作成しました。
画面をポチポチ操作するだけでモックを作成できるので、非常に便利でした。
LINEbotを開発する際は是非使ってみると良いでしょう。

# 3. 全体像
今回開発したLINEbotの構成は以下のようになります。
![空白の図 - ページ 1 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1441421/dadb86ab-83ab-922f-aa9d-0eb1ed1ec455.png)

ユーザーが送信するたびLINEbotのWebhookを介してFastAPIサーバーにRequestが送られ、DB(mongoDB)と連携して適切な処理を行い、ユーザーにResponseを返します。
この一連の処理が終了すると指定のグループラインに必要事項が通知されるという流れになります。

DBに用意したコレクションは以下の二つです。
- userコレクション: userのセッション情報
- practice_menuコレクション: 練習メニューやドキュメントのURLを登録してある

2ターン以上の会話を行うには入力履歴や会話状況をデータとして保存しなければなりません。今回はそれらの情報をDB側のuserコレクションで管理することで2ターン以上の会話を実現しました。
これが正しい方法かわかりませんが、もっと良い方法があればぜひコメントで教えてください！

# 4. 実行環境
macOS Monterey(version 12.1)
IDE: VSCode
python 3.9.10
aiolinebot 0.4.1
dnspython 2.2.1
fastapi 0.75.0
gunicorn 20.1.0
pydantic 1.9.0
pymongo 3.12.3
python-decouple 3.6
uvicorn 0.17.6


# 5. ディレクトリ構成
本アプリのディレクトリ構成は以下のようになります。
```
linebot
├── db
│   ├── db_practice_menu.py
│   └── db_session.py
├── image
│   └── rich_menu_image.jpg
├── routers
│   ├── route_linebot.py
│   └── route_practice.py
├── template
│   ├── button_template.py
│   ├── carousel_template.py
│　　　　　　├── check_template.py
│　　　　　　├── create_rich_menu.py
│   └── text_message.py
├── __init__.py
├──　.env
├── config.py
├── main.py
├── Procfile
├── requirements.txt
├── runtime.txt
└── schemas.py
```

# 6. 実装
### ルーティング
main.pyではルーティングの設定を行なっています。
```python:main.py
from fastapi import FastAPI

from routers import route_linebot, route_practice


app = FastAPI()

app.include_router(route_linebot.router)
app.include_router(route_practice.router)


@app.get("/")
def root():
    return {"message": "Welcome to Fast API"}
```
今回作成したAPIはLINEbotの処理用と練習メニューのデータベース操作用の二つに大別されるため、ファイルの中身が大きくならないようルーティングによりリソースの分割を行いました。
今回は後者の説明は割愛して、メインのLINEbotの処理部分を説明していきます。

### LINEbotのメイン処理
```python:route_linebot.py
from fastapi import Request, BackgroundTasks, APIRouter
from linebot import WebhookParser
from linebot.models import TextMessage
from aiolinebot import AioLineBotApi

from config import ACCESS_TOKEN, SECRET, GROUP_ID

from db.db_session import (
    db_register,
    db_update_context,
    db_reset_status,
    db_update_day,
    db_update_time,
    db_update_last_sentence,
    db_add_practice_name,
    db_add_practice_time,
    db_add_url,
)

from db.db_practice_menu import (
    db_get_practice_name_list_by_practice_period,
    db_get_url_by_practice_name,
)

from template.button_template import (
    select_day_template,
    final_check_button,
    select_parent_practice_template,
    select_child_practice_template,
    practice_check_button,
    practice_info_template,
)

from template.text_message import notification_template, first_statement

from template.create_rich_menu import create_rich_menu


router = APIRouter(prefix="/messaging_api/handle_request", tags=["line-bot"])


line_api = AioLineBotApi(channel_access_token=ACCESS_TOKEN)
parser = WebhookParser(channel_secret=SECRET)

rich_menu_id = line_api.create_rich_menu(rich_menu=create_rich_menu())
with open("./image/rich_menu_image.jpg", "rb") as f:
    line_api.set_rich_menu_image(rich_menu_id, "image/jpeg", f)
line_api.set_default_rich_menu(rich_menu_id=rich_menu_id)


async def handle_events(events):
    """
    user["context"]とtextの値によって出力内容を決定
    """
    for e in events:
        try:
            text = e.message.text
            user_id = e.source.user_id

            # ユーザーのセッション情報を取得
            user = db_register(user_id)

            if text == "キャンセル":
                """キャンセル処理"""

                line_api.reply_message(
                    e.reply_token,
                    first_statement(),
                )
                db_reset_status(user_id)

            elif user["context"] == "0":
                """練習予定日入力"""

                if text == "スタート":
                    line_api.reply_message(e.reply_token, select_day_template())
                    db_update_context(user_id=user_id, context="1")

            elif user["context"] == "1":
                """練習時間帯入力"""

                db_update_day(user_id=user_id, day=text)
                line_api.reply_message(
                    e.reply_token, TextMessage(text="次は練習時間帯を入力してね！\n例)18:00~21:00")
                )
                db_update_context(user_id=user_id, context="2")

            elif user["context"] == "2":
                """親メニュー表示"""

                if not user["practice_name"]:
                    db_update_time(user_id=user_id, time=text)
                    line_api.push_message(
                        user_id, TextMessage(text="これから練習メニューを選んでいくよ！")
                    )
                elif text == "はい":
                    line_api.push_message(user_id, TextMessage(text="追加のメニューを選んでいくよ！"))
                elif text == "いいえ":
                    line_api.reply_message(
                        e.reply_token,
                        TextMessage(text="ゲーム時間を入力してね！\n例)30分×2, 20分×1"),
                    )
                    db_add_practice_name(user_id=user_id, practice_name="ゲーム")
                    db_update_context(user_id=user_id, context="6")
                    break
                line_api.reply_message(e.reply_token, select_parent_practice_template())
                db_update_context(user_id=user_id, context="3")

            elif user["context"] == "3":
                """親メニューの選択"""

                if text == "アップTR":
                    practice_period = "1"
                elif text == "シュートTR":
                    practice_period = "2"
                elif text == "トランジッションTR":
                    practice_period = "3"
                elif text == "定位置攻撃・守備TR":
                    practice_period = "4"
                elif text == "セットプレーTR":
                    practice_period = "5"
                child_practice_name_list = db_get_practice_name_list_by_practice_period(
                    practice_period=practice_period
                )
                line_api.reply_message(
                    e.reply_token,
                    select_child_practice_template(
                        child_practice_name_list=child_practice_name_list
                    ),
                )
                db_update_context(user_id=user_id, context="4")

            elif user["context"] == "4":
                """子メニューの選択"""

                db_add_practice_name(user_id=user_id, practice_name=text)
                db_add_url(user_id=user_id, url=db_get_url_by_practice_name(text))
                line_api.reply_message(
                    e.reply_token,
                    TextMessage(
                        text=f"{text}の練習時間を入力してね！\n例)15分1セット→15分×1,\n20分2セット→20分×2"
                    ),
                )
                db_update_context(user_id=user_id, context="5")

            elif user["context"] == "5":
                """練習時間入力"""

                db_add_practice_time(user_id=user_id, practice_time=text)
                line_api.reply_message(e.reply_token, practice_check_button())
                db_update_context(user_id=user_id, context="2")

            elif user["context"] == "6":
                """試合時間入力"""

                db_add_practice_time(user_id=user_id, practice_time=text)
                line_api.reply_message(
                    e.reply_token,
                    TextMessage(text="最後に、次の練習で意識することなど、メンバーに伝えたいことを自由に入力してね！"),
                )
                db_update_context(user_id=user_id, context="7")

            elif user["context"] == "7":
                """メッセージの入力"""

                db_update_last_sentence(user_id=user_id, last_sentence=text)
                line_api.reply_message(
                    e.reply_token,
                    final_check_button(
                        user["day"],
                        user["time"],
                        user["practice_name"],
                        user["practice_time"],
                    ),
                )
                db_update_context(user_id=user_id, context="8")

            elif user["context"] == "8":
                """最終確認・通知処理"""

                if text == "はい":
                    line_api.push_message(
                        GROUP_ID, TextMessage(text="次の練習メニューです。\n周知お願いします。")
                    )
                    line_api.push_message(
                        GROUP_ID,
                        notification_template(
                            user["day"],
                            user["time"],
                            user["practice_name"],
                            user["practice_time"],
                        ),
                    )
                    line_api.push_message(
                        GROUP_ID,
                        practice_info_template(user["practice_name"][:-1], user["url"]),
                    )
                    last_sentence = user["last_sentence"]
                    line_api.push_message(
                        GROUP_ID, TextMessage(text=f"メッセージ：\n{last_sentence}")
                    )
                    line_api.reply_message(
                        e.reply_token, TextMessage(text="お疲れ様！\n全体ラインに通知しておいたよ！")
                    )
                    line_api.push_message(
                        user_id,
                        first_statement(),
                    )
                    db_reset_status(user_id)
                elif text == "いいえ":
                    line_api.reply_message(
                        e.reply_token, TextMessage(text="最初からやり直してね!")
                    )
                    line_api.push_message(
                        user_id,
                        first_statement(),
                    )
                    db_reset_status(user_id)
                else:
                    line_api.reply_message(
                        e.reply_token, TextMessage(text="「はい」か「いいえ」で答えて欲しいな!")
                    )

        except Exception as err:
            print(err)


@router.post("")
async def handle_request(request: Request, background_tasks: BackgroundTasks):
    events = parser.parse(
        (await request.body()).decode("utf-8"),
        request.headers.get("X-Line-Signature", ""),
    )

    background_tasks.add_task(handle_events, events=events)

    return "ok"
```
上がLINEbotの実装部分になります。
非常に長くなってしまいましたが、user["context"]の値を会話の状態としてDB側で保持しておいて、取得・更新を繰り返すことでLINEbotと2ターン以上の会話を実現させています。
![空白の図 - ロジック.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1441421/16c146be-33f2-38dd-cbb0-3052df6a15e4.png)

また、LINEサーバーへのレスポンスは1000ミリ秒以内に行わなければなりません。今回は特に重い処理はしていませんが、FastAPIのbackground_tasksメソッドを使ってLINEbotの処理をバックグラウンドへ移行することで重い処理でもエラーが起きないよう工夫しました。
こちらは下の記事を参考にしました。

[FastAPIで非同期処理に対応したLINEBOTを開発する](https://qiita.com/uezo/items/7fa15f3d77b140190981 "FastAPIで非同期処理に対応したLINEBOTを開発する")

### contextの取得・更新
contextを更新するメソッドは、以下のdb_session.py内のdb_update_context()により行なっています。
入力から出力までの一連の処理が終わるごとにuserコレクションのcontextの値を更新していきます。
また、リクエストが来るたび、route_linebot.py内でdb_register()を使って、user_idと一致するuserレコードを取得し、user["context"]でcontextの値を取得しています。

```python:db_session.py
from pymongo import MongoClient

from config import MONGO_API_KEY

client = MongoClient(MONGO_API_KEY)
database = client.LINE_BOT
collection_user = database.user


def db_register(user_id: str) -> dict:
    """
    ユーザー情報の登録・取得
    """
    overlap_user = collection_user.find_one({"user_id": user_id})
    if overlap_user:
        return overlap_user
    user = collection_user.insert_one(
        {
            "user_id": user_id,
            "context": "0",
            "day": "",
            "time": "",
            "practice_name": [],
            "practice_time": [],
            "last_sentence": "",
            "url": [],
        }
    )
    new_user = collection_user.find_one({"_id": user.inserted_id})
    return new_user


def db_update_context(user_id: str, context: str) -> None:
    """
    contextのアップデート
    """

    user = collection_user.find_one({"user_id": user_id})
    if user:
        collection_user.update_one({"user_id": user_id}, {"$set": {"context": context}})
(省略)
```

# 7. テンプレートメッセージ
LINEbotから送られてくるメッセージタイプは色々種類がありますが、TextMessage以外で今回使用したのは以下の3つです。
- ボタンテンプレート
- 確認テンプレート
- カルーセルテンプレート

以下にコードの一部を載せておきます。
他にも多くの[メッセージタイプ](https://developers.line.biz/ja/docs/messaging-api/message-types/)があるので試してみてはいかがでしょうか。

### ボタンテンプレート
```python:button_template.py
import datetime
import calendar

from linebot.models import (
    TemplateSendMessage,
    ButtonsTemplate,
    MessageAction,
)


def get_last_4_days():
    """
    今日から直近4間の年月日(曜)をリストで返す
    フォーマット:YYYY年mm月dd日(a)
    """

    weekday_dic = {
        "Sunday": "日",
        "Monday": "月",
        "Tuesday": "火",
        "Wednesday": "水",
        "Thursday": "木",
        "Friday": "金",
        "Saturday": "土",
    }

    today = datetime.datetime.now()
    tomorrow = today + datetime.timedelta(days=1)
    day_after_tomorrow = today + datetime.timedelta(days=2)
    two_days_after_tomorrow = today + datetime.timedelta(days=3)

    today = (
        today.strftime("%Y年%m月%d日")
        + "("
        + weekday_dic[calendar.day_name[today.weekday()]]
        + ")"
    )
    tomorrow = (
        tomorrow.strftime("%Y年%m月%d日")
        + "("
        + weekday_dic[calendar.day_name[tomorrow.weekday()]]
        + ")"
    )
    day_after_tomorrow = (
        day_after_tomorrow.strftime("%Y年%m月%d日")
        + "("
        + weekday_dic[calendar.day_name[day_after_tomorrow.weekday()]]
        + ")"
    )
    two_days_after_tomorrow = (
        two_days_after_tomorrow.strftime("%Y年%m月%d日")
        + "("
        + weekday_dic[calendar.day_name[two_days_after_tomorrow.weekday()]]
        + ")"
    )

    return [today, tomorrow, day_after_tomorrow, two_days_after_tomorrow]


def select_day_template():
    """
    日程選択ボタンテンプレート
    """

    today, tomorrow, day_after_tomorrow, two_days_after_tomorrow = get_last_4_days()
    buttons_template_message = TemplateSendMessage(
        alt_text="select day",
        template=ButtonsTemplate(
            title="練習予定日を選択してね!",
            text="以下から選択",
            actions=[
                MessageAction(label=today, text=today),
                MessageAction(label=tomorrow, text=tomorrow),
                MessageAction(label=day_after_tomorrow, text=day_after_tomorrow),
                MessageAction(
                    label=two_days_after_tomorrow, text=two_days_after_tomorrow
                ),
            ],
        ),
    )

    return buttons_template_message
```

### 確認テンプレート　
```python:check_template.py
from linebot.models import TemplateSendMessage, ConfirmTemplate, MessageAction


def practice_check_button():
    """
    練習内容確認テンプレート
    """

    confirm_template_message = TemplateSendMessage(
        alt_text="pracitce check",
        template=ConfirmTemplate(
            text="練習メニューを追加しますか？",
            actions=[
                MessageAction(label="はい", text="はい"),
                MessageAction(label="いいえ", text="いいえ"),
            ],
        ),
    )

    return confirm_template_message
```

### カルーセルテンプレート 
```python:carousel_template.py
from linebot.models import (
    CarouselColumn,
    CarouselTemplate,
    MessageAction,
    URIAction,
    TemplateSendMessage,
)


def select_parent_practice_template():
    """
    親メニューの選択ボタンテンプレート
    """

    parent_practice_name_list = [
        "アップTR",
        "シュートTR",
        "トランジッションTR",
        "定位置攻撃・守備TR",
        "セットプレーTR",
    ]
    columns_list = []
    for parent_practice_name in parent_practice_name_list:
        columns_list.append(
            CarouselColumn(
                title=f"{parent_practice_name}",
                text="下のボタンを押すと練習メニュー一覧が表示されます。",
                actions=[
                    MessageAction(label="練習の一覧を表示", text=f"{parent_practice_name}")
                ],
            )
        )

    carousel_template_message = TemplateSendMessage(
        alt_text="Carousel template",
        template=CarouselTemplate(columns=columns_list),
    )
    return carousel_template_message
```

# 8. リッチメニュー 
LINEbotにはリッチメニューという機能もあります。よく公式LINEについており、タップするとメニューが表示されるアレです。
こちらも今回使用したので参考までにコードを載せておきます。

```python:create_rich_menu.py
from linebot.models import (
    RichMenu,
    RichMenuSize,
    RichMenuArea,
    RichMenuBounds,
    MessageAction,
)


def create_rich_menu():
    rich_menu_to_create = RichMenu(
        size=RichMenuSize(width=2500, height=843),
        selected=False,
        name="練習メニュー通知くん",
        chat_bar_text="メニュー",
        areas=[
            RichMenuArea(
                bounds=RichMenuBounds(x=0, y=0, width=1250, height=843),
                action=MessageAction(label="スタート", text="スタート"),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(x=1250, y=0, width=1250, height=843),
                action=MessageAction(label="キャンセル", text="キャンセル"),
            ),
        ],
    )

    return rich_menu_to_create
```

これだけではリッチメニューを表示することができないので、route_line_bot.pyにて表示させる処理を記述しています。

```python:route_line_bot.py
rich_menu_id = line_api.create_rich_menu(rich_menu=create_rich_menu())
with open("./image/rich_menu_image.jpg", "rb") as f:
    line_api.set_rich_menu_image(rich_menu_id, "image/jpeg", f)
line_api.set_default_rich_menu(rich_menu_id=rich_menu_id)
```

# 9. デプロイ
今回私はherokuにデプロイしましたが、本記事では詳細なデプロイ手順は割愛します。

[LINEDevelopersConsole](https://developers.line.biz/ja/)のWebhookの設定画面にて「デプロイ先のURL+エンドポイント」を入力します。
![スクリーンショット 2022-04-21 18.47.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1441421/4cac4937-73e1-a53d-8add-fcd9e836da91.png)

実際にLINEbotを友だち登録して、しっかりと返答があれば無事LINEbotの完成です。

# 10. 最後に
これでFastAPIを使ってLINEbotを開発し、2ターン以上会話を続ける方法を理解できたかと思います。

コードの詳細な解説は割愛してロジックを説明するだけにとどめましたが、概要にGitHubのURLを載せておきますのでそちらを見ていただけると嬉しいです。

LINEbotを開発するのは初めてで調べることの方が多かったですが、この記事を見た方が効率よく開発を進めてくだされば幸いです。