LINE Messaging APIとPython FastAPIでメッセージ応答Botを作る

LINE Messaging API からPytohn FastAPIを介して
メッセージをやり取りするLINE Botを作成します。
メッセージの応答はFlexMessage形式で表示するようにします。
Web APIは西暦か和暦で年を送信すると西暦・和暦・干支の情報を返すようにします。

# 開発環境
VS Code
Heroku CLI
Python 3.x
FastAPI
LINE Developers

# 前提
VS Code、Pythonのインストールは事前に行い、Pythonプログラムを実装する環境を用意しておいてください。
（参考: Windows環境）
https://www.python.jp/python_vscode/windows/index.html

# 完成イメージ
![LINE_MSG_Sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2622496/55c9828e-bc6a-916e-6980-e0ad1b58ebf0.png)

# LINE Developersへの登録・設定
LINE Developersにアクセスし、アカウント、プロバイダー、チャネルを作成します。
https://developers.line.biz/ja/

### アカウントの作成
LINE Develpersにはじめてアクセスする際は、アカウントを作成してください。
既に利用しているLINEのアカウントを使用する場合、LINEのアカウントでログインすることが出来ます。
普段利用しているLINEのアカウントとは別に作成する場合、メールアドレスでの登録も可能です。

### プロバイダーの作成
LINE Developersにログインしたらプロバイダーを作成します。
プロバイダーについての詳細は公式のドキュメントを参照してください。
https://developers.line.biz/ja/docs/liff/getting-started/#step-one-create-provider

### チャネルの作成
LINE Developersからプロバイダーを選択し、「新規チャネルの作成」をクリックします。
チャネルの種類から「Messaging API」を選択します。
チャネルの設定画面に従い必要事項を入力します。
同意事項にチェックを付けて作成を完了します。

### 友だち登録
LINE Developersから作成したチャネルを開き、「Messaging API」を開くとORコードが表示されます。
このQRコードをスマートフォンのLINEから読み込み友だち登録を行います。

### チャネルアクセストークンの発行
「Messaging API」を下にスクロールしていくと「チャネルアクセストークン」という項目がありますので、
「発行」ボタンをクリックし、アクセストークンを発行してください。

# Herokuの登録・設定
LINEのMessaging APIから自作のWeb APIへアクセスするには、Web APIを公開する必要があります。
今回は無料のPaaS環境であるHerokuを利用します。

### アカウント作成
Herokuへアクセスしアカウントを作成します。
以下のページより「Sign up」をクリックし、必要事項を入力して無料のアカウントを作成します。
https://id.heroku.com/login

### Heroku CLIのインストール
Herokuを操作するコマンドラインツールをローカルにインストールします。
以下のサイトより手順に従いインストールします。
https://devcenter.heroku.com/ja/articles/heroku-cli

:::note warn
公式サイトの前提条件に記載されているとおりGitの利用が必要となります。
事前にGitをインストールし、ロカール環境にてgitコマンドを使える状態にしておく必要があります。
:::

### Heroku CLIでの操作
コマンドラインツール（cmd、GitCMD、Terminal等）を起動します。

#### Herokuへのログイン
`heroku login`を入力すると、`heroku: Press any key ...`と入力が促されるので、
[Enter]等、'q'以外のキーを入力するとブラウザ―が起動します。ブラウザよりHerokuにログインすると
`Logged in as ...`が表示されログイン状態になります。
~~~ :cmd
> heroku login
heroku: Press any key to open up the browser to login or q to exit:
Opening browser to https://cli-auth.heroku.com/auth/cli/browser/xxxxxxxx
Logging in... done
Logged in as xxxxx@abc.com
~~~

#### アプリケーションの登録
`heroku create {アプリ名}`を入力しアプリケーションを登録します。
アプリ名はHeroku全体でユニークである必要があります。
正常に登録されたらURLが表示されます。
~~~ :cmd
> heroku create linebot-sample
Creating linebot-sample... done
https://linebot-sample.herokuapp.com/ | https://git.heroku.com/linebot-sample.git
~~~

#### 環境変数の登録
LINE Messaging APIとの連携に必要な情報を環境変数に登録し、アプリケーションから使用できるようにします。
必要な情報はLINE Developersのチャネルより参照できます。それぞれにコピーボタンが設けられているので値を
コピーして貼り付けることが出来ます。
* 「チャネル基本設定」≫「チャネルシークレット」
* 「Messaging API設定」≫「チャネルアクセストークン（長期）」

