Python(Flask)で実装したLINEBotを"Herokuを使わずに"動かす

#この記事について
掲題の通り。
LINEBotに関する記事は様々な言語についてのものが多数存在するが、探してみた限りだとほぼ全てがHeroku等のPaaS環境で動かすことを前提としたものだった。
この記事では敢えてHeroku等は活用せず、自前の環境で動作させる方法を記載する。

#アプリケーション構成
下図参照。
![linebot_arch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476892/dad216c4-7ea2-40bf-53c9-bf96d770b050.png)
## Flask
Python用Webアプリケーション開発フレームワーク。
これ自体にも一応Webサーバとしての機能は備わっているが、あくまで最低限の動作確認用。本番運用は別にWebサーバが必要。
公式：https://flask.palletsprojects.com/en/1.1.x/
## uWSGI
WSGIはWeb Server Gateway Interfaceの略で、Webサーバとアプリケーションをつなぐもの。Python特有の仕様。
uWSGIはこの仕様を充足するサーバであり、今回はFlaskのアプリケーションと後述のNginxをつなぐ役割を果たす。
Flaskを稼働させるアプリケーションサーバでもある。
公式：https://uwsgi-docs.readthedocs.io/en/latest/
## Nginx
Webサーバ。クライアントからのリクエストを受け取り、それに対するレスポンスを返す。
今回はクライアントとの間にLINEサーバを挟んでいるので、リクエストはLINEサーバから受け取ることになる。
LINE Botでは**WebサーバのSSL化が必須**とされているので、別途ドメインの取得及びSSL証明書（オレオレ証明書不可）の取得も必須である。
公式：https://nginx.org/en/
## LINEサーバ
クライアントのLINEアプリと直接通信を行うところ。
LINE Developersというコンソールにて、ブラウザから各種設定を行う。

# 開発環境

* CentOS 8系
* Python 3系
* LINEアカウント取得済
* ドメイン取得済(ここでは `www.example.com` を取得したものとして記載する)
* 上記ドメインのSSL証明書取得済

