4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-09-08

こんにちは、@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

なお、LINE WORKSでBotに話しかけると下図のような挙動をします。
image.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アプリの作成については、過去記事をご参照頂ければと思います。
なお、この際、過去記事同様にLINE WORKS API情報も設定しておいて頂ければと思います。

アプリ作成後はHeroku Postgresをadd onしますが、その方法は@mickie895さんの以下の記事が参考になりましたのでご紹介致します。

HerokuのPostgreSQLの使い方【環境構築辺】

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

2.models.pyの作成

SQLAlchemyにおけるテーブルは、Baseクラスを継承したクラスで定義します。

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です。各メソッドが何をしているかは、メソッド説明文に記述しましたので、コードをご覧ください。

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にデプロイするまでの流れ

5. テーブル作成

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

6. BotのコールバックURLを指定&テスト

LINE WORKS Developer Console > Bot画面で、BotのコールバックURLを指定します。指定するURLはhttps://{アプリ名}.herokuapp.com/callbackです。
image.png

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

さいごに

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

その他参考にした記事

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

SQLAlchemyの使い方

公式ドキュメントは当然英語。読みたくないなぁ・・・と思っていた自分を救ってくれたのは@ckernさんの記事でした。

【PythonのORM】SQLAlchemyで基本的なSQLクエリまとめ

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

Flaskのリダイレクトでエラーになる

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

Python のマイクロフレームワーク『Flask』を試してみた

CSSの変更が反映されない

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

ChromeでCSSが反映されない?キャッシュ消去で対処

複数レコードの一括削除の実装方法がわからない

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

python – Flaskのチェックボックスを繰り返し処理するでわかった。

レコード毎に違うページに遷移する方法がわからない

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

Python Flask URLからパラメータを取得する方法?

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

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?