`heroku config:set {環境変数名}="{値}" --app {アプリケーション名}`にて環境変数と
適用先のアプリケーションを指定します。
チャネルシークレット、チャネルアクセストークンを設定した環境変数をそれぞれ用意します。
~~~ :cmd
> heroku config:set YOUR_CHANNEL_SECRET="abcdefg" --app linebot-sample
> heroku config:set YOUR_CHANNEL_ACCESS_TOKEN="abcdefghijklmn=" --app linebot-sample
~~~

# Web APIの実装
FastAPIを使用したWeb APIを実装します。
参考：https://fastapi.tiangolo.com/ja/

line-bot-sdkはLINE Botの実装に必要なライブラリが提供されています。
実装方法はGitHubに公開されています。
参考：https://github.com/line/line-bot-sdk-python

### 必要なライブラリのインストール
Pythonのパッケージ管理ツール「pip」にてFastAPIとLINE Botに必要なSDKをインストールします。
~~~ :cmd
> pip install fastapi[all]
> pip install line-bot-sdk
~~~

### プロジェクトフォルダの作成
ローカルの適当な場所にプロジェクトのルートとなるフォルダを作成します。
作成したフォルダをVS Codeで開きます。

### プロジェクト構成
最終的なプロジェクトの構成は以下のようになります。
~~~
root/
  ├ app/
  │   ├ bots/
  │   │  └ message_bot.py
  │   └ resources/
  │      └ images/
  │          ├ eto_hitsuji.jpg
  │          ├ eto_i.jpg
  │          ...
  ├ main.py
  ├ Procfile
  ├ requirements.txt
  └ runtime.txt
~~~

### 処理の実装
* `resources/images/`に適当な干支の画像ファイルを保存しておいてください。
* `message_bot.py`では受け取ったメッセージに対してFlexSendMessageのオブジェクトを返す処理を実装します。
* `main.py`ではアプリケーションの起動、HTTPリクエストに対応する処理、LINE Botのハンドリングを実装します。

#### message_bot.py
`app/bots/`フォルダに`message_bot.py`を作成します。
情報を格納するクラスとして以下を用意します。
* Eto: 干支の表示名と画像のURLの情報を格納
* JpYear: 西暦・和暦を変換するための元号の情報を格納
* YearInfo: パラメータの年に対応した返信メッセージに関する情報を格納

`get_year_info`関数でパラメータに応じて西暦、和暦の情報を取得し`JpYear`のインスタンスを生成します。
`JpYear`の`to_flex_message`関数で`FlexSendMessage`を生成して返します。
line_bot_sdkではFlex Messageの要素に対応したコンテナやコンポーネントのクラスが
それぞれ用意されており、それらを組み合わせ実装しています。

:::note info
LINE Messaging APIのFlex Messageに関する仕様の詳細は公式サイトを参考にして下さい。
https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/
:::

~~~ python:message_bot.py
from linebot.models import (BoxComponent, BubbleContainer, FlexSendMessage,
                            ImageComponent, TextComponent, TextSendMessage, URIAction)

IMG_URL_BASE: str = "https://{アプリ名}.herokuapp.com/resources/images/"


class Eto:
    """
    干支の表示内容です。

    Attributes
    ----------
    name : str
        表示名です。
    image : str
        FlexMessageのhero部分に指定する画像のURLです。
    """

    def __init__(self, name: str, image: str):
        self.name = name
        self.image = image


class JpYear:
    """
    和暦の情報です。

    Attributes
    ----------
    name : str
        和暦の表示名です。
        例）令和元年、令和2年
    abbr : str
        和暦の省略表示内容です。
        例）R.1、R.2
    start : int
        和暦の開始年です。
    end : int
        和暦の終了年です。

    """

    def __init__(self, name: str, abbr: str, start: int, end: int):
        self.name = name
        self.abbr = abbr
        self.start = start
        self.end = end


