LINE WORKS トークBotのトークスクリプトを管理するWebアプリをつくりました

こんにちは、@0yanです。
Python＋Heroku PostgresでLINE WORKS トークBotのトークスクリプトを管理（表示・作成・更新・削除）するWebアプリを作りました。
初めてのWebアプリ作成だったので相当苦労しました・・・。次回つくる際の備忘録兼同じように社内でトークBotを導入したい方の参考になればと思い、投稿することにしました。



# 想定する読者
- Python初心者で、データベースを使ったWebアプリを作ってみたい方
- LINE WORKS トークBotのトーク内容を自分以外に管理させたい方
- 自分でDBサーバーたてるのはしんどい、Heroku Postgresに興味があるという方



# Webアプリをつくった背景
当社のバックオフィス部門には経理財務課、総務課、そして私が所属する人事課がありますが、バックオフィスにいると当然ですが、**社員からの問い合わせが毎日のように発生します。**そしてその度、業務を中断するので集中力も途切れ、非常に生産性が低いな・・・と感じておりました。**特に内線。**あれは本当に廃止したい。強制的に出させられるので本当に不愉快。

ということで、少しでも問い合わせを減らすためにLINE WORKSでトークBotを作ろうと思ったのですが、質疑応答のトークスクリプトを登録できるのが自分だけだと、現場の問い合わせ対応は減っても依頼されたトークスクリプトを登録するという業務が発生します。工数減りません。

なので、Heroku Postgres（以下、DB）を使用し、DBにトークスクリプトを登録・更新・削除するための管理用インターフェースとして、初めてWebアプリを作りました。



# 環境
- Windows 10 Home Edition
- Python 3.7.3（Anaconda3をインストール）
- Heroku Postgres



# ライブラリ
- Flask 1.1.1
- gunicorn 19.9.0
- lineworks 0.0.5
- SQLAlchemy 1.3.7



# 前提条件
1. Herokuのアカウント取得済＆GitHubからのデプロイ方法を理解している
2. LINE WORKSのAPI情報取得済



# 完成イメージ
まだプロトタイプなのでCSSは殆ど手をつけておりませんが、Webアプリ側の完成イメージは下図のような感じです。
運用の際は、他のメンバーにこのアプリへのログイン権限を付与し、適時、トークスクリプトを作成・更新・削除して貰う予定です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/8392ccba-113b-d846-2baa-8dd52bc3954b.png)

なお、LINE WORKSでBotに話しかけると下図のような挙動をします。
<img width="375" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/4638f2b2-a5bf-aaac-1378-08fe0c475454.png">

ゆくゆくは自然言語処理できるAIも組み合わせて、「●●について」という問いかけでなくても返信できるようにしたいです。



# 完成までの大まかな流れ
実際には試行錯誤しながらなので順番前後しましたが、ざっくり下記のとおりです。

1. Herokuアプリを作成、Heroku Postgresをadd on
2. models.pyの作成
3. views.pyと対応するテンプレート（html）の作成
4. GitHub経由でHerokuにデプロイ
5. テーブル作成
6. BotのコールバックURLを指定＆テスト


