LINEBotとPythonで、異性への質問BOTをつくった【閲覧注意】

シンプルなLINEBotアプリを作れるようになったので、次のステップに進みたい!
そんな思いで作ったLINEBotについて紹介します。

素人のコードを貼ってる上に、これといった解説もしてないので、閲覧注意です。
批判は受け付けませんので自己責任で読んでください笑

使っている技術は、

・Python
・Flask
・LINEMessageingAPI
・Heroku
・GoogleSpreadsheetAPI

です。
以前勉強した、Python×スプレッドシートAPIでユーザーIDの保存や、Python初心者がたったの3日間でチャットボットを作った【1日目】のあたりの知識を使っています。

もくじ

  1. 今回作ったBotの概要
  2. メイン部分
  3. ユーザの情報を保存する
  4. 質問文を格納し、取り出す
  5. まとめ

今回作ったBotの概要

まず、今回作ったBotについて紹介します。
完成形はこちら。

コンセプトは、「知り合いには恥ずかしくて聞けないような、心と身体の悩みや疑問を見知らぬ異性に聞けるBot」です。

機能は、

・性別の登録
・質問の投稿
・異性の質問に回答
・自分の質問への回答の取得

の4つを実装しました。

作成期間は二日間。
かかった時間は大体5〜6時間くらいです。
LINEMessageingAPIはフリープランを利用し、DBとしてGoogleSpreadsheet、サーバーにHerokuを使用しました。
今はもう運用していないのですが、記録としてコードだけ残しておこうと思います。

メイン部分

先にコード貼っておきます。
ちょっと長いです。

from flask import Flask, request, abort, render_template,redirect
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
    SourceUser, SourceGroup, SourceRoom,
    TemplateSendMessage, ConfirmTemplate, MessageAction,
    ButtonsTemplate, ImageCarouselTemplate, ImageCarouselColumn, URIAction,
    PostbackAction, DatetimePickerAction,
    CameraAction, CameraRollAction, LocationAction,
    CarouselTemplate, CarouselColumn, PostbackEvent,
    StickerMessage, StickerSendMessage, LocationMessage, LocationSendMessage,
    ImageMessage, VideoMessage, AudioMessage, FileMessage,
    UnfollowEvent, FollowEvent, JoinEvent, LeaveEvent, BeaconEvent,
    FlexSendMessage, BubbleContainer, ImageComponent, BoxComponent,
    TextComponent, SpacerComponent, IconComponent, ButtonComponent,
    SeparatorComponent, QuickReply, QuickReplyButton
)
import os
import json

import users_DB
import questions

#アクセスキーの取得
app = Flask(__name__)

#BOTの認証。Heroku環境で設定済み。
YOUR_CHANNEL_ACCESS_TOKEN = os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]
YOUR_CHANNEL_SECRET = os.environ["YOUR_CHANNEL_SECRET"]

line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)

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

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

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

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    """
    UsersのActivityによって条件分岐。
    noneQuestionがデフォルト→メニューを表示。
    waitQestionが質問待ち状態→次に入力されたテキストが質問になる。
    """
    UserID = event.source.user_id
    text = event.message.text
    activity = users_DB.checkActivity(UserID)

    if activity == "noneQuestion":
        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text='メニュー',
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="異性に質問してみる", data="異性に質問してみる")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                    ])))

    elif activity == "waitQestion":
        sex = users_DB.getSex(UserID)
        questions.add(UserID,sex,text)
        users_DB.changeActivity(UserID,"waitAnswer")
        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text=text + "があなたの質問として投稿されました!",
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="自分への回答を確認する", data="回答を確認する")
                        ),
                        QuickReplyButton(
                        action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="質問を変更する", data="異性に質問してみる")
                        ),
                    ])))

    elif activity == "waitAnswer":
        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text='メニュー',
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="自分への回答を確認する", data="回答を確認する")
                        ),
                        QuickReplyButton(
                        action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="質問をする", data="異性に質問してみる")
                        ),
                    ])))

    else:
        QuestionKey = activity
        questions.putAnswer(UserID,text,QuestionKey)
        users_DB.changeActivity(UserID,"waitAnswer")

        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text=text + "をあなたの回答として投稿しました",
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="自分への回答を確認する", data="回答を確認する")
                        ),
                        QuickReplyButton(
                        action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="質問をする", data="異性に質問してみる")
                        ),
                    ])))

    return "ok"

@handler.add(FollowEvent)
def handle_follow(event):
    """
    友だち追加したときのイベント。
    UsersDBにIDと性別、アクティビティを追加。
    """
    UserID = event.source.user_id
    users_DB.add(UserID)
    buttons_template = ButtonsTemplate(
        title='友達追加ありがとう!', text='まず、あなたの性別を教えてください!', actions=[
            PostbackAction(label='男', data='male'),
            PostbackAction(label='女', data='female'),
        ])
    template_message = TemplateSendMessage(alt_text='友達追加ありがとう!\nまず、あなたの性別を教えてください。', template=buttons_template)
    line_bot_api.reply_message(event.reply_token, template_message)