class YearInfo:
    """
    年の情報を格納するクラスです。
    """

    def __init__(self, year: int, year_jp: str, year_jp_abbr: str, eto: Eto):
        self.year = year
        self.year_jp = year_jp
        self.year_jp_abbr = year_jp_abbr
        self.eto = eto

    def to_message(self):
        """
        西暦、和暦、干支を改行で繋げて返します。
        """

        return str(self.year) + "年\n" + self.year_jp + "\n" + self.eto.name + "年"

    def to_flex_message(self):
        """
        LINE Messaging APIのFlexMessage形式を生成して返します。
        """

        contents: BubbleContainer = BubbleContainer(
            size="micro",
            hero=ImageComponent(
                url=self.eto.image,
                size="full",
                aspect_ratio="20:13",
                aspect_mode="cover",
                action=URIAction(uri="http://linecorp.com/"),
            ),
            body=BoxComponent(
                layout="vertical",
                background_color="#e6cde3",
                contents=[
                    BoxComponent(
                        layout="vertical",
                        margin="lg",
                        spacing="sm",
                        contents=[
                            BoxComponent(
                                layout="horizontal",
                                spacing="sm",
                                contents=[
                                    TextComponent(
                                        text="西暦", size="sm", color="#555555", flex=0
                                    ),
                                    TextComponent(
                                        text=str(self.year),
                                        size="sm",
                                        color="#111111",
                                        align="end",
                                    ),
                                ],
                            ),
                            BoxComponent(
                                layout="horizontal",
                                spacing="sm",
                                contents=[
                                    TextComponent(
                                        text="和暦", size="sm", color="#555555", flex=0
                                    ),
                                    TextComponent(
                                        text=self.year_jp,
                                        size="sm",
                                        color="#111111",
                                        align="end",
                                    ),
                                ],
                            ),
                            BoxComponent(
                                layout="horizontal",
                                spacing="sm",
                                contents=[
                                    TextComponent(
                                        text="干支", size="sm", color="#555555", flex=0
                                    ),
                                    TextComponent(
                                        text=self.eto.name,
                                        size="sm",
                                        color="#111111",
                                        align="end",
                                    ),
                                ],
                            ),
                        ],
                    )
                ],
            ),
        )

        return FlexSendMessage(alt_text=self.to_message(), contents=contents)


jp_years = [
    JpYear(name="明治", abbr="M", start=1868, end=1911),
    JpYear(name="大正", abbr="T", start=1912, end=1925),
    JpYear(name="昭和", abbr="S", start=1926, end=1988),
    JpYear(name="平成", abbr="S", start=1989, end=2018),
    JpYear(name="令和", abbr="R", start=2019, end=9999),
]
"""
元号の配列です。
"""

eto = [
    Eto(name="申（さる）", image=IMG_URL_BASE + "eto_saru.jpg"),
    Eto(name="酉（とり）", image=IMG_URL_BASE + "eto_tori.jpg"),
    Eto(name="戌（いぬ）", image=IMG_URL_BASE + "eto_inu.jpg"),
    Eto(name="亥（い）", image=IMG_URL_BASE + "eto_i.jpg"),
    Eto(name="子（ね）", image=IMG_URL_BASE + "eto_ne.jpg"),
    Eto(name="丑（うし）", image=IMG_URL_BASE + "eto_ushi.jpg"),
    Eto(name="寅（とら）", image=IMG_URL_BASE + "eto_tora.jpg"),
    Eto(name="卯（う）", image=IMG_URL_BASE + "eto_u.jpg"),
    Eto(name="辰（たつ）", image=IMG_URL_BASE + "eto_tatsu.jpg"),
    Eto(name="巳（み）", image=IMG_URL_BASE + "eto_mi.jpg"),
    Eto(name="午（うま）", image=IMG_URL_BASE + "eto_uma.jpg"),
    Eto(name="未（ひつじ）", image=IMG_URL_BASE + "eto_hitsuji.jpg"),
]
"""
干支の配列です。
"""


