[Python][flask]LINEとGoogleスプレッドシートを連携させて、TodoBOTをつくった

今回は、LINEとGoogleスプレッドシートを連携させてTodoBOTを作りました。

完成したBOTはこんな感じです！
<img width="375" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/288721/e3a5e7f6-5db8-69ea-7c2d-2d71554ad00c.png">

BOTを友だち追加したときに使い方を教えてくれますが、一応ここにも書いておきます。

追加[追加したい項目]
と送信するとTodoリストに追加することができます。

完了[完了した項目]
と送信するとTodoリストから指定した項目を削除することができます。

やること
と送信すると、現在のTodoリストが送られてきます。

クリア
と送信すると、Todoリストをリセットすることができます。

googleスプレッドシートとはこんな感じに連携しています
![image.png](https://qiita-image-store.s3.amazonaws.com/0/288721/cf226315-0ad3-48d8-9689-526cb0373892.png)


#1.GoogleSpreadSheetAPIの利用設定
まず、botと紐付けるスプレッドシートの認証などが必要です。
以下の記事が超絶わかりやすかったので参考にさせていただきました。
[PythonでGoogleスプレッドシートを編集](https://qiita.com/akabei/items/0eac37cb852ad476c6b9)

#2.スプレッドシートを操作するクラスを定義する
スプレッドシートを操作する`RemoteControlGoogleSpreadSheet`クラスを自作しました。
####ワークシートの作成
この後に書いている`edit_gspread.py`を見ていただくとわかるかと思いますが、ユーザが友だち登録した際に`RemoteControlGoogleSpreadSheet`クラスのインスタンスを生成しています。

```python:re_con_gspread.py
class RemoteControlGoogleSpreadSheet:
    def __init__(self, title):
        scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive']
        credentials = ServiceAccountCredentials.from_json_keyfile_name('gspread-sample-06cdd678a479.json', scope)
        global gc
        gc = gspread.authorize(credentials)
        gc = gc.open("mySheet")#操作するスプレッドシートを指定する

        try :
            #新たにワークシートを作成し、Worksheetオブジェクトをworksheetに格納します。
            worksheet = gc.add_worksheet(title=title, rows="100", cols="2")
        except :
            #すでにワークシートが存在しているときは、そのワークシートのWorksheetオブジェクトを格納します。
            worksheet = gc.worksheet(title)

        self.worksheet = worksheet #worksheetをメンバに格納
        self.Todo = 1 #書き込む行を指定しているメンバ
        self.Done = 2 #書き込む行を指定しているメンバ
        self.worksheet.update_cell(1, self.Todo, "Todo")
        self.worksheet.update_cell(1, self.Done, "Done")
```
`Worksheetオブジェクト.update_cell(行番号, 列番号, "テキスト")`で指定したセルにテキストを書き込めます。

友だち追加されるとインスタンスが生成され、LINEの表示名がタイトルのワークシートが作成されます。
こんな感じです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/288721/22407ec7-3deb-0a95-9f7a-2d6250b776be.png)

####書き込む行の制御
Todo列とDone列がどちらも空欄である最初の行番号を返します。

```python:RemoteControlGoogleSpreadSheet
    #Todoリストの最後の行を返す
    def detect_last_row(self):
        row_count = 1
        while self.worksheet.cell(row_count, self.Todo).value != "" or self.worksheet.cell(row_count, self.Done).value != "":
            row_count += 1
        return row_count
```
`Worksheetオブジェクト.cell(列番号, 行番号)`で指定したセルのCellオブジェクトができます。
`Cellオブジェクト.value`で、そのセルの値を取り出すことができます。

####テキストをTodo列に書き込む
受け取ったテキストをTodo列に順番に書いていく関数です。
列の指定は先ほどの`detect_last_row()`で行っています。

```python:RemoteControlGoogleSpreadSheet

    #受け取ったテキストをTodo列に順番に書いていく
    def write_to_Todo(self, text):
        self.worksheet.update_cell(self.detect_last_row(), self.Todo, text[2:])

```
`text[2:]`としているのはユーザーから、追加[追加したい項目]、のようなメッセージを期待しているためです。`text[2:]`とすることでTodoリストに追加したい部分のみを取り出しています。

####Todo列からDone列に移動させる
受け取ったテキストがTodo列に存在すれば、Todo列から削除し、Done列に書き込みます。

```python:RemoteControlGoogleSpreadSheet

    def from_Todo_to_Done(self, text):
        #項目がTodoに存在するか否か
        is_there_cell = True
        try:
            #目的のセルの行番号を格納
            num = self.worksheet.find(text[2:]).row

            self.worksheet.update_cell(num, self.Todo, "")
            self.worksheet.update_cell(num, self.Done, text[2:])

        except CellNotFound:#該当するcellが見つからなかったとき
            is_there_cell = False

        return is_there_cell
```
`Worksheetオブジェクト.fing("テキスト")`で、指定した値を持つセルのCellオブジェクトができます。
`Cellオブジェクト.row`で、そのセルの行番号を取り出せます。

####botから送信するTodoリストを整える
Todo列の値をすべて取得し、ユーザーが見やすいようにフォーマットを整えます。

```python:RemoteControlGoogleSpreadSheet

    #Todoのcolの中身をすべて取得して返す
    def get_Todo(self):
        #Todo列の値をリストにしてすべて取得
        TodoList = self.worksheet.col_values(self.Todo)

        #TodoからDoneに移動させた分の空白を除く
        TodoList = [todo for todo in TodoList if todo != ""]

        #Todoの項目を改行と・で連結
        #TodoList[1]はTodoであるため除く
        TodoList = "\n・".join(TodoList[1:])

        return TodoList
```
`Worksheetオブジェクト.col_values(列番号)`で、指定した列のすべての値をリストで取得します。

####ワークシートのリセット
ワークシートの値をすべて削除し、最初の状態に戻します。

```python:RemoteControlGoogleSpreadSheet
    #ワークシートをリセットする
    def clear_worksheet(self):
        self.worksheet.clear()
        self.worksheet.update_cell(1, self.Todo, "Todo")
        self.worksheet.update_cell(1, self.Done, "Done")

```
`Worksheetオブジェクト.clear()`で、ワークシートのすべての値を削除します

####ワークシートの削除
スプレッドシートから、ワークシートを削除します。

```python:RemoteControlGoogleSpreadSheet

    #ワークシートを削除する
    def delete_worksheet(self):
        gc.del_worksheet(self.worksheet)
```
`Spreadsheetオブジェクト.del_worksheet(Worksheetオブジェクト)`で、指定したワークシートを削除できます。


#3.LINEbotの処理をflaskで書く
続いて、先ほど定義した`RemoteControlGoogleSpreadSheet`クラスを用いて、TodoBOTの中身を書いていきます。
長いので見やすいように3つに分けました。

####必要モジュールのインポートなど
まず、必要モジュールのインポートやAPIキーの設定、HTTPリクエストについての部分です。
`callback()`の中身は全くわかりません。。。

```python:edit_gspread.py
from flask import Flask, request, abort

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, ImageMessage, TextSendMessage, FollowEvent, SourceGroup, SourceRoom
)
from re_con_gspread import RemoteControlGoogleSpreadSheet


worksheets = {}#辞書を初期化する
separator = "==================="


app = Flask(__name__)

line_bot_api = LineBotApi('') #アクセストークンを入れてください
handler = WebhookHandler('') #Channel Secretを入れてください

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

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

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

    return 'OK'
```
`worksheets = {}`には後でユーザー固有のWorksheetオブジェクトを格納するので、初期化しておきます。

####テキストメッセージに対する応答

続いて、ユーザーからテキストメッセージが送られてきたときの処理を書いていきます。
送信されたテキストをトリガーにして、`RemoteControlGoogleSpreadSheet`クラスのメソッドを使い、スプレッドシートを操作しています。

```python:edit_gspread.py
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    text = event.message.text
    profile = line_bot_api.get_profile(event.source.user_id)

    try:
        #ユーザーからテキストメッセージが送信されるたび、そのユーザーidに対応するWorksheetオブジェクトをworksheetに格納する
        worksheet = worksheets[profile.user_id]

    except KeyError:
        #辞書にインスタンスが登録さてていないと言われたらもう一度登録する,herokuとのラグでたまにおこるぽい
        worksheets[profile.user_id] = RemoteControlGoogleSpreadSheet(profile.display_name)
        worksheet = worksheets[profile.user_id]


    #ユーザーから送信されたテキストメッセージの最初の２文字が"追加"だったら、その後の文字列をTodo列に書き込む
    if text[:2] == "追加" :
        worksheet.write_to_Todo(text)

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = text[2:] + "\nをTodoリストに追加しました"))


    #ユーザーから送信されたテキストメッセージの最初の２文字が"完了"であり、それ以降の文字列がTodo列に存在したら、Done列に移動させる。
    #存在しなかった場合は、その旨をユーザーに伝える。
    if text[:2] == "完了" :
        is_there_cell = worksheet.from_Todo_to_Done(text)

        if is_there_cell == True:
            line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text = text[2:] + "\nをTodoリストから削除しました"))
        else :
            line_bot_api.reply_message(
                event.reply_token,
                TextSendMessage(text = text[2:] + "\nはTodoリストに存在しません"))


    #ユーザーから『やること』と送信されたとき、Todo列の値をユーザーに送信する
    if text == "やること" :
        respons = worksheet.get_Todo()

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = "Todo\n・" + respons))


    #ユーザーから『クリア』と送られてきたとき、ワークシートをリセットする
    if text == "クリア" :
        worksheet.clear_worksheet()
        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = "Todoリストをクリアしました"))


    #herokuとのラグでエラーが出ることがあるので、この機能は使わない方がよさげ
    if text == "削除" :
        worksheet.delete_worksheet()
        del worksheets[profile.user_id]
        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = "Todoリストを削除しました"))
```

####友だち追加されたときの処理
新たに友だち登録したユーザーのuser_idをキーとして、`RemoteControlGoogleSpreadSheet`クラスのインスタンスを辞書に格納します。

ユーザーごとのインスタンスを辞書に格納しておくことで、メッセージが送信される毎にいちいちインスタンスを生成する時間が省けます。

```python:edit_gspread.py
    #フォローイベント時の処理
@handler.add(FollowEvent)
def handle_follow(event):
    profile = line_bot_api.get_profile(event.source.user_id)

    #友だち登録時に,新しいWorksheetのインスタンスを生成し、辞書に格納
    worksheets[profile.user_id] = RemoteControlGoogleSpreadSheet(profile.display_name)#引数を名前に持つワークシートを作る


    #友達追加したユーザにメッセージを送信
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text="友達追加ありがとうございます\n\n" + separator + "\nTodoリストに追加したいときは\n\n追加{追加したい項目}\
        \n\nのように入力してください\nex) 追加買い物に行く\n" + separator + "\nTodoリストを確認したいときは\n\nやること\n\nと入力してください\n"\
         + separator + "\nTodoリストから削除したいときは\n\n完了{削除したい項目}\n\nと入力してください\n"\
        "ex) 完了買い物に行く" + separator + "\nTodoリストをクリアしたいときは\n\nクリア\n\nと入力してください" + separator))


if __name__ == "__main__":
    app.debug = True
    app.run()
```

#4.まとめ
自分でTodoアプリを作ることができました！

ローカルできちんと動作しても、Herokuにデプロイして動かしてみるといろいろ問題が起きて大変でした。

あとで気づきましたが、わざわざスプレッドシートと連携しなくても、リストにテキストを保持しておけばTodoBOT作れましたね...

ただオブジェクト指向をより理解できたのでよかったのかなと！

3/22 一部修正

#参考にしたサイト
[PythonでGoogleスプレッドシートを編集](https://qiita.com/akabei/items/0eac37cb852ad476c6b9)

[リストの空の要素を駆逐する](https://qiita.com/github-nakasho/items/a08e21e80cbc9761db2f)