@handler.add(UnfollowEvent)
def handle_unfollow(event):
    """
    ブロックされた時のイベント。
    UsersDBのIDと性別、アクティビティを削除。
    <TODO> QuestionDBの投稿を削除。
    """
    UserID = event.source.user_id
    users_DB.remove(UserID)

@handler.add(PostbackEvent)
def handle_postback(event):
    """
    ポストバックに対応したメソッド。
    性別の登録。
    質問の投稿。
    回答の投稿。
    """

    UserID = event.source.user_id

    if event.postback.data == 'male':
        sex = "male"
        users_DB.updateSex(UserID,sex)

        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text='男性として登録されました!さっそく質問をしてみましょう!',
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="異性に質問してみる", data="異性に質問してみる")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                    ])))

    elif event.postback.data == 'female':
        sex = "female"
        users_DB.updateSex(UserID,sex)

        line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(
                    text='女性として登録されました!さっそく質問をしてみましょう!',
                    quick_reply=QuickReply(
                        items=[
                        QuickReplyButton(
                            action=PostbackAction(label="異性に質問してみる", data="異性に質問してみる")
                        ),
                        QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                        ),
                    ])))

    elif event.postback.data == "異性に質問してみる":
        users_DB.changeActivity(UserID,"waitQestion")
        line_bot_api.reply_message(
            event.reply_token, TextSendMessage(text="質問を入力してね!"))

    elif event.postback.data == "誰かの質問に答える":
        q = questions.getOtherQuestion(UserID)
        if q == False:
            line_bot_api.reply_message(
                    event.reply_token,
                    TextSendMessage(
                        text='未回答の異性の質問はありませんでした',
                        quick_reply=QuickReply(
                            items=[
                            QuickReplyButton(
                                action=PostbackAction(label="自分への回答を確認する", data="回答を確認する")
                            ),
                            QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                            ),
                            QuickReplyButton(
                                action=PostbackAction(label="質問をする", data="異性に質問してみる")
                            ),
                        ])))
        else:
            users_DB.changeActivity(UserID,q[1])
            line_bot_api.reply_message(
                event.reply_token, TextSendMessage(text="質問:" + q[0] +"\n\nこの質問に対する回答を入力してください"))

    elif event.postback.data == "回答を確認する":
        key = users_DB.getQuestionKey(UserID)
        a = questions.getAnserForMe(key)
        answer = a[0]
        question = a[1]

        if answer == "":
            line_bot_api.reply_message(
                    event.reply_token,
                    TextSendMessage(
                        text='まだあなたの質問への回答はありません。\nもう少し時間がたってからまた試してみてね!',
                        quick_reply=QuickReply(
                            items=[
                            QuickReplyButton(
                                action=PostbackAction(label="自分への回答を確認する", data="回答を確認する")
                            ),
                            QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                            ),
                            QuickReplyButton(
                                action=PostbackAction(label="質問をする", data="異性に質問してみる")
                            ),
                        ])))
        else:
            line_bot_api.reply_message(
                    event.reply_token,
                    TextSendMessage(
                        text=question + 'に対する回答が届いています!\n\n「' + answer + "」",
                        quick_reply=QuickReply(
                            items=[
                            QuickReplyButton(
                                action=PostbackAction(label="次の質問をする", data="異性に質問してみる")
                            ),
                            QuickReplyButton(
                            action=PostbackAction(label="誰かの質問に答える", data="誰かの質問に答える")
                            ),
                        ])))


if __name__ == "__main__":
    app.run()

解説するほどのコードではないですが、簡単にまとめておきます。

  import users_DB
  import questions

これは独自に定義したソースファイルです。

users_DBがスプレッドシートにユーザーの情報を保存する機能を持っています。
questionsは、ユーザーの投稿した質問と、それに対する回答を扱っています。

また、メニュー代わりとして、LINEのクイックリプライ機能を使っています。
本当はリッチメニューを使いたかったのですが、フリープランでは残念ながら使えなかったので…。

ユーザーの操作はpostbackイベントで区別させています。

また、DBにユーザーの状態を表す項目を追加し、それを変化させることによって、質問の投稿や回答の投稿などの操作を区別しています。
随分雑な説明ではありますが、ご容赦を。

ユーザの情報を保存する

今回は、ユーザーの情報を保存するDBとして、スプレッドシートを用いています。
設定したフィールドは、
・id
・UserId(これでプッシュメッセージを送れる)
・性別
・アクティビティ(質問の入力待ちなどの状態を判別する項目)
の4つ。

