PythonとHerokuで謎解きLINEbotを作る　後編

:::note warn
（2022/09/30追記）本記事で利用している**Herokuの無償プランは、2022年11月28日（月）に廃止されることとなりました**。ご注意ください。
https://forest.watch.impress.co.jp/docs/news/1435422.html
:::

# はじめに

この記事は↓の記事の続きです。ここからいよいよ謎解きbotらしい機能を実装していきます。

https://qiita.com/TomokiMitsuhama/items/84f2c861971d86b48466

## 注意（再掲）

:::note warn
この記事は**LINE謎解き「27 Letters」のネタバレをわずかに含みます**（大きなネタバレはありませんが……）。
:::

https://line.me/R/ti/p/@979mvoqz

一期一会なコンテンツなので、まずはプレイしてみてください。
LINEのアカウントがあれば無料でできます。
謎解き初心者には難しいかもしれませんが、「プログラミングでできること」の一例としてはうってつけなのではと思います。

:::note warn
僕はPythonもといプログラミング初心者であるため、**「コードの作法」的なことはいまいちわかっていないことはご了承ください**。
:::

# 少しずつ実装しよう

ここまではエコーボットの実装をしてきました。これをアレンジして謎解きbotらしくしていきます。
ですが、一気に完成形まで行くことは難しいです。一気に実装するとおそらくどこかでエラーが出ます。そしていじった箇所が多ければ多いほどエラーの原因特定は難しくなります。
**まずは機能を縮小したバージョンを作って、どうすればLINE botは想定通り動くのかを知ることが重要です**。
ここでは、そのために作った機能テスト用のアプリについて解説していきます。

# テストアプリの仕様
テストアプリはこんな感じの仕様を想定しました。

- 友だち登録されたらルール説明のメッセージを送る
- 「封筒を開ける」と送信することで、タイマースタート、問題の確認と解答が可能になる
- 「封筒を開ける」と送信するまでは、問題の確認や解答はできない（ゲーム開始前のメタ読み防止）
- 「謎を見る」と送ると問題画像が送られてくる（画像はいらすとや仮置き）
- Aの謎の答え「うがい」を送るとAの正解判定がなされる
- Bの謎の答え「てあらい」を送るとBの正解判定がなされる
- 封筒開封後は現在の進捗確認メッセージも追加で送られる
- AとBのどちらもに正解すると達成度が100%になる。100%になった瞬間は進捗確認メッセージも100%達成仕様に変わる
- 最終問題の答え「にんにくらんおう」を送ると「any%クリア」判定がなされる
- 100%達成状態で「にんにくらんおう」を送ると「100%クリア」判定がなされる。100%クリアの瞬間は進捗確認メッセージも100%達成仕様に変わる
- 「リザルト」と送るとクリアタイムが確認できる
- 「解答欄」と送ると現時点での解答がまとめて送られる
- その他のメッセージを送ると、誤答対応用メッセージが送られる

謎解きとしてはアレですが、機能テスト用なのでこれで十分でしょう。

# Heroku Postgresに連携

こうした機能をつけるためには、「プレイヤーごとのセーブデータ」を作る必要があります。
セーブデータをLINE上に保存することはできないので、データベースを作らなければなりません。
Herokuには**Heroku Postgres**というHeroku内で使えるデータベースがあります。
これを活用していきましょう。