### 1.Herokuアプリを作成、Heroku Postgresをadd on
Herokuアプリの作成については、[過去記事](https://qiita.com/0yan/items/492ca08bb3bfde32dab8#heroku-app%E3%81%AE%E4%BD%9C%E6%88%90)をご参照頂ければと思います。
なお、この際、過去記事同様にLINE WORKS API情報も設定しておいて頂ければと思います。

アプリ作成後はHeroku Postgresをadd onしますが、その方法は@mickie895さんの以下の記事が参考になりましたのでご紹介致します。
>[HerokuのPostgreSQLの使い方【環境構築辺】](https://qiita.com/mickie895/items/9d9e95f2b2efb1b961df)

なお、Heroku Postgresのadd onができると、環境変数に`DATABASE_URL`が自動設定されます。


### 2.models.pyの作成
SQLAlchemyにおけるテーブルは、Baseクラスを継承したクラスで定義します。

```python:models.py
# coding: utf-8

import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base


Base = declarative_base()

class Talk(Base):
    __tablename__ = 'talks'
    check_box = sqlalchemy.Column(sqlalchemy.Boolean, default=False)
    talk_id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
    receive_msg = sqlalchemy.Column(sqlalchemy.String(100), nullable=False)
    reply_msg = sqlalchemy.Column(sqlalchemy.String(1000), nullable=False)
```

check_boxは削除するレコードを選択するために用意しました。
talk_idはレコードを一意に識別するための主キー。
receive_msgはBotが受信するメッセージ、reply_msgはBotに返信させたいメッセージです。


### 3.views.pyと対応するテンプレート（html）の作成
いよいよWebアプリのView部分をつくっていきます。
まず、views.pyです。各メソッドが何をしているかは、メソッド説明文に記述しましたので、コードをご覧ください。

```python:views.py
# coding: utf-8

import os

from flask import Flask, abort, redirect, render_template, request, url_for
from lineworks.talkbot_api import TalkBotApi
from sqlalchemy.orm import sessionmaker

from models import *


app = Flask(__name__)

# DATABASE_URL、LINE WORKS API情報はHerokuの環境変数に設定
engine = sqlalchemy.create_engine(os.environ.get('DATABASE_URL'), echo=True)
talkbot = TalkBotApi(
    api_id=os.environ.get('API_ID'),
    private_key=os.environ.get('PRIVATE_KEY'),
    server_api_consumer_key=os.environ.get('SERVER_API_CONSUMER_KEY'),
    server_id=os.environ.get('SERVER_ID'),
    bot_no=os.environ.get('BOT_NO'),
    domain_id=os.environ.get('DOMAIN_ID'),
    account_id=os.environ.get('ACCOUNT_ID'),
    room_id=os.environ.get('ROOM_ID')
)


@app.route('/create_table', methods=['GET'])
def create_table():
    '''
    models.pyのクラスのうち、一度も作成していないテーブルを作成します。

    :return: ステータスコード200
    '''
    Base.metadata.create_all(bind=engine)
    return '', 200


@app.route('/delete_table', methods=['DELETE'])
def delete_table():
    '''
    talksテーブルを削除します。
    Curlコマンドで削除することを想定しています。

    :return: ステータスコード200
    '''
    if request.method == 'DELETE':
        sql = 'DROP TABLE talks;'
        engine.execute(sql)
        return '', 200
    else:
        return abort(405)


@app.route('/', methods=['GET', 'POST'])
def index():
    '''
    本記事では紹介しませんが、トークスクリプト一覧ページの手前にログイン画面を表示しようと思っております。
    現在はプロトタイプのため、/authenticationにID・PWをPOSTする簡単なものになっておりますが、
    本番運用するときにはFlask-loginを使いたいなぁと考えております。

    :return: index.htmlのレンダリング結果
    '''
    title = 'Login'
    if request.method == 'GET':
        msg = ''
        return render_template('index.html', title=title, msg=msg)
    elif request.method == 'POST':
        msg = 'ログインIDまたはパスワードが間違っています。'
        return render_template('index.html', title=title, msg=msg)
    else:
        return abort(405)


@app.route('/authentication', methods=['POST'])
def authentication():
    '''
    ログインページから遷移してくるページです。
    Herokuの環境変数に設定されたLOGIN_IDとPASSWORDと、ログインページからPOSTされたものが一致するか確認します。
    一致した場合はトークスクリプト一覧ページに、一致しなかった場合はログインページに転送します。

    :return: トークスクリプト一覧ページまたはログインページに転送
    '''
    if request.method == 'POST':
        LOGIN_ID = os.environ.get('LOGIN_ID')
        PASSWORD = os.environ.get('PASSWORD')
        if request.form['login_id'] == LOGIN_ID and request.form['password'] == PASSWORD:
            return redirect(url_for('talkscript'))
        else:
            return redirect(url_for('index'))
    else:
        return abort(405)


@app.route('/talkscript', methods=['GET'])
def talkscript():
    '''
    トークスクリプト一覧ページです。
    Heroku Postgresに登録されているトークスクリプトを表示します。
    作成ボタンを押下するとトークスクリプト作成画面に遷移します。
    削除したいトーク内容にチェックを入れて削除ボタンを押下すると、当該トークが削除されます。
    更新したいトーク内容のIDをクリックすると、トークスクリプト更新画面に遷移します。

    認証に失敗した場合、ログインページに転送します。

    :return: talkscript.htmlのレンダリング結果 または ログインページに転送
    '''
    if request.method == 'GET':
        Session = sessionmaker(bind=engine)
        session = Session()
        title = 'トーク一覧'
        talks = session.query(Talk).order_by(Talk.talk_id).all()
        session.close()
        return render_template('talkscript.html', title=title, talks=talks)
    else:
        return abort(405)


@app.route('/talkscript/create', methods=['GET'])
def create_view():
    '''
    トークスクリプト一覧ページから遷移してくるページです。
    トークスクリプトの作成ページを表示します。

    :return: create.htmlのレンダリング結果
    '''
    if request.method == 'GET':
        title = 'トークの作成'
        return render_template('create.html', title=title)
    else:
        return abort(405)


@app.route('/talkscript/update', methods=['GET'])
def update_view():
    '''
    トークスクリプト一覧ページから遷移してくるページです。
    トークスクリプトの更新ページを表示します。
    update_idはトークスクリプト一覧ページで押下したトークのID（talk_id）です。
    DBに登録されているレコード（トーク）のうち、talk_id = update_idとなるレコードをupdate.htmlに渡します。

    :return: update.htmlのレンダリング結果
    '''
    if request.method == 'GET':
        Session = sessionmaker(bind=engine)
        session = Session()
        title = 'トークの更新'
        update_id = request.args.get('talk_id')
        talks = session.query(Talk).filter(Talk.talk_id == update_id).all()
        session.close()
        return render_template('update.html', title=title, update_id=update_id, talks=talks)
    else:
        return abort(405)


@app.route('/create_script', methods=['POST'])
def create_script():
    '''
    トークスクリプト作成ページから遷移してくるページです。
    トークスクリプトを作成後、トークスクリプト一覧ページに転送します。

    :return: トークスクリプト一覧ページに転送
    '''
    if request.method == 'POST':
        Session = sessionmaker(bind=engine)
        session = Session()
        talk = Talk()
        talk.receive_msg = request.form['receive_msg']
        talk.reply_msg = request.form['reply_msg']
        session.add(talk)
        session.commit()
        session.close()
        return redirect(url_for('talkscript'))
    else:
        return abort(405)


@app.route('/update_script', methods=['POST'])
def update_script():
    '''
    トークスクリプト更新ページから遷移してくるページです。
    トークスクリプトを更新後、トークスクリプト一覧ページに転送します。

    :return: トークスクリプト一覧ページに転送
    '''
    if request.method == 'POST':
        Session = sessionmaker(bind=engine)
        session = Session()
        talk = session.query(Talk).filter(Talk.talk_id == request.form['update_id']).first()
        talk.receive_msg = request.form['receive_msg']
        talk.reply_msg = request.form['reply_msg']
        session.commit()
        session.close()
        return redirect(url_for('talkscript'))
    else:
        return abort(405)


@app.route('/delete_script', methods=['POST'])
def delete_script():
    '''
    トークスクリプト一覧ページで選択したレコード（トーク）を削除後、トークスクリプト一覧ページに転送します。

    :return: トークスクリプト一覧ページに転送
    '''
    if request.method == 'POST':
        Session = sessionmaker(bind=engine)
        session = Session()
        selected_ids = request.form.getlist('selected')
        for selected_id in selected_ids:
            print(selected_id)
            session.query(Talk).filter(Talk.talk_id == selected_id).delete()
            session.commit()
        session.close()
        return redirect(url_for('talkscript'))
    else:
        return abort(405)


@app.route('/callback', methods=['POST'])
def callback():
    '''
    本URLはLINE WORKSのトークBotのコールバックURLに設定されており、「●●について」という形式で問い合わせが来ると、
    「●●」の部分とreceive_msgと一致するレコードがあるか、トークスクリプトから検索します。
    一致するレコードがある場合は、それに対応するreply_msgを返信します。
    一致しない場合は問い合わせ形式が「●●について」になっているか確認するよう促すメッセージを返信します。

    なお、LINE WORKSからのPOSTリクエストでない場合はステータスコード400を返します。

    :return: ステータスコード200, 400, 405のいずれか
    '''
    if request.method == 'POST':
        body = request.json
        if body['type'] == 'message':
            try:
                Session = sessionmaker(bind=engine)
                session = Session()

                # 「●●について」と質問が来るが、DB登録値は「●●」なので後ろ4文字削除
                row_msg = str(body['content']['text'])
                receive_msg = row_msg[:-4]
                reply_msg_list = session.query(Talk.reply_msg).filter(Talk.receive_msg == receive_msg).first()
                reply_msg = str(reply_msg_list[0])
                talkbot.send_text_message(send_text=reply_msg)
                return '', 200
            except:
                error_msg = ('問い合わせの仕方は「●●について」でお願いします。'
                            '問い合わせ方法が合っていても、この返信の場合はすみません、わかりません。')
                talkbot.send_text_message(send_text=error_msg)
                return '', 200
        else:
            return abort(400)
    else:
        return abort(405)


if __name__ == '__main__':
    app.run()
```


次はテンプレートです。layout.htmlを作り、それを拡張する形で作りました。

``` layout.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>{{title}}</title>
    <link rel="stylesheet" type="text/css" href="..\static\css\style.css">
</head>
<body>
    {% block content %}
    {% endblock %}
</body>
</html>
```

``` talkscript.html
{% extends 'layout.html' %}
{% block content %}
<h3>トーク一覧</h3>
<button><a href="/talkscript/create">トークの作成</a></button>
<form action="/delete_script" method="post">
    <table>
        <tr>
            <th>□</th>
            <th>ID</th>
            <th>受信メッセージ</th>
            <th>返信メッセージ</th>
        </tr>
        {% for talk in talks %}
        <tr>
            <td>{% if talk.check_bok == True %}
                <input type="checkbox" value="{{talk.talk_id}}" name="selected" checked>
                {% else %}
                <input type="checkbox" value="{{talk.talk_id}}" name="selected">
                {% endif %}</td>
            <td><a href="/talkscript/update?talk_id={{talk.talk_id}}">{{talk.talk_id}}</a></td>
            <td>{{talk.receive_msg}}</td>
            <td>{{talk.reply_msg}}</td>
        </tr>
        {% endfor %}
    </table>
    <input type="submit" value="トークの削除">
</form>
{% endblock %}
```

``` create.html
{% extends 'layout.html' %}
{% block content %}
<h3>{{title}}</h3>
<p>作成したいトーク内容を入力してください。</p>
<form action="/create_script" method="POST">
        <label for="receive_msg">受信メッセージ: </label>
        <input id="receive_msg" type="text" name="receive_msg">
        <label for="reply_msg">返信メッセージ: </label>
        <input id="reply_msg" type="text" name="reply_msg">
        <input type="submit" value="作成">
</form>
{% endblock %}
```

``` update.html
{% extends 'layout.html' %}
{% block content %}
<h3>{{title}}</h3>
<p>トークの更新内容を入力してください。</p>
<p>【更新前】</p>
<table>
    <tr>
        <th>受信メッセージ</th>
        <th>返信メッセージ</th>
    </tr>
    {% for talk in talks %}
    <tr>
        <td>{{talk.receive_msg}}</td>
        <td>{{talk.reply_msg}}</td>
    </tr>
    {% endfor %}
</table>
<br>
<p>【更新後】</p>
<form action="/update_script" method="POST">
    <table>
        <tr>
            <th>受信メッセージ</th>
            <th>返信メッセージ</th>
        </tr>
        <tr>
            <td><input id="receive_msg" type="text" name="receive_msg"></td>
            <td><input id="reply_msg" type="text" name="reply_msg"></td>
        </tr>
    </table>
    <input id="update_id" type="hidden" value="{{update_id}}" name="update_id">
    <input type="submit" value="更新">
</form>
{% endblock %}
```


### 4. GitHub経由でHerokuにデプロイ
以下の記事を参考にして頂ければと思います。

[【備忘録】GitHub経由でHerokuにデプロイするまでの流れ](https://qiita.com/0yan/items/cd7eb98e114dd812c498)



### 5. テーブル作成
デプロイ成功後、`https://{アプリ名}.herokuapp.com/create_table`にアクセスすることでテーブルが作成されます。



### 6. BotのコールバックURLを指定＆テスト
LINE WORKS Developer Console > Bot画面で、BotのコールバックURLを指定します。指定するURLは`https://{アプリ名}.herokuapp.com/callback`です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/738cd45c-c452-20c8-81f0-0e2e2ca61961.png)

設定後、DBのreceive_msgフィールドに登録したワード＋「について」をBotに送信し、完成イメージのような返信が返ってくれば完成です！



# さいごに
長文となりましたが、閲覧頂きありがとうございました。この記事が誰かの役に立てば幸いです。



# その他参考にした記事
本Webアプリ開発にあたり、ハマったところと助けて貰った参考記事をご紹介致します。


#### SQLAlchemyの使い方
公式ドキュメントは当然英語。読みたくないなぁ・・・と思っていた自分を救ってくれたのは@ckernさんの記事でした。
>[【PythonのORM】SQLAlchemyで基本的なSQLクエリまとめ](https://qiita.com/ckern/items/a762b1bc0f192a55eae8)

大変分かり易かったです！


#### Flaskのリダイレクトでエラーになる
url_forに渡すのは、routeデコレーターの引数に指定している値ではなく、メソッド名であることを知らず、四苦八苦していました。
間違えに気付かせてくださったのは以下の記事です。

>[Python のマイクロフレームワーク『Flask』を試してみた](https://tnakamura.hatenablog.com/entry/20101214/flask)


#### CSSの変更が反映されない
Google Chromeにキャッシュが残っており、そちらが表示されていただけでした。
それに気づかせてくださったのは以下の記事です。

>[ChromeでCSSが反映されない？キャッシュ消去で対処](https://saruwakakun.com/it/web/google-chrome)


#### 複数レコードの一括削除の実装方法がわからない
レコード一覧で、チェックボックスによる複数選択＆一括削除をするWebアプリはよく見るので、自分もそれを使いたいと思っておりました。
が、どうやれば良いのだろう・・・という状態を救ってくれたのは以下の記事でした。

>[python – Flaskのチェックボックスを繰り返し処理する](https://codeday.me/jp/qa/20190207/225016.html)でわかった。


#### レコード毎に違うページに遷移する方法がわからない
データベースのレコード更新にあたり、レコード選択したらそのトーク内容を表示する画面を作りたかったものの、そのやり方がわかりませんでした。
それを助けてくれたのは以下の記事です。
aタグでGETする際にパラメータ渡す方法をとりました。

[Python Flask URLからパラメータを取得する方法？](https://codeday.me/jp/qa/20181215/50073.html)


以上、参考にした記事でした。
記事を書いてくださった皆様、ありがとうございました！