コードはこちら。

  from oauth2client.service_account import ServiceAccountCredentials
  from httplib2 import Http
  import gspread

  scopes = ['https://www.googleapis.com/auth/spreadsheets']
  json_file = 'OAuth用クライアントIDの作成でダウンロードしたjsonファイル'
  credentials = ServiceAccountCredentials.from_json_keyfile_name(json_file, scopes=scopes)
  http_auth = credentials.authorize(Http())

  # スプレッドシート用クライアントの準備
  doc_id = 'ドキュメントのID
  client = gspread.authorize(credentials)
  gfile   = client.open_by_key(doc_id)
  worksheet  = gfile.worksheet('シート名')

  def add(userID):
      key = worksheet.cell(1,1).value
      key = int(key)
      worksheet.update_cell(key+1, 1, str(key))
      worksheet.update_cell(key+1, 2, userID)
      worksheet.update_cell(key+1, 4, "noneQuestion")
      worksheet.update_cell(1, 1, str(key+1))

      return "ok"

  def remove(UserID):
      cell = worksheet.find(UserID)
      row = cell.row
      worksheet.delete_row(row)
      return "ok"

  def updateSex(UserID,sex):
      cell = worksheet.find(UserID)
      worksheet.update_cell(cell.row, cell.col+1, sex)

  def changeActivity(UserID,activity):
      cell = worksheet.find(UserID)
      worksheet.update_cell(cell.row, cell.col+2, activity)

  def checkActivity(UserID):
      cell = worksheet.find(UserID)
      activity = worksheet.cell(cell.row, cell.col+2).value
      return activity

  def getSex(UserID):
      cell = worksheet.find(UserID)
      sex = worksheet.cell(cell.row, cell.col+1).value
      return sex

  def getQuestionKey(UserID):
      cell = worksheet.find(UserID)
      answerKey = worksheet.cell(cell.row, cell.col+3).value
      return answerKey

  def changeQuestionID(UserID,Qkey):
      cell = worksheet.find(UserID)
      worksheet.update_cell(cell.row, cell.col+3, Qkey)
      return "ok"

今回、UserIDで特定したデータの行の中から特定のフィールドを抜き出す時、列番号を足し引きして指定するという、保守性のかけらもない方法をとってしまいました。
割と後悔するのが心底恥ずかしいコードです。

本当は列の頭にあるフィールド名で指定したかったんだけど、実装方法がわかりませんでした。
やっぱりちゃんとDB使えるようになりたいな。

質問文を格納し、取り出す

もはや語るべきものはなく。
コードだけ貼っておきます。

  from oauth2client.service_account import ServiceAccountCredentials
  from httplib2 import Http
  import gspread
  import users_DB

  scopes = ['https://www.googleapis.com/auth/spreadsheets']
  json_file = 'OAuth用クライアントIDの作成でダウンロードしたjsonファイル'
  credentials = ServiceAccountCredentials.from_json_keyfile_name(json_file, scopes=scopes)
  http_auth = credentials.authorize(Http())

  # スプレッドシート用クライアントの準備
  doc_id = 'ドキュメントのID'
  client = gspread.authorize(credentials)
  gfile   = client.open_by_key(doc_id) #読み書きするgoogle spreadsheet
  worksheet  = gfile.worksheet('シート名')

  def add(UserID,Sex,Question):
      key = worksheet.cell(1,1).value
      key = int(key)
      worksheet.update_cell(key+1, 1, str(key))

      worksheet.update_cell(key+1, 2, Sex)
      worksheet.update_cell(key+1, 3, UserID)
      worksheet.update_cell(key+1, 4, Question)
      worksheet.update_cell(1, 1, str(key+1))

      users_DB.changeQuestionID(UserID,key)

      return "ok"

  def remove(QuestionID):
      cell = worksheet.find(QuestionID)
      row = cell.row
      worksheet.deleate_row(row)
      return "ok"

  def getOtherQuestion(UserID):
      sex = users_DB.getSex(UserID)
      maxQuestionKey = int(worksheet.cell(1,1).value)

      if sex == "male":
          keysex = "female"
      else:
          keysex = "male"

      try:
          cells = worksheet.findall(keysex)
          for cell in cells:
              if worksheet.cell(cell.row, cell.col + 4).value == "":
                  q = []
                  Question = worksheet.cell(cell.row,cell.col + 2).value
                  key = worksheet.cell(cell.row,cell.col - 1).value
                  q.append(Question)
                  q.append(key)
                  return q

          return False。

      except:
          return False

  def putAnswer(UserID,Answer,QuestionID):
      cell = worksheet.find(QuestionID)
      worksheet.update_cell(cell.row, cell.col + 4, Answer)
      worksheet.update_cell(cell.row, cell.col + 5, UserID)

  def getAnserForMe(key):
      cell = worksheet.find(key)
      a = []
      question = worksheet.cell(cell.row, cell.col + 3).value
      answer = worksheet.cell(cell.row, cell.col + 4).value

      a.append(answer)
      a.append(question)

      return a

まとめ

今回は単純に作ったものを記録しておくための記事なので、説明を省きまくって雑に書いてしまいました。
お恥ずかしい限りです。