# LINE Developers側の設定
## チャネルの作成
[公式のリファレンス](https://developers.line.biz/ja/docs/messaging-api/getting-started/)に沿って設定を行っていけばOK 
以前はここでプランの設定を求められていたが（フリープラン or 有料）、2020年5月時点で聞かれなくなっている。しかもフリープランで使用できなかったはずのPUSH APIがいつの間にか使用可能になっている。。。

## Botの設定を行う
LINE Developersのコンソールでの設定を行う。ここも[公式のリファレンス](https://developers.line.biz/ja/docs/messaging-api/building-bot/)に沿って設定を行っていけばOK 
BotサーバのエンドポイントURLはこの時点ではまだ入力しなくてOK。ここ重要なポイントは以下

* 基本設定にて確認可能なChannel secretを控えておく
* チャネルアクセストークンを発行し、控えておく

# 自前の環境でLINE Botの作成
まずはFlask, line-bot-sdk, uWSGIをインストール

```bash
$ pip install flask 
$ pip install line-bot-sdk
$ pip install uwsgi
```

## Flaskにてアプリケーションの作成
line_botディレクトリ配下に、以下のような構成で作成。
設定ファイルを別ファイルにしているが、ここは好みかと

```
line_bot
 |-app.py
 |-conf.json
 |-logging.conf
```

### app.py
[line-bot-sdk-pythonのサンプルプログラム](https://github.com/line/line-bot-sdk-python)を参考に作成。
ユーザが送信したのがテキストの場合はオウム返し、画像・動画・スタンプが送信された場合は定型文を返すようにした

```python:app.py
# -*- coding: utf-8 -*-

from flask import Flask, request, abort

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, ImageMessage, VideoMessage, StickerMessage, TextSendMessage
)
import os
import sys
import json
from logging import getLogger, config

app = Flask(__name__)

ABS_PATH = os.path.dirname(os.path.abspath(__file__))
with open(ABS_PATH+'/conf.json', 'r') as f:
    CONF_DATA = json.load(f)

CHANNEL_ACCESS_TOKEN = CONF_DATA['CHANNEL_ACCESS_TOKEN']
CHANNEL_SECRET = CONF_DATA['CHANNEL_SECRET']

line_bot_api = LineBotApi(CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)

config.fileConfig('logging.conf')
logger = getLogger(__name__)

@app.route("/test", methods=['GET', 'POST'])
def test():
    return 'I\'m alive!'

@app.route("/callback", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))

@handler.add(MessageEvent, message=ImageMessage)
def handle_image(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="Image"))

@handler.add(MessageEvent, message=VideoMessage)
def handle_video(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="Video"))

@handler.add(MessageEvent, message=StickerMessage)
def handle_sticker(event):
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="Sticker"))

if __name__ == "__main__":
    # ポートは今回は9999番を指定
    port = int(os.getenv("PORT", 9999))
    # Flaskはデフォルトだと実行しても外部公開されないので、runの引数にIPとポートを指定する
    app.run(host="0.0.0.0", port=port)
```

### conf.json
LINE Developersで取得したChannel secretとチャネルアクセストークンを設定する。
今回は外部ファイルに設定するようにしたが、app.py内に直接書いてもよいと思います

```json:conf.json
{
"CHANNEL_SECRET": "Channel secretを設定",
"CHANNEL_ACCESS_TOKEN": "チャネルアクセストークンを設定"
}
```

### logging.conf
完全に好みです。

```conf:logging.conf
[loggers]
keys=root

[handlers]
keys=fileHandler

[formatters]
keys=simpleFormatter

[logger_root]
level=INFO
handlers=fileHandler

[handler_fileHandler]
class=handlers.TimedRotatingFileHandler
formatter=simpleFormatter
args=('app.log','MIDNIGHT')

[formatter_simpleFormatter]
format=%(asctime)s %(levelname)s %(message)s
```

## Nginxの設定

Nginxは /etc/nginx/ にあるものとして進める。
設定ファイルを修正していない場合、/etc/nginx/conf.d/*.conf を読み込んで設定するようになっていると思うので
/etc/nginx/conf.d/ 配下に linebot.conf を作成し、以下を記載する。
今回は9998番ポートを使用するので、別途ポート開放は必要。

```conf:linebot.conf
server {
    listen       9998 ssl;
    server_name  example.com;

    ssl_protocols       TLSv1.2 TLSv1.1 TLSv1;
    ssl_ciphers         ALL:!aNULL:!SSLv2:!EXP:!MD5:!RC4:!LOW:+HIGH:+MEDIUM;
    ssl_certificate     [CRTファイルを置いた場所を指定];
    ssl_certificate_key [鍵ファイルを置いた場所を指定];
    ssl_session_timeout 10m;

    location / {
        include uwsgi_params;
        uwsgi_pass unix:///tmp/uwsgi.sock;
        proxy_pass http://127.0.0.1:9999/;
    }
}
```

設定出来たら、Nginxを再起動

```
$ sudo systemctl restart nginx
```

## uWSGIの設定
app.pyと同階層に、uWSGIの設定ファイル uwsgi.ini を作成し、以下を記載

```ini:uwsgi.ini
# uwsgi.ini

[uwsgi]

# wsgiファイル
wsgi-file=[app.pyのパス]
callable=app

# アクセス許可ホスト:ポート
http=0.0.0.0:9999

socket=/tmp/uwsgi.sock
module=app
chmod-socket=666

pidfile=/home/[pidファイルを出力したいパス]/uwsgi.pid

# daemonizeを指定するとデーモン化。指定したパスにstdout/stderrを出力
daemonize=/[logファイルを出力したいパス]/uwsgi.log
```

あとは下記コマンドでuWSGIを起動すれば、アクセスが可能になる。

```bash
$ uwsgi --ini myapp.ini
```
停止するときは、uwsgi.pidのPIDでkill -QUITすればOK

# 動作確認

ブラウザでもcurlでもなんでもいいので、`https://www.example.com:9998/test`にアクセスすると、"I'm alive!"が返ってくるはず。
これが確認できたら、LINE DevelopersにてWebhook URLに`https://www.example.com:9998/callback`を指定し、Use webhookをオンにする。
（URL入力欄下部の"verify"ボタンはなぜかエラーになる。これについては調査中）
これで、作成したLINE Botが動作するようになるはず。
![linebot_sc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/476892/791445b4-1f0f-7441-6190-8b21272026e8.png)

# 個人的な今後の課題
- 上述したが、以前フリープランで使えなかったはずのPUSH APIが使えるようになっていた。
フリープランでも開発の幅が大きく広がったと思うので、これについて使用方法を調べる
- LINE DevelopersのコンソールにてWebhook URLのverifyがエラーとなる件が未解決。
verify時にLINEサーバから送信されてくるリクエストをそれ専用にハンドリングする必要がある？
    - **2020/05/13 追記** [これについて調査した記事](https://qiita.com/hiro0236/items/c1d41be73817d0ad5cec)を書きました

# おわりに
Herokuを使用してLINE Botを動かす手順を記載してるどのページでも書いていないのですが
Herokuは無料枠の場合、一定時間(30分程度？)動作がないとスリープ状態となってしまい
接続タイムアウトになったりして、とても実用に堪えるものではありません。
有料プランの導入を考えていない場合、自前の環境があるならそちらで動かした方がよいでしょう。

私の記載した情報に間違い等あれば教えていただけるとありがたいです。