def get_year_info(src: str):
    """
    引数で指定された年の情報を返します。

    Parameters
    ----------
    src : str
        西暦の年、または元号＋年を指定します。

    Returns
    ----------
    result : YearInfo
        西暦、和暦、干支の情報が格納された情報です。
    """

    year = src.replace("年", "")

    yearnum: int = 0
    year_j: int = 0
    jp_year: JpYear = None

    if year.isdecimal():  # 入力された値が数値(西暦)の時の処理
        yearnum = int(year)

        # 西暦から該当する和暦を取得
        for jp_y in jp_years:
            if jp_y.start <= yearnum <= jp_y.end:
                jp_year = jp_y
                year_j = yearnum - jp_y.start + 1

    else:  # 入力値が和暦の時の処理
        jp = year[:2]  # 年号部分の切り出し
        yy = year[2:]  # 年部分の切り出し

        for jp_y in jp_years:
            if jp == jp_y.name or jp.startswith(jp_y.abbr):
                jp_year = jp_y
                yearnum = jp_y.start
                if yy != "元" and yy.isdecimal():
                    year_j = int(yy)
                else:
                    year_j = 1

                yearnum += year_j - 1

    result: YearInfo = YearInfo(
        year=yearnum,
        year_jp=jp_year.name + str(year_j) + "年",
        year_jp_abbr=jp_year.abbr + "." + str(year_j).zfill(2),
        eto=eto[yearnum % 12],
    )

    return result


def send(message: str):
    """
    引数のメッセージに応答するメッセージを返します。

    Parameters
    ----------
    message : str
        Messaging APIで受信したメッセージです。

    Returns
    ----------
    managent_bot : FlexSendMessage or TextSendMessage
    """

    if "年" in message or (message.isnumeric() and len(message) == 4):
        year_inf: YearInfo = get_year_info(message)
        return year_inf.to_flex_message()

    else:
        result = TextSendMessage(text="西暦（yyyy）か和暦（令和3年）のように指定してください。")

    return result
~~~

#### main.py
プロジェクトのルートにmain.pyファイルを作成します。
必要なモジュールをインポートし、Fast APIとLINE Botに必要なインスタンスを作成します。
LineBotApi、WebhookHandlerに必要なチャネルアクセストークン、チャネルシークレットは
環境変数に登録していますので、環境変数から取得してセットします。

##### 画像ファイルダウンロード用のハンドラ：resources
Flex Messageのhero部分に表示する画像はLINEから直接画像ファイルにアクセスされるため、
指定した画像がダウンロードできるようにGETメソッドに対応した`resource`関数を用意し、
指定されたパスの画像ファイルをレスポンスするようにします。

##### メッセージ送信のリクエストハンドラ：callback
LINEでメッセージを送信するとPOSTメソッドでURLが"{ホスト}/callback"でリクエストされます。
それに従った処理を`callback`関数として定義します。
ここではHTTPヘッダで渡されたシグネチャとリクエストボディをLINEのhandlerに渡します。
handler側でLINEからの正当なアクセスであるか検証を行ってくれます。
エラーが発生しなければOKでレスポンスします。

##### メッセージイベントハンドラ：handle_message
受け取ったメッセージを返信する処理はLINEのhandlerより呼び出されるため、
`handle_message`関数として別途定義します。
引数で受け取った`MessageEvent`にLINEから送信されたメッセージが格納されているので、
先に実装した`message_bot`の`send`関数に渡して返信メッセージを取得します。
`LineBotApi`の`reply_message`関数に返信用のトークンと返信メッセージを渡して終了です。

~~~ python:main.py
import os
from fastapi import FastAPI, Header, Request
from fastapi.responses import FileResponse
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent
from starlette.exceptions import HTTPException

from app.bots import message_bot

# FastAPIのインスタンス作成
app = FastAPI(title="linebot-sample", description="This is sample of LINE Bot.")

# LINE Botに関するインスタンス作成
line_bot_api = LineBotApi(os.environ["YOUR_CHANNEL_ACCESS_TOKEN"])
handler = WebhookHandler(os.environ["YOUR_CHANNEL_SECRET"])


@app.get("/")
def root():
    """
    ルートにアクセスした際の処理です。APIの情報を返します。
    """

    return {"title": app.title, "description": app.description}


@app.get(
    "/resources/{directory}/{file}",
    summary="静的ファイルの取得用です。",
    description="指定したパスのファイルをレスポンスします。",
)
def resources(directory: str, file: str):

    return FileResponse("./app/resources/" + directory + "/" + file)


@app.post(
    "/callback",
    summary="LINE Message APIからのコールバックです。",
    description="ユーザーからメッセージが送信された際、LINE Message APIからこちらのメソッドが呼び出されます。",
)
async def callback(request: Request, x_line_signature=Header(None)):

    body = await request.body()

    try:
        handler.handle(body.decode("utf-8"), x_line_signature)

    except InvalidSignatureError:
        raise HTTPException(status_code=400, detail="InvalidSignatureError")

    return "OK"


