LIFFアプリを作ってみよう（#2 応用編）-LINEトークルームに自分で描いた画像を投稿できるようにする！-

#目的
自称LINE Bot芸人としてのノウハウまとめその③として。

#やりたいこと
この記事はLIFFアプリに関する記事その2になります。
LIFFアプリそのものの作り方について知りたい方はは、[こちら](https://qiita.com/bowtin/items/ecba2bcad7ecbe0df745)をどうぞ。

今回は、LIFFアプリの概要は理解できたと想定をしてLIFFアプリそのものをカスタマイズしていきます。
題材はこんな感じでいきます。

* LIFFアプリを起動するとcanvasが出現
* 好きなように絵を描いてPostボタンを押すとトーク画面に画像が自動的に投稿される

#作り方
今回の記事は、[LINE Engineeringの記事](https://engineering.linecorp.com/ja/blog/liff-our-latest-product-for-third-party-developers/)を参考にしています。
手順はこんな感じでいきます。

* 上記LINE Engineeringの記事からなるべくコードを再利用させて頂く
* Herokuアプリを作ってデプロイ
* 自分なりにカスタマイズしていく

というわけで、早速やっていきましょう。
##LINE Engineeringの記事からコードを読み解く
まずは、LINE公式アカウント（LINE Bot）が必要です。
こちらは出来上がっているものとします。また、LIFFアプリを登録する手順についても[こちら](https://qiita.com/bowtin/items/ecba2bcad7ecbe0df745)で書いていますので省略します。


記事を読み解いた結果、こんなことがわかりました。
* シンプルなJavaScriptのみでは実現が難しい
* 投稿された画像を保存しておくストレージも必要
　（記事内では触れられていないが、Canvasに描いた内容を画像としてどこかに保存して、そのURLをLINEトークルームに投稿するので）
* 当然Webサーバも必要（記事内では環境については特に触れられておらず、WebフレームワークはFlaskを使用している模様）
* サーバサイドのコードも必要（記事内ではPythonを使用）

というわけで、今回Python、Flaskはそのまま利用するとして、WebサーバはHerokuを使います。
画像ストレージは本来S3やGCS等のストレージサービスを使うべきですが、今回はHerokuのストレージにそのまま保存してしまおうと思います。
ちなみに、Herokuは再デプロイをすると新規コンテナが構築されるため、コンテナ内のストレージに保存しておいた画像はすべて吹っ飛びます。実験用なので今回はこれで良しとします。

Python+Flaskのイントロダクションが必要な方は[こちら](https://qiita.com/bowtin/items/194c1f3b1211d7892ab8)をどうぞ。


##サーバサイドのコードを再現
コードはほぼLINE Engineeringさんの記事から拝借していますが、できる限りコメントを入れてみました。
ざっとまとめると、

* "/"にリクエストが来たらindex.html（Canvasを表示するところ）を返す
* "/saveimage"へリクエストがきたら画像を所定の場所に保存したあとで画像のパスをレスポンスとして返す

ってことですね。

```python:app.py
from flask import Flask, request, render_template, make_response
from flask_bootstrap import Bootstrap
import os
import uuid
import base64
from PIL import Image
import warnings
import traceback
import logger

app = Flask(__name__, static_folder='imgs')
bootstrap = Bootstrap(app)

@app.route('/')
def do_get():
    return render_template('index.html')

@app.route('/saveimage', methods=['POST'])
def saveimage():
    try:
        event = request.form.to_dict()

        #画像保存用ディレクトリの作成
        dir_name = 'imgs'

        #画像につけるファイル名の作成
        img_name = uuid.uuid4().hex

        #画像を保存する
        if not os.path.exists(dir_name): #ディレクトリが存在しない場合、作成
            os.makedirs(dir_name)
        with open(os.path.join(dir_name, '{}.jpg'.format(img_name)), 'wb') as img: #ディレクトリが存在する場合、画像を保存
            img.write(base64.b64decode(event['image'].split(",")[1]))
        original = Image.open(os.path.join(dir_name, '{}.jpg'.format(img_name)))
        
        #JPEG形式ではなかった場合の処理（大丈夫なはずだけど...）
        if(original.format != 'JPEG'):
            return make_response('Unsupported image type.', 400)

        #サムネイルの作成
        original.thumbnail((240, 240), Image.ANTIALIAS)

        #画像を保存
        original.save(os.path.join(dir_name, '{}_240.jpg'.format(img_name)), 'JPEG')
    except:
        logger.error(traceback.format_exc())
    return make_response(img_name, 200) #画像をレスポンスとして返す

#サーバ起動
if __name__ == '__main__':
    port = int(os.getenv("PORT", 5000))
    app.run(host="0.0.0.0", port=port)
```

##Canvasが表示されるHTML
続いて、HTML（Flaskテンプレート）です。


```index.html
{% extends "bootstrap/base.html" %}
{%- block metas %}
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
{%- endblock metas %}

{% block scripts %}
{{super()}}
  <script src="https://cdn.jsdelivr.net/npm/signature_pad@2.3.2/dist/signature_pad.min.js"></script>
  <script>

    //Canvasの設定
    const canvas = document.querySelector('#canvas');
    const signaturePad = new SignaturePad(canvas, {
      backgroundColor: 'rgb(235,235,235)',
    });


    //画面ロード時の設定
    //主にLIFFアプリの初期化、canvasのサイズ設定等
    $(window).on('load', function(){
      canvas.setAttribute('width', $('.container').width());
      canvas.setAttribute('height', window.innerHeight - $('#btn-clear').outerHeight() - 10);
      signaturePad.clear();
      liff.init(function (data) {});
    });

  </script>

    <!-- 追加 -->
    <script src="https://d.line-scdn.net/liff/1.0/sdk.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
      //Postがクリックされたときの挙動
      //"/saveimage"にPOSTでリクエストを投げる
      $('#btn-post').on('click', function() {
        $.ajax({
          type: 'POST',
          url: '/saveimage',
          data: {
            'image': signaturePad.toDataURL('image/jpeg')
          },
          success: function (res, status) {
            liff.getProfile().then(function (profile) {
              liff.sendMessages([
                {
                  type: 'image',
                  originalContentUrl: 'https://' + document.domain + '/imgs/' + res + '.jpg',
                  previewImageUrl: 'https://' + document.domain + '/imgs/' + res + '_240.jpg'
                }
              ]).then(function () {
                liff.closeWindow();
              }).catch(function (error) {
                window.alert('Error sending message: ' + error.message);
              });
            }).catch(function (error) {
                window.alert("Error getting profile: " + error.message);
            });
          },
          error: function (res) {
            window.alert('Error saving image: ' + res.status);
          },
          complete: function(data) {
          }
        });
      });
    </script>

{% endblock %}

{% block title %}Draw!{% endblock %}

  {% block content %}
  <!-- 実際にCanvasが描画されるエリア -->
  <div class="container">
    <canvas id="canvas"></canvas>
    <button id="btn-post" type="button" class="btn btn-success btn-block">Post</button>
  </div>


  {% endblock %}

{% endblock %}
```

##その他必要なファイルを用意する
これはFlaskのルールですが、フォルダ階層をこのようにしておきましょう。

```
プロジェクトのルート
 |
 +---templates
 |      +---index.html（Canvasとか書いてあるやつ）
 |
 +---app.py（実際のファイル名は何でも良いが、下記Procfileに記述する必要有り）
 +---Procfile
 +---requirements.txt
```

更にHerokuのルールですが、Procfileとrequirements.txtを書く必要があります。
今回のProcfileはこんな感じです。
Webサーバが起動するにあたりどのコードが動くかという定義だと考えると良いでしょう。

```Procfile
web: python app.py
```

requirementsは必要なPythonモジュールの列挙と考えましょう。
今回はこんな感じ。

```requirements.txt
flask
gunicorn
flask-bootstrap
Pillow
logger
```


##Herokuアプリを作る
Heroku CLIは導入済みと想定します。まだの場合は[ここ](https://devcenter.heroku.com/articles/heroku-cli)からインストールします。

以下コマンドでHerokuアプリを一つ作ります。

```shell
> heroku create
Creating app... done, ⬢ blooming-woodland-85618
```

##デプロイする
以前の記事でも同じことをしていますのでサラッと流します。

```shell
> git init
> git remote add https://git.heroku.com/アプリ名.git
#アプリ名は今回はblooming-woodland-85618
> git commit -m "new commit!"
> git push heroku master

# ~略~
remote: Verifying deploy... done.
To https://git.heroku.com/blooming-woodland-85618.git
 * [new branch]      master -> master

```

##LIFFアプリとして登録後、動作確認
LIFFアプリの登録については前記事でも触れていますので省略します。
早速動作確認をしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/64df27d4-7ee4-f7a9-076a-51ff7e56ca96.png)

line://で始まるURLをLINEトーク画面で開くと、CanvasのエリアとPostボタンが表示されています。
適当に何か描いてPostしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/21f0841a-7cba-7c4f-c9b1-e9b29620fc1d.png)


ちゃんと画像が投稿されています！
（先日作ったBotとのトーク画面で開いているので余計なリッチメニューが見えていますが気にしないでください）

##カスタマイズ
ここからはLINE Botはほぼ関係ありません。
Postだけだと使いにくかったので、「Clear」と「Undo」を追加してみました（CSSはやっつけです）。
こちらはコードのみ参考程度に貼っておきます。

```html:index.html
{% extends "bootstrap/base.html" %}
{%- block metas %}
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
{%- endblock metas %}

{% block scripts %}
{{super()}}
  <script src="https://cdn.jsdelivr.net/npm/signature_pad@2.3.2/dist/signature_pad.min.js"></script>
  <script>
    var undoStack = []; //Undo用のスタック
    const MAX_STACK_SIZE = 20; //最大Undo数
    
    const canvas = document.querySelector('#canvas');
    const signaturePad = new SignaturePad(canvas, {
      backgroundColor: 'rgb(235,235,235)',
    });

    var ctx = canvas.getContext('2d');

    $(window).on('load', function(){
      canvas.setAttribute('width', $('.container').width());
      canvas.setAttribute('height', window.innerHeight - $('#btn-clear').outerHeight() - 10);
      signaturePad.clear();
      liff.init(function (data) {});

      if(undoStack .length >= MAX_STACK_SIZE){ //Undoスタックの要素数が最大Undo数を超えている場合
        undoStack.pop(); //末尾要素を削除
      }

      //Undoスタックに情報を保持する
      undoStack.unshift(ctx.getImageData(0, 0, canvas.width, canvas.height));

    });

    $(canvas).on('mouseup', function(){
      var imageData = ctx.getImageData(0,0,canvas.width, canvas.height);
      undoStack.push(imageData);
    });

    $(canvas).on('touchend', function(){
      var imageData = ctx.getImageData(0,0,canvas.width, canvas.height);
      undoStack.push(imageData);
    });

  </script>

    <!-- 追加 -->
    <script src="https://d.line-scdn.net/liff/1.0/sdk.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
      $('#btn-post').on('click', function() {
        $.ajax({
          type: 'POST',
          url: '/saveimage',
          data: {
            'image': signaturePad.toDataURL('image/jpeg')
          },
          success: function (res, status) {
            liff.getProfile().then(function (profile) {
              liff.sendMessages([
                {
                  type: 'image',
                  originalContentUrl: 'https://' + document.domain + '/imgs/' + res + '.jpg',
                  previewImageUrl: 'https://' + document.domain + '/imgs/' + res + '_240.jpg'
                }
              ]).then(function () {
                liff.closeWindow();
              }).catch(function (error) {
                window.alert('Error sending message: ' + error.message);
              });
            }).catch(function (error) {
                window.alert("Error getting profile: " + error.message);
            });
          },
          error: function (res) {
            window.alert('Error saving image: ' + res.status);
          },
          complete: function(data) {
          }
        });
      });
    </script>

    <script>
        $('#btn-clear').on('click', function(){
            ctx.fillStyle = 'rgb(235,235,235)';
            ctx.fillstyle = 'rgb(235,235,235)';
            ctx.fillRect(0, 0, 9999, 9999);
        });

        $('#btn-undo').on('click', function(){
          if(undoStack.length > 1){
            undoStack.pop(); //Undoスタックの末尾を削除

            undoStack.forEach(function(imageData){
              ctx.putImageData(imageData, 0, 0);
            });
          }
        });
    </script>
{% endblock %}

{% block title %}Draw!{% endblock %}

{% block content %}
<div class="container">
  <canvas id="canvas"></canvas>
  <div class="btn-container">
    <button id="btn-post" type="button" class="btn btn-success btn-block">Post</button>
    <button id="btn-clear" type="button" class="btn btn-danger btn-block">Clear</button>
    <button id="btn-undo" type="button" class="btn btn-primary btn-block">Undo</button>
  </div>
</div>

<style>
    button#btn-post{
        max-width: 33%;
        float:left !important;
        margin-right: 0.25%;
        margin-left: 0.25%;
    }
    button#btn-clear{
        max-width: 33%;
        float:left !important;
        margin-right: 0.25%;
        margin-top: 0%;
    }
    button#btn-undo{
      max-width: 33%;
    }

</style>
{% endblock %}
```

最終的にはこんな感じになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495652/20358bef-287d-11f1-0a65-b9f3508c3946.png)

#まとめ
* LIFFアプリで実用的なモノも作れちゃう！