天鳳個室の自動成績管理アプリをLINE botとPythonで作る　その②

# はじめに
* 前回の記事 [「天鳳個室の自動成績管理アプリをLINE botとPythonで作る　その①」](https://qiita.com/all_green/items/4c5f4bc1a2044b00d2f8)の続きです
* 今回はLINEからのユーザーリクエストに応じた処理（図の緑の矢印の部分）について書こうと思います．
![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160678/dbac96ac-4dd9-bb8f-7bdb-59b4c826ca5d.png)

# やりたいこと
* 前回の記事で天鳳のログファイル（下図のような形式）を自動で記録していくアプリを作成することができたので，その__ログの集計情報をゲットしたい__．
* 集計情報とは，例えば各対局者の累計ポイントや累計チップ，ポイントの推移などです．　　
* 天鳳には公式の[集計ツール](https://tenhou.net/ranking_tool.html)があるのですが，頻繁に集計するためにログを__コピペするのはめんどくさい__のでプログラムを作って自動的に集計したい．　　
* 一人でやる分には毎回プログラムを実行すればいいだけですが，麻雀仲間は基本的にプログラミングできないので，みんながいつでも集計情報を見れるように__慣れ親しんだインターフェースで実行したい__．

* 以上の動機から集計ツールをpythonで作り，インターフェースをLINEにすることにしました．


![スクリーンショット 2020-06-07 1.06.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160678/d87661f5-388e-464a-44d7-c08c000ef7cc.png)



# 集計アプリの作成
## グラフ作成プログラム
* まず集計計算やグラフ作成を行うプログラムを作ります．ここはPythonを使い慣れている皆さんであればすぐ作れるかと思います．自分のやりたい処理に合わせて作成してください．
* 今回は記事が冗長になるのでグラフ作成プログラムだけ具体的に書きます．
* 各機能を.pyファイルに関数として定義しておけば外部から呼び出しできて便利です．今回のコード例でも関数にして，あとで説明するリクエスト応答プログラムから呼び出せるようにしています．
* 以下は，Aさん，Bさん，Cさん，Dさんの４人で三麻を打った場合の処理について，累計点数，累計チップの推移をグラフにするコード例です．合計点数を出したり他の処理についても同様の要領で作れると思います．
* ログファイルは(log.txt)は作業ディレクトリにある前提になっています．（前回の記事でログファイルはDropboxに保存していたので，予めDropboxからダウンロードしておく必要がありますが，これについてはLINEのリクエスト応答部に書くので今は作業ディレクトリにあるものとしてください）
* 次に各プレイヤーの点数，累積点数，チップ，累積チップを格納するための配列を用意しておきます（pointsX,pointSumX,tipX,tipSumX　where X=A,B,C,D）．あとから思えばA,B,C,Dとつけるととても面倒で，辞書を使えばよかったなと思います．また一時情報を格納する配列（LISTという名前）を用意しておきます．
* 各行に対して愚直にsplit()メソッドを使用して情報を分離し，先程用意した一時配列に格納していきます．チップありとチップ無しでログファイルの形式が少し違うので，分岐処理しています．もっといいやり方がありそうです．．．(笑)
* 一時ファイルの中身を，各プレーヤー用の配列に分配していきます．辞書にしとけばifなんか使わなくても書けたな(;´д｀)ﾄﾎﾎ…
* 最後にグラフをmatplotlibで描画します．
* もっとスマートにかけそうですが，突貫プログラムなのでご容赦を．．．（笑）

```python
# coding:utf-8

import numpy as np 
#import pandas as pd
import re
import matplotlib.pyplot as plt

def graph_plot(tip):
    f = open('log.txt')
    lines = f.readlines() # 1行毎にファイル終端まで全て読む(改行文字も含まれる)
    f.close()
    pointsA   = [0] # Aさん
    pointsB   = [0] # Bさん
    pointsC   = [0] # Cさん
    pointsD   = [0] # Dさん
    
    pointSumA = [0] # 
    pointSumB = [0] # 
    pointSumC = [0] # 
    pointSumD = [0] # 
    
    tipA   = [0] # 
    tipB   = [0] # 
    tipC   = [0] # 
    tipD   = [0] # 
    
    tipSumA = [0] # 
    tipSumB = [0] # 
    tipSumC = [0] # 
    tipSumD = [0] # 
    
    LIST = []

    for line in lines[1:]:
        if len(line) > 10: #変な行は飛ばす
            roomid  = line.split("|")[0]
            time    = line.split("|")[1]
            rools   = line.split("|")[2]
            players = line.split("|")[3]
            # 祝儀なしの場合
            if tip == False:
                l = re.split('[ ()]', players)
                LIST.append([l[1],float(l[2].replace("+",""))])
                LIST.append([l[4],float(l[5].replace("+",""))])
                LIST.append([l[7],float(l[8].replace("+",""))])
            # 祝儀ありの場合
            if tip == True:
                l = re.split('[ (,)]', players)
                print(l)
                LIST.append([l[1],float(l[2].replace("+","")),float(l[3].replace("+","").replace("枚",""))])
                LIST.append([l[5],float(l[6].replace("+","")),float(l[7].replace("+","").replace("枚",""))])
                LIST.append([l[9],float(l[10].replace("+","")),float(l[11].replace("+","").replace("枚",""))])

    # print(LIST)
    for i,data in enumerate(LIST):
        player  = data[0]
        point   = data[1]
        if tip == True:
            tips    = data[2]
        if player == "Aさん":
            pointsA.append(point)
            pointSumA.append(pointSumA[-1]+point)
            if tip == True:
                tipA.append(tips)
                tipSumA.append(tipSumA[-1]+tips)
        elif player == "Bさん":
            pointsB.append(point)
            pointSumB.append(pointSumB[-1]+point)
            if tip == True:
                tipB.append(tips)
                tipSumB.append(tipSumB[-1]+tips)
        elif player == "Cさん":
            pointsC.append(point)
            pointSumC.append(pointSumC[-1]+point)
            if tip == True:
                tipC.append(tips)
                tipSumC.append(tipSumC[-1]+tips)
        elif player == "Dさん":
            pointsD.append(point)
            pointSumD.append(pointSumD[-1]+point)
            if tip == True:
                tipD.append(tips)
                tipSumD.append(tipSumD[-1]+tips)
        
    xA = [i for i in range(len(pointsA))]
    xB = [i for i in range(len(pointsB))]
    xC = [i for i in range(len(pointsC))]
    xD = [i for i in range(len(pointsD))]
    

    plt.clf()

    plt.plot(xA,pointSumA,label="Aさん")
    plt.plot(xB,pointSumB,label="Bさん")
    plt.plot(xC,pointSumC,label="Cさん")
    plt.plot(xD,pointSumD,label="Dさん")
    
    plt.legend()
    plt.savefig("graph_1.png")

    plt.clf()
    plt.plot(xA,tipSumA,label="Aさん")
    plt.plot(xB,tipSumB,label="Bさん")
    plt.plot(xC,tipSumC,label="Cさん")
    plt.plot(xD,tipSumD,label="Dさん")
    
    plt.legend()
    plt.savefig("graph_2.png")



if __name__ == "__main__":
    graph_plot(tip=True)
```

* プログラムを実行するとこんな図が出来上がります．
* 私は９人でやってるので９人分の折れ線が表示されてます
* ちなみに僕は青線です！圧倒的弱者！
* いまは横軸を半荘数にしていますが，１日毎にしても面白そうですね．

![graph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160678/da94a928-d35f-3c17-ab70-9441ebb2c42a.png)

## リクエスト応答（LINE bot API）
* 次にLINE bot APIを使用したリクエスト応答部分を作成していきます．  

### プリアンブル部
* まず必要なモジュールを`import`していきます．　　
* どうでもいいですが，最近REFERENCESをコード中に書くと後で助かることに気が付きました．
* 


```python

# -*- coding: utf-8 -*-

#  Licensed under the Apache License, Version 2.0 (the "License"); you may
#  not use this file except in compliance with the License. You may obtain
#  a copy of the License at
#
#       https://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#  License for the specific language governing permissions and limitations
#  under the License.

'''
## references
1. Line Bot API
* [公式-python](https://github.com/line/line-bot-sdk-python/blob/master/README.rst_)
* https://keinumata.hatenablog.com/entry/2018/05/08/122348
* [bottun](https://qiita.com/shimayu22/items/c599a94dfa39c6466dfa)
* [](https://dev.classmethod.jp/etc/line-messaging-api-action-object/)
* [避難場所LINE bot](https://qiita.com/lovemysoul/items/5ad818220d65b74351a5)
  このサイトまじでわかりやすい．神．

2. DB,SQL
* https://baku1101.hatenablog.com/entry/2019/04/15/185003
* https://qiita.com/jacob_327/items/ec7d2223010ad4a0dd38

3. Python x S3(AWS)
* https://www.casleyconsulting.co.jp/blog/engineer/2861/

4. Heroku
* [環境変数の設定](https://qiita.com/colorrabbit/items/18db3c97734f32ebdfde)
* [Heroku x Linebot API](https://miyabi-lab.space/blog/21)


'''

# system
import os
import sys
import datetime
from argparse import ArgumentParser

# Web FlameWork
from flask import Flask, request, abort

# Line API
from linebot import (
    LineBotApi, WebhookHandler
    )
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent,
    PostbackEvent, 
    TextMessage, 
    TextSendMessage,
    ButtonsTemplate,
    URIAction,
    PostbackAction,
    MessageAction,
    ImageSendMessage,
    ConfirmTemplate,
    TemplateSendMessage,
    QuickReply,
    QuickReplyButton
)



# DF,Graph,etc
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np 
import re # 正規表現

import pprint

# Google Drive は使わないのでいらないです
# Google Drive API
# import os
# import pprint

# AWS
import boto3

```

### インスタンスの作成
* webフレームワークにはFlaskを使用していますが，これが何なのかよくわかっていません．今後勉強します．とりあえず先人のコピペです．
* [Python3 + Flask + Herokuで避難場所確認LINE Botを作りました -Qiita](https://qiita.com/lovemysoul/items/5ad818220d65b74351a5)を主に参考にしました．
* つづいてLINE botのチャネルのアクセストークンとチャネルシークレットを用意します．ここではHerokuの環境変数に```LINE_CHANNEL_SECRET```と```LINE_CHANNEL_ACCESS_TOKEN```という名前で登録していたものを```os.getenv```で読み込んでいます．LINE botのチャネル作成も前述のサイトを参考にしました．こうしておくとコード中に直接シークレット等の情報を書かなくていいので，セキュリティ的に良いみたいです．
* Herokuの環境変数については[[heroku]環境変数の操作 -Qiita](https://qiita.com/colorrabbit/items/18db3c97734f32ebdfde)を参考にしました．
* 同様にAmazon　S3への用のインスタンスも用意しておきます．


```python

# Flask Web App Instance
app = Flask(__name__)


# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None:
    print('Specify LINE_CHANNEL_SECRET as environment variable.')
    sys.exit(1)
if channel_access_token is None:
    print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
    sys.exit(1)


# PREPARE LINE messaging API Instance
line_bot_api = LineBotApi(channel_access_token)
handler = WebhookHandler(channel_secret)

# AWS Instance
aws_s3_bucket = os.environ['AWS_BUCKET']
s3 = boto3.resource("s3")
bucket = s3.Bucket(aws_s3_bucket)
s3_client = boto3.client('s3')

```

### リクエスト応答処理部分
* ここからがLINE botの本体になります．
* 基本的な構成はやはり前述の[Python3 + Flask + Herokuで避難場所確認LINE Botを作りました -Qiita](https://qiita.com/lovemysoul/items/5ad818220d65b74351a5)を主に参考にしました．


```python



'''
以下アクション時の応答処理
'''

@app.route("/callback", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    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)
    return 'OK'

#

```

### メッセージイベントに対する応答
* LINEでユーザーからメッセージが送られてきたときの応答は以下のように，```@handler.add(MessageEvent, message=TextMessage)```というデコレータがついた関数の中に定義していきます．
* メッセージの内容を```if```で分岐処理させています．
* １つ目の「新垣結衣」は個人的な趣味嗜好なので飛ばしてください．
* ２つ目に「ぐらふ」という文字列がメッセージに含まれるときの処理を定義します．
    * このとき「点」と「チップ」といった___Quick Reply___ボタンを表示させます．
    * どちらかが選ばれると定義しておいた___postback___(```data="request_point"```等で定義しているもの)と呼ばれるものが返ってきます．
    * このpostbackに応じて，後述の___postback action___で定義した処理が実行されます．
    * メッセージが「しゅうけい」という文字列を含んでいる場合も同様に，Quick Replyボタンを表示して．．．と繰り返します．

```python
@handler.add(MessageEvent, message=TextMessage)
def message_text(event):
    '''
    テキストメッセージが送られてきたときの処理
    '''
    try:
        message = event.message.text
        if message.count("新垣結衣") != 0:   
            text = "plotting...\n"
            line_bot_api.reply_message(
                event.reply_token,
                ImageSendMessage(
                    original_content_url = "https://orionfdn.org/wp-content/uploads/2018/12/WS000011-69.jpg",
                    preview_image_url    = "https://orionfdn.org/wp-content/uploads/2018/12/WS000011-69.jpg"
                )
            )
        
        # Graph Plot
        elif message.count("ぐらふ") != 0:
            # import download4
            # import graph

            line_bot_api.reply_message(
                    event.reply_token,
                    TextSendMessage(
                        text="どれにする?",
                        quick_reply=QuickReply(
                            items=[
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="点",       # ボタンに表示する文字
                                        text="点数推移を見せて",  # テキストとして送信する文字
                                        data="request_point"     # Postback
                                    )
                                ),
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="チップ",
                                        text="チップ推移をみせて",
                                        data="request_tip"
                                    )
                                )
                            ]
                        )
                    )
                )


             

        # Summary
        elif message.count("しゅうけい") != 0:
            line_bot_api.reply_message(
                    event.reply_token,
                    TextSendMessage(
                        text="どれにする?",
                        quick_reply=QuickReply(
                            items=[
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="収支",       # ボタンに表示する文字
                                        text="収支を見せて",  # テキストとして送信する文字
                                        data="request_sum"     # Postback
                                    )
                                ),
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="着順",
                                        text="着順をみせて",
                                        data="request_rank"
                                    )
                                ),
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="チーム",
                                        text="チーム成績をみせて",
                                        data="request_team"
                                    )
                                ),
                                QuickReplyButton(
                                    action=PostbackAction(
                                        label="Today",
                                        text="今日の結果をみせて",
                                        data="request_today"
                                    )
                                )
                            ]
                        )
                    )
                )

        # おまけ
        elif message.count("もげ") != 0:
            line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = "じょんが")
            )

        elif message.count("だーー") != 0:
           line_bot_api.reply_message(
                event.reply_token,
                ImageSendMessage(
                    original_content_url = "http://attrip.jp/wp-content/uploads/2013/07/20130716-130424.jpg",
                    preview_image_url    = "http://attrip.jp/wp-content/uploads/2013/07/20130716-130424.jpg"
                )
            )

        elif message.count("ブリテン") != 0:
            line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text = "イギリスかよ")
            )

    


    except:
        import traceback
        print("errrrrrrrrrror")
        traceback.print_exc()
        
```

###　Postbackイベントに対する応答．
* さっきのQuick　Replyで返ってきたpostbackに対応した処理を以下で定義していきます．
* まず自作モジュール群の```download4```（前回記事）,```summary```,```graph```を```import```してます．
* ```graph```は先程書いた点数推移やチップ推移をグラフを作成するためのモジュールです．
* ```summary```は長くなるので書いてませんが，基本的には```graph```と同様な構成で，着順分布や合計点数を計算するためのモジュールです
* postbackが```"request_point"```であったときの処理だけ説明します．

1. ```download4.download("/logvol2.txt","log.txt")```でログファイルをDropboxからダウンロードします．
2. ```graph.graph_plot(tip=True)```でグラフをプロットします．
3. ```bucket.upload_file("test.png", "test.png")```でS3へグラフファイルをアップします．
4. ```s3_image_url ...(以下略)```でアップしたファイルのURLを取得します．
5. 最後に```ImageSendMessage()```を使用してユーザにグラフ画像を返信します．

* 基本的な流れは他の処理でも同様ですので，各自で好きなように作ってみてください．
* 画像じゃなくて文字を送る場合は```TextSendMessage()```を使います．


```python



@handler.add(PostbackEvent)
def handle_postback(event):
    '''
    PostBackアクションがあったときの動作
    '''
    import download4
    import summary
    import graph

    postbackdata = event.postback.data
    if postbackdata == "request_point":
        download4.download("/logvol2.txt","log.txt")
        graph.graph_plot(tip=True)
        bucket.upload_file("test.png", "test.png")
        s3_image_url = s3_client.generate_presigned_url(
            ClientMethod = 'get_object',
            Params       = {'Bucket': aws_s3_bucket, 'Key': "test.png"},
            ExpiresIn    = 600,
            HttpMethod   = 'GET'
        )

        line_bot_api.reply_message(
            event.reply_token,
            ImageSendMessage(
                original_content_url = s3_image_url,
                preview_image_url    = s3_image_url,
            )
        )
        download4.upload("test.png","/graph.png")  

    if postbackdata == "request_tip":
        download4.download("/logvol2.txt","log.txt")
        graph.graph_plot(tip=True)
        bucket.upload_file("test2.png", "test2.png")
        s3_image_url = s3_client.generate_presigned_url(
            ClientMethod = 'get_object',
            Params       = {'Bucket': aws_s3_bucket, 'Key': "test2.png"},
            ExpiresIn    = 600,
            HttpMethod   = 'GET'
        )

        line_bot_api.reply_message(
            event.reply_token,
            ImageSendMessage(
                original_content_url = s3_image_url,
                preview_image_url    = s3_image_url,
            )
        )
        download4.upload("test2.png","/graph2.png")    


    elif postbackdata == "request_sum":
        import download4
        import summary
        download4.download("/logvol2.txt","log.txt")
        summary.sumup(tip=True)

        with open('summary.txt') as f:
            lines = f.readlines()
        text = ""
        for line in lines:
            text += "{}\n".format(line)
        line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text = text)
        )
        download4.upload("summary.txt","/summary.txt")  
    
    elif postbackdata == "request_today":
        import download4
        import summary
        download4.download("/todays_score.txt","todays_log.txt")
        summary.today(tip=True)

        with open('todays_summary.txt') as f:
            lines = f.readlines()
        text = ""
        for line in lines:
            text += "{}\n".format(line)
        line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text = text)
        )
        download4.upload("todays_summary.txt","/todays_summary.txt")  

    elif postbackdata == "request_rank":
        import download4
        import summary
        download4.download("/logvol2.txt","log.txt")
        summary.sumup(tip=True)

        with open('rank.txt') as f:
            lines = f.readlines()
        text = ""
        for line in lines:
            text += "{}\n".format(line)
        line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text = text)
        )
        download4.upload("rank.txt","/rank.txt")  

    elif postbackdata == "request_team":
        import download4
        import summary
        download4.download("/logvol2.txt","log.txt")
        summary.sumup(tip=True)

        with open('team.txt') as f:
            lines = f.readlines()
        text = ""
        for line in lines:
            text += "{}\n".format(line)
        line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text = text)
        )
        download4.upload("team.txt","/team.txt")  
    


if __name__ == "__main__":
    print("hello")
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```
   
## Herokuへまとめてデプロイ
* あとはグラフ作成の```graph.py```（記事の最初なやつ），集計計算の```summary.py```（今回中身については書いてません），LINE bot の応答部分```tenhoulinebot.py```（としておく）をまとめてHerokuへデプロイするだけです．
* 作ったLine botを友達登録して「ぐらふ」とかって打つと，グラフの画像が返ってきます． 



# おわりに
* かなり大雑把な説明になってしまったので，あとから直していこうと思います．
* とりあえず自分の備忘録として投稿させていただきます．
* 分かりづらい部分はコメントいただけると，僕の勉強にもなるのでうれしいです．

# p.s.
* 麻雀勝てないので，Apex Legendはじめました．
* 初FPSめちゃくちゃ難しい．