まずは、PostgreSQLをいじるためのソフトをインストールしましょう。やり方は[ここなど](https://www.dbonline.jp/postgresql/install/ "PostgreSQLインストール")を参照してください。

次に、作成したHerokuアプリのダッシュボードで、Resources > Add-onsのFind More add-onsから、Heroku Postgresを追加します。すると、今のアプリに連携したデータベースが作られます。
Herokuアプリの環境変数（前編参照）にデータベースURLが追加されていることを確認してください。

Heroku CLI上でそのデータベースを開くためには、データベースのページにアクセスして、「Setting > View Credentials > Heroku CLI」の欄に書かれたコマンドをコピーして実行します。
すると、```（アプリの名前）::DATABASE=>```と表示されると思います。この状態でいろいろなコマンドを打ち込むことでデータベースを操作できます。SQLのコマンドですね。なお、この状態はCtrl+Cで解除されます。

例えばテストアプリを作る際は、まずデータベースに表を作るために次のコマンドを実行しました。
```
CREATE TABLE playlog (id SERIAL NOT NULL, lineid TEXT NOT NULL, envelope timestamp, q_a timestamp, q_b timestamp, q_final timestamp, clear_any timestamp, clear_all timestamp);
```
これで、次のような表を作ることができます。
| データラベル | id | lineid |envelope | q_a | q_b | q_final | clear_any | clear_all |
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| **データ型** | 連番 | テキスト | timestamp | timestamp | timestamp | timestamp | timestamp | timestamp |

それぞれ次の情報を記録することを意図しています。

- **id**：自動で連番の数字が付与されます。
- **lineid**：LINEアカウントのID。これは@で始まる文字列とは別の、アカウントそれぞれが持っているIDです（詳細はよくわかりません）。ブロックしてもこの文字列は変わりません。なので、 **リセットコマンドを仕込まない限りは、プレイヤーがセーブデータをリセットすることはできません**。アカウント登録と同時にこれとidが記録されます。
- **envelope**：「封筒を開ける」コマンドを送信した時刻が記録されます。
- **q_a**：Aの謎に正解した時刻が記録されます。
- **q_b**：Bの謎に正解した時刻が記録されます。
- **q_final**：最終問題に正解した時刻が記録されます。
- **clear_any**：作ったものの使いませんでした。
- **clear_all**：100%クリアした時刻が記録されます。

Postgresの他のコマンドはここに載っています。ここに載っているのを知っていれば十分だと思います。

https://amateur-engineer-blog.com/heroku-postgres-basic-data-operation/

# 画像を格納する

謎解きにはほとんどの場合自分で用意した画像が必須です。
画像を送信するためにはまずはどこかのサーバにアップロードする必要があります。
twitterやブログに貼り付けるという形の対処もありますが、やはりプログラムと同梱するのが一番良いでしょう。

その方法は結構簡単で、作業フォルダ内に画像用フォルダを作るだけです。```git commit```すると自動で画像もHerokuにアップロードされます。そのURLは、仮に画像フォルダの名前を「static」、画像の名前を「sample.png」とすると、
（自分のHerokuアプリのURL）/static/sample.png
になります。下のプログラムではサンプルとしていらすとやのURLになっていますが、そこを適宜こんな感じのURLに変えるといい感じになります。

# 完成したもの

以上の準備を経て、いよいよbotを謎解きらしくしていきます。
いろいろな試行錯誤を経て、完成したものが次になります。

## main.py

```main.py
from pickle import FALSE, TRUE
from flask import Flask, request, abort
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage, FollowEvent, ImageMessage, ImageSendMessage
    )
import os
import psycopg2
import datetime

app = Flask(__name__)

linebot_api = LineBotApi(os.environ["YOUR_CHANNEL_ACCESS_TOKEN"]) # チャンネルアクセストークン取得（環境変数への入力が必要）
handler = WebhookHandler(os.environ["YOUR_CHANNEL_SECRET"]) # チャンネルシークレット取得（環境変数への入力が必要）

DATABASE_URL = os.environ.get('DATABASE_URL') # データベースURL取得
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()

def q_a(hoge): # Aの謎の解答表示用
    if hoge == None:
        return ""
    else:
        return "うがい"
def q_b(hoge): # Bの謎の解答表示用
    if hoge == None:
        return ""
    else:
        return "てあらい"
def subtract(a, b): # 時間計測用
    if a == None:
        return "未達成"
    else:
        td = a - b
        return str(td.days) + "日" + str(td.seconds//3600) + "時間" + str(td.seconds%3600//60) + "分" + str(td.seconds%60) + "秒"


@app.route("/callback", methods=['POST']) # 署名の確認
def callback():
    signature = request.headers["X-Line-Signature"]
    body = request.get_data(as_text=True)

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

    return 'OK'

@handler.add(FollowEvent) #友達登録されたときの挙動
def handle_follow(event):
    many_time = FALSE
    userid = event.source.user_id #ユーザーID取得
    cur.execute("BEGIN") # SQLトランザクション開始
    cur.execute("SELECT lineid FROM playlog") # データベース読み込み
    id_list = cur.fetchall()
    for i in range(len(id_list)): # ID検索
        if id_list[i][0] == userid:
            many_time = TRUE
            break
    if many_time == FALSE: # はじめての友達登録だったとき
        sql_order = "INSERT INTO playlog(lineid) VALUES ('{}')".format(userid)
        cur.execute(sql_order)
        linebot_api.reply_message(event.reply_token, TextSendMessage(text="【封筒を開ける】"))
    else: # 2回目以降の友達登録だったとき
        cur.execute("SELECT * FROM playlog WHERE lineid='{}'".format(userid))
        players_log = cur.fetchone()
        if players_log[2] == None:
            linebot_api.reply_message(event.reply_token, TextSendMessage(text="【封筒を開ける】"))
        else:
            linebot_api.reply_message(event.reply_token, TextSendMessage(text="【謎を見る】"))
    cur.execute("COMMIT") # SQLトランザクション終了、保存

@handler.add(MessageEvent, message=TextMessage) # メッセージが来たときの挙動
def handle_message(event):
    userid = event.source.user_id # ユーザーID取得
    cur.execute("BEGIN") # SQLトランザクション開始
    cur.execute("SELECT * FROM playlog WHERE lineid='{}'".format(userid)) # ユーザーのログ取得
    players_log = cur.fetchone()
    correct = 2 - players_log[3:5].count(None) # ここまでの正解数を計算。players_log[3]が空白なら、Aに正解していない。players_log[4]が空白なら、Bに正解していない。ここは適宜変更が必要。
    correct_now = 0 # このメッセージが正解か否か（True/Falseで良かった）
    clear_check = False # このメッセージがクリアキーワードか否か
    if players_log[2] == None: # 封筒を開く前
        if event.message.text == "封筒を開ける": # 解答受付開始
            sql_order = "UPDATE playlog SET envelope = current_timestamp WHERE lineid = '{identity}'".format(identity = userid) # データベース上のenvelopeの値を現在時刻に設定
            cur.execute(sql_order)
            reply_1 = [TextSendMessage(text="封筒が開きました！ゲームスタートです！\n【謎を見る】")] # 送信メッセージ設定
            reply_synth = reply_1
        else: # 封筒を開いていないときは一切の解答を受け付けない
            reply_synth = [TextSendMessage(text="まずは【封筒を開ける】で封筒を開けてください。")] # 送信メッセージ設定
        linebot_api.reply_message(event.reply_token, reply_synth) # 送信
    else: # 封筒を開いた後
        if event.message.text == "謎を見る": # 謎の送信コマンド
            reply_1 = [ImageSendMessage(original_content_url='https://4.bp.blogspot.com/-vvIiYibsDho/VNH7aZmYIpI/AAAAAAAAreQ/LRktMGV4lW4/s180-c/ugai_tearai.png', preview_image_url='https://4.bp.blogspot.com/-vvIiYibsDho/VNH7aZmYIpI/AAAAAAAAreQ/LRktMGV4lW4/s800/ugai_tearai.png')] # 送信メッセージ1の設定
        elif event.message.text == "うがい": # Aの正解
            if players_log[3] == None: # まだ正解していない場合（players_log[3]はAの正解時刻）
                sql_order = "UPDATE playlog SET q_a = current_timestamp WHERE lineid = '{identity}'".format(identity = userid) # データベースに正解時刻を書き込む
                cur.execute(sql_order)
                correct += 1 # 正解数に1を足す
                correct_now = 1 # 今のメッセージの正解判定
                reply_1 = [TextSendMessage(text="Aの謎正解です！")] # 送信メッセージ1の設定
            else: # もう正解している場合
                reply_1 = [TextSendMessage(text="Aの謎はすでに正解済みです。")] # 送信メッセージ1の設定
        elif event.message.text == "てあらい": # Bの正解
            if players_log[4] == None: # まだ正解していない場合（players_log[4]はBの正解時刻）
                sql_order = "UPDATE playlog SET q_b = current_timestamp WHERE lineid = '{identity}'".format(identity = userid) # データベースに正解時刻を書き込む
                cur.execute(sql_order)
                correct += 1 # 正解数に1を足す
                correct_now = 1 # 今のメッセージの正解判定
                reply_1 = [TextSendMessage(text="Bの謎正解です！")] # 送信メッセージ1の設定
            else: # もう正解している場合
                reply_1 = [TextSendMessage(text="Bの謎はすでに正解済みです。")] # 送信メッセージ1の設定
        elif event.message.text == "にんにくらんおう": # 最終問題の正解
            if players_log[5] == None: # まだ正解していない場合（players_log[5]は最終問題の正解時刻）
                sql_order = "UPDATE playlog SET q_final = current_timestamp WHERE lineid = '{identity}'".format(identity = userid)
                cur.execute(sql_order)
            clear_check = True # 今のメッセージは最終問題の正解と判定
            correct_now = 1 # 今のメッセージの正解判定
            reply_1 = [TextSendMessage(text="クリアです！\nおめでとうございます！\n【リザルト】と入力してください。")] # 送信メッセージ1の設定
        elif event.message.text == "解答欄": # 解答確認
            reply_1 = [TextSendMessage(text="A " + q_a(players_log[3]) + "\nB " + q_b(players_log[4]))] # 送信メッセージ1の設定（正解していればAとBの正解が表示される）
        elif event.message.text == "リザルト": # リザルト確認
            reply_1 = [TextSendMessage(text="any%クリア：" + subtract(players_log[5], players_log[2]) + "\n100%クリア： " + subtract(players_log[7], players_log[2]))] # 送信メッセージ1の設定（クリアタイム）
        else: # いずれでもない場合
            reply_1 = [TextSendMessage(text="違います")] # 送信メッセージ1の設定
        
        if correct == 2: # A~B全部正解している場合
            if correct_now == 1: # 今のメッセージが正解だった場合
                if clear_check: # 今のメッセージが最終問題の正解だった場合
                    if players_log[8] == None: # 最終問題にまだ正解していなかった場合
                        sql_order = "UPDATE playlog SET clear_all = current_timestamp WHERE lineid = '{identity}'".format(identity = userid) # データベースにオールクリア時刻を書き込む
                        cur.execute(sql_order)
                    reply_2 = [TextSendMessage(text="これで達成度100%クリアです！\nおめでとうございます！\n【リザルト】と入力してください。")] # 送信メッセージ2の設定
                else: # 今のメッセージが最終問題の正解ではなかった場合
                    reply_2 = [TextSendMessage(text="達成度100%到達！\nおめでとうございます！")] # 送信メッセージ2の設定
            else: # 今のメッセージが正解ではなかった場合
                reply_2 = [TextSendMessage(text="現在の達成度："+ str(correct) + "/2")] # 送信メッセージ2の設定
        else: # まだ全部正解していない場合
            reply_2 = [TextSendMessage(text="現在の達成度："+ str(correct) + "/2")] # 送信メッセージ2の設定
        
        reply_synth = reply_1 + reply_2 # 送信メッセージ1, 2の統合
        linebot_api.reply_message(event.reply_token, reply_synth) # 送信
    cur.execute("COMMIT") # SQLトランザクション終了、保存
    
if __name__ == '__main__':
    app.run()
```

## requirements:txt

```psycopg2```を追記します。

## 詰まったところとその解決策
### SQLはよくわからないので、極力Pythonオブジェクトを操作する
Postgresの操作コマンドが本当によくわからなかったので、Postgresを操作するコマンドは最小限にしています。そのためちょっと非効率に見える部分もあるかもしれません。SQLに詳しい人はもっと簡略化できると思います。
例えば次のような感じです。
```main.py
    else: # 2回目以降の友達登録だったとき
        cur.execute("SELECT * FROM playlog WHERE lineid='{}'".format(userid))
        players_log = cur.fetchone()
        if players_log[2] == None:
            linebot_api.reply_message(event.reply_token, TextSendMessage(text="【封筒を開ける】"))
        else:
            linebot_api.reply_message(event.reply_token, TextSendMessage(text="【謎を見る】"))
    cur.execute("COMMIT") # SQLトランザクション終了、保存
```
2～3行目は該当プレイヤーのセーブデータを抜き出す操作ですが、3行目でこれをPythonのリストオブジェクトにします。そして4行目でこのリストオブジェクトを調べています。
ここでは2番目（Pythonは0番目から始まります）がenvelopeだったと覚えているので2を直接入れましたが、本番プログラムではさすがに覚えきれない量の要素数だったので変数化しました。

### event.reply_tokenを叩けるのは1メッセージにつき1回まで
```linebot.api.reply_message```はメッセージを送信するコマンドですが、**1対応内で2回使うとエラーが発生します**。これは、```event.reply_token```を使えるのが1回までだからです。

ではどのようにすれば複数通の返信が送れるのかというと、```linebot.api.reply_message```の **第2変数をリスト化**すれば解決します。
上のプログラムでは、例えば```reply_1 = [TextSendMessage(text="Aの謎正解です！")]```と```reply_2 = [TextSendMessage(text="達成度100%到達！\nおめでとうございます！")]```の2つを定義し、```reply_synth = reply_1 + reply_2```によってこの2つのリストオブジェクトを結合します。そして、```linebot_api.reply_message(event.reply_token, reply_synth)```で、この結合したリストオブジェクトを送信する、という流れで、複数通の返信を送ることができます。

なお、**一度に送れる返信は5件まで**という制限もあるので、上で紹介したリストの長さを最大5にしている必要があります。

### COMMITしないと保存されない

このプログラムではPostgresの操作を行いますが、この操作はCOMMITしないと保存されません。
このプログラムでは、```cur.execute("BEGIN")```と```cur.execute("COMMIT")```で処理全体の操作を挟むことで、処理全体を「SQLトランザクション」化しています。

### 友だち登録時のメッセージを追加する方法
```@handler.add(FollowEvent)```で、友だち登録されたときのメッセージが送れます。
これの注意は、そのためには```linebot.models```から```FollowEvent```をimportする必要があるということです。

### 画像送信の方法
画像送信をするためには```TextSendMessage```の代わりに```ImageSendMessage```を使えばよいのですが、これにも注意すべきことがあります。

```main.py
reply_1 = [ImageSendMessage(original_content_url='https://4.bp.blogspot.com/-vvIiYibsDho/VNH7aZmYIpI/AAAAAAAAreQ/LRktMGV4lW4/s180-c/ugai_tearai.png', preview_image_url='https://4.bp.blogspot.com/-vvIiYibsDho/VNH7aZmYIpI/AAAAAAAAreQ/LRktMGV4lW4/s800/ugai_tearai.png')]
```

このように、```original_content_url```と```preview_image_url```の2つが必要になります。この2つは同じURLで問題なかったです。

Web上の画像を使う場合、**ここに設定するURLはhttpsでなければならない**ので、httpだった場合はsを足してください。
最後に、上同様に```linebot.models```から```ImageMessage, ImageSendMessage```をimportする必要もあることに注意してください。

### クリア画像を達成度ごとに変える

上のテストアプリでは実装していませんが、今回「27 Letters」では、クリア時点での達成度（小謎の正解数）に応じてクリア記念の画像が異なるという仕様を付けました。

そして、この実現のためには画像フォルダを整理する必要があります。まずは達成度に応じたクリア画像を用意します（「27 Letters」の場合36枚）。そして、先ほど作った画像フォルダ（static）の中にクリア画像フォルダを作ります（名前をclearとします）。そして、その中にクリア画像を入れます。ここで、例えば達成度が0の画像にはClear0.pngのように、**「Clear + (達成度の数字)+ .png」となるようにすべてのファイルをリネームする**のがポイントです。
これをアップロードすることで、例えば達成度0の場合のクリア画像は「（HerokuアプリのURL）/static/clear/Clear0.png」というURLに格納されます。

そしてmain.py側でコードを書くのですが、この仕様は```format```メソッドを使うことで1行で実現することができます。一部略しますが、おおよそこのようなコードです。

```main.py
reply_1 = [ImageSendMessage(original_content_url=FQDN + '/static/clear/Clear{}.png'.format(correct), preview_image_url=FQDN + '/static/clear/Clear{}.png'.format(correct))]
```
上のコードを説明するために、いくつかの独自変数を説明する必要がありますね。
```FQDN```は今回のHerokuアプリのURLで、```correct```はその時点での達成度です。
```format```メソッドでは、文字列内の```{}```の中に引数の値を入れることができます。なので、仮に```correct```の値が0とすると、```'/static/clear/Clear{}.png'.format(correct)```は```'/static/clear/Clear0.png'```ということになります。これと```FQDN```を結合することで、達成度0の場合の画像のURLが```original_content_url```の値になります。

このformatメソッドを利用すればクリアツイートを達成度ごとにカスタマイズすることもできます。

https://tools.ikunaga.net/tweet-link/

上のサイトでツイートリンクを生成したら、そのカスタマイズしたい部分を```{}```にして```format```メソッドを使えばできます（ここは文字コード等の説明が面倒なので割愛）。


# Heroku無料版の限界

:::note warn
（2022/09/30追記）本記事で利用している**Herokuの無償プランは、2022年11月28日（月）に廃止されることとなりました**。なのでこの部分はじきにout-datedとなります。ご注意ください。
https://forest.watch.impress.co.jp/docs/news/1435422.html
:::

こんな感じでHerokuを用いれば高機能なbotが作れるわけですが、Herokuの無料版には制限があります。

- 利用時間の制限（1月当たり550時間、クレジットカードを登録すれば1000時間）
- Heroku Postgresの行数の制限（10000行）と予告なしのメンテナンス（その間はおそらくSQLへの書き込みはできないので、botの動きは制限されると思います……。なので、最初の注意に「応答しない場合はしばらく待ってから送信してください」という注意書きを付しています。メンテナンスの時間は現状2分くらいです。）

1000時間あれば一つのbotはおそらく問題なく稼働できますが、2つとなると厳しいかと思います。無料でやりたいのであれば、こうした実装はここぞというときに取っておくのがいいかと思います（1年くらい経過したらbotの流行も治まって利用時間も無視できるくらいになるのかもしれませんが、ここは1年くらい経ってみないと分からないです）。
HerokuはPaaSと呼ばれるサービスの一つで、PaaSは他の有名どころだとAWSなどがありますね。使える人はこの辺を使ってみるのも良いと思います（この辺は詳しくないので詳しい人に任せます）。

# 大事なことを忘れていた！　～エラー対応～
ということを書いたのがリリース前だったんですが、リリース後に気付いた問題があったのでその話をします（本当は上のソースコードに組み込みたかった話ですが、いろいろ面倒なのでこちらでします。すいません……）。

上で述べた通り、Heroku Postgresにはメンテナンスの時間がやってきます。その間はデータベースへの書き込みはできません。ということは、その間に友だち登録した人は、IDがデータベースに書き込まれないことになります。上のプログラムは「データベースにLINE IDが登録されていること」を前提に成り立っているので、何を送信しても内部エラーが起き、何も返信が返ってこないという事態になります。超やばい。

この問題の対処について。まず、LINE VOOMでエラーへの対応方法に関する投稿をしました。友だち登録をすればLINE VOOMは確認ができます。そこを見てもらいさえすれば、上のような現象が起きた人もうまく対応してくれるかもしれません。

しかし、やはり本質的にはプログラムを修正する必要がありました。要は**エラーが起きたときの例外処理が欠けていた**のです。
最終的にはプログラムはこんな感じになりました。
```main.py
def handle_message(event):
    try:
        # ここに今までのhandle_messageのメイン部分を入れる
    except:
        linebot_api.reply_message(event.reply_token, "エラーが発生しました。\n右上の投稿を参考に対応をお願いします。")
```

これを入れるとログにエラーの内容が出てこないので、開発中は付けないほうがたぶんやりやすいです。ですが、不測の事態が起きることも考えて、本番のアプリではこんな感じのを付けるべきだと思います。エラーに詳しい人とかはエラーの内容で分けた対応とかもできるのかも。

# おわりに

以上でおそらくLINE謎解きbotに必要な基礎部分は解説できたと思います。
これを機にすごいLINE謎が出てくれたらうれしいですね。