@handler.add(MessageEvent)
def handle_message(event):
    """
    LINE Messaging APIのハンドラより呼び出される処理です。
    受け取ったメッセージに従い返信メッセージを返却します。

    Parameters
    ----------
    event : MessageEvent
        送信されたメッセージの情報です。
    """

    res_data = message_bot.send(event.message.text)
    line_bot_api.reply_message(event.reply_token, res_data)
~~~

### Herokuに必要なファイルの準備
Herokuへデプロイし稼働させるために必要なファイルをプロジェクトフォルダ直下に作成します。

#### Procfile
FastAPIのプログラムをHerokuで起動させるためのコマンドを定義します。

~~~ :Procfile
web:  uvicorn main:app --reload --host=0.0.0.0 --port=${PORT:-5000}
~~~

#### requirements.txt
VSCodeのターミナルより`pip freeze`コマンドにてインストールされているモジュールの確認ができます。
今回必要なモジュールは以下となりますので、取得した情報を元に作成してください。

~~~ :requirements.txt
fastapi==0.75.0
line-bot-sdk==2.2.1
uvicorn==0.17.6
~~~

::: note warn
`pip freeze -> requirements.txt`コマンドで直接テキストファイルに
出力できますが、インストールされているモジュールが全て出力されます。
:::

#### runtime.txt
実行するPythonのバージョンを指定します。
Herokuの推奨バージョンに従って設定してください。

~~~ :runtime.txt
python-3.10.4
~~~

# Herokuへデプロイ
HerokuへのデプロイはGitを介して行います。
また、初回のデプロイ時はGitリポジトリとHerokuへのリモートを関連付けるため、
Herokuにログインしていない場合は、Heroku CLIにてログインしてください。

### コマンド実行
以下の順でVSCodeのターミナルからコマンドを実行します。

::: note info
VSCodeのターミナルを起動すればデフォルトでプロジェクトのルートが
カレントディレクトリになっています。
:::

* `git init`を実行しリポジトリを作成します。
* `git add .`を実行しプロジェクトフォルダ配下の全てをコミット対象にします。
* `git commit -m "{コメント}"`を実行しコミットを行います。
* `heroku git:remote -a {Herokuに登録したアプリ名}`を実行しHerokuをGitのリモート先にします。
* `git push heroku master`を実行しHerokuへプッシュすることでデプロイが開始されます。

ターミナルに出力されたメッセージにてデプロイが完了したことを確認して下さい。

~~~ :VSCodeターミナル
～省略～
To https://git.heroku.com/{アプリ名}.git
   [new branch]      master -> master
~~~

以降、プログラムを修正した際のデプロイは`git add`、`git commit`、`git push`のみ必要です。

### 動作確認

ブラウザよりルートのURLにアクセスしてtitleとdescriptionのJSON文字列が返ってくれば無事に成功です。

~~~ json:https://{アプリ名}.herokuapp.com/
{"title":"linebot-sample","description":"This is sample of LINE Bot."}
~~~

# LINE Developersの設定変更
対象のチャネルに対して今回作成したWebAPIをWebhookとして使用するように設定を変更します。

### Webhookの設定
ブラウザよりLINE Developersのコンソールにログインし、対象チャネルのMessaging API設定を開きます。
[Webhook設定] > [Webhook URL]に`https://{アプリ名}.herokuapp.com/callback`を指定します。
Webhookの利用をONにします。
検証ボタンをクリックし「成功」のメッセージが表示されればOKです。
![webhook.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2622496/0c8e4df9-325b-dc48-f279-8dc5e40233d6.png)

### 応答メッセージの設定
チャネルを作成した状態ではデフォルトで応答メッセージが設定されており、
Webhookの応答メッセージと両方表示されてしまうので、[応答メッセージ] > [編集]を開き、
[応答メッセージ]を"オフ"、[Webhook]を"オン"にします。
![replay_message.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2622496/5404e69b-6041-ef18-a8fb-dd5e1c360e5f.png)

以上で完了です。

LINEから友だち登録したBotのトーク画面からメッセージを送信してみてください。
冒頭の完成イメージのような返信メッセージが受信されれば無事成功です。