LINE WORKS用Trello BotをHerokuにデプロイするまで

こんばんは、@0yanです。
前回、[PythonでLINE WORKS版 Trello Botを作るまでのお話](https://qiita.com/0yan/items/cd9a292d5c81c32a847d)という記事を書きましたが、
- Trello webhookのエンドポイント（コールバックURLに指定するサーバ）は社外にさらせないとダメじゃん
- DMZでさらすとしても、API情報をソースコードに載せるのはちょっとマズイよな・・・
ということに気付き、結局、実装しておりませんでした。

しかし、「Herokuの環境変数にAPI情報を載せればいけるのでは？」と気付いてデプロイしました。
本記事は、デプロイと躓いたことの備忘録です。
LINE WORKS版Trello Botの作り方については、前回の記事をご参照願います。
※ コードは前回から更新されておりますので、こちらをご覧ください。



# 環境
Windows 10 Home Edition
Python 3.7.3
Flask 1.1.1
gunicorn 19.9.0
py-trello 0.15.0



# 目次
- デプロイ準備
    - 仮想環境構築
    - Herokuのビルドプロセスで使うファイルの作成
    - 最終的なディレクトリ構成
- Heroku Appの作成
- Trello webhookの生成
    - webhookを間違って生成してしまった場合
- デプロイ
- ハマったところ
    - ①既存のトークルームに招待できない
    - ②Trelloから同じ通知が3回飛んできた
- さいごに
- おまけ（残りのコード）



# デプロイ準備
### 仮想環境構築
①プロジェクト直下に仮想環境を構築してアクティベートします。

```
$ python -m venv venv
$ cd venv
$ Scripts\activate.bat
```

②ライブラリをインストールします（py-trelloはプロジェクト直下=仮想環境外でインストールします）。

```
$ pip install flask
$ pip install gunicorn
```


### Herokuのビルドプロセスで使うファイルの作成
仮想環境（venv）直下に
1.Procfile（コードの実行を開始するためのコマンドを指定したテキストファイル）
2.runtime.txt（Pythonのバージョン指定ファイル）
3.requirements.txt（依存関係ファイルのリスト）
を作成します。

```python:Procfile
web: gunicorn app:app --log-file=-
```

```python:runtime.txt
python-3.7.3
```

```python:requirements.txt
Click==7.0
cryptography==2.7
decorator==4.4.0
Flask==1.1.1
gunicorn==19.9.0
Jinja2==2.10.1
json5==0.8.4
jsonschema==3.0.1
pycrypto==2.6.1
```

requirements.txtについては、下記コマンドで作成したファイルから余計なパッケージを削除して作成しました。

```
pip freeze > requirements.txt
```


### 最終的なディレクトリ構成
```
project
├ create_hook.py
├ venv
　　├ include
　　├ Lib
　　├ Scripts
　　├ app.py
　　├ bot.py
　　├ Procfile
　　├ requirements.txt
　　├ runtime.txt
```
※ app.py（前回の記事のwebhook_server.py）とbot.pyは前回の記事から更新しております。
　 本記事の最後に掲載しております。



# Heroku Appの作成
①Herokuにログイン後、ダッシュボード右上の「New」ボタンをクリック→「Create new app」をクリック→名前をつけてAppを作ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/f3599723-0ced-b7be-070f-60531dd05e9d.png)

②Settingタブ＞Buildpacks＞「Add buildpack」ボタンをクリック→Pythonを選択して「Save Changes」ボタンをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/54e58fe6-b223-7630-d5f9-8aebbf179af2.png)

③Settingタブ＞Config Vars＞「Reveal Config Vars」をクリック→Heroku Appの環境変数にLINE WORKS API情報（LINE WORKS Developer Consoleで発行したもの）を登録します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/cef97326-22ec-8f92-bc9f-920453e6f5f1.png)


### 登録するLINE WORKS API情報
- ACCOUNT_ID：空白※1
- API_ID：API ID
- BOT_NO：作成したBotのNo
- DOMAIN_ID：Domain ID
- PRIVATE_KEY：Server List(ID登録タイプ)の認証キー※2
- ROOM_ID：後で追加※3
- SERVER_API_CONSUMER_KEY：Server API Consumer Key
- SERVER_ID：Server List(ID登録タイプ)のID

###### 補足
※1 Botを含む複数人のトークルームを作ることを想定しておりますが、1対1でBotに通知させる場合はROOM_IDを空白とし、こちらに通知させたいアカウントのIDを登録します。
※2 ダウンロードしたファイルに記載の内容すべて（-----BEGIN PRIVATE KEY----- ～ -----END PRIVATE KEY-----）入力する必要があります。
※3 Herokuにデプロイ後、Botを含むトークルームを作成します。その際の戻り値が



# Trello webhookの生成
①作成したHeroku AppのDomain（Setting＞Domains and certificates＞Domain）をコピーします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/cd8f1d90-2edf-6e7f-1afe-761414eb1f5e.png)

②create_hook.pyのコールバックURLに、①でコピーしたアドレス/webhookを指定します。

```python:create_hook.py
# coding: utf-8

from trello import Board
from trello import TrelloClient


# Trello API情報（Gitに本ファイルをアップしないのでこちらに記載）
API_KEY = "自身のAPI KEY"
TOKEN = "自身のTOKEN"
API_SECRET = "自身のAPI SECRET"


def create_hook():

    global API_KEY
    global TOKEN
    global API_SECRET

    # クライアントインスタンスの生成（API認証）
    client = TrelloClient(api_key=API_KEY, api_secret=API_SECRET, token=TOKEN)

    # コールバックURL（=Heroku AppのDmain）の指定
    callback_url = "Heroku AppのDmain/webhook"

    # 自身のTrelloボードインスタンス生成
    board = Board(client=client, board_id="自身のTrelloボードのID")

    # webhookの生成
    webhook = client.create_hook(callback_url=callback_url, id_model=board.id)

    # webhookが生成されたか確認
    if webhook == False:
        print("Failed.")
    else:
        print("Success!")


if __name__ == '__main__':
    create_hook()
```

③create_hookメソッドを実行し、「Success!」とコンソールに表示されたらwebhookの生成は成功です。

④下記URLにアクセスし、webhookが生成されたか確認します。
https://trello.com/1/tokens/自身のTOKEN/webhooks


### webhookを間違って生成してしまった場合
Trelloの[APIドキュメント](https://developers.trello.com/reference#webhooksid-1)に削除ツールがあります。
使い方は下図のとおりです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/c29a1dad-3002-1193-6a39-b77ae3b7dd1e.png)



# デプロイ
①[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)をインストールします。

②仮想環境をアクティベートした状態で、ターミナルからコマンド入力してHerokuにデプロイします（作業ディレクトリはvenv）。

```
# Herokuにlogin
$ heroku login

# ローカルリポジトリの作成
$ git init

# カレントディレクトリ（venv）の更新内容をインデックスに反映
$ git add .

# 更新内容をローカルリポジトリに記録
$ git commit -m 'コミットメッセージを入力'

# リモートリポジトリ（=Heroku）のマスターブランチにプッシュ
$ git push heroku master
```



# ハマったところ
### ①既存のトークルームに招待できない
LINE WORKSのトークBotは、公開設定を「公開」にすると同じドメインに所属している全員が検索・登録できるようになってしまいます。
しかし、Trelloの通知が誰でも見れるようになっては困るため、公開設定は「非公開」にしたいと思います。
すると、今度は既存のトークルームに招待できません。
・・・ので、非公開Botとの新規トークルームを生成することにしました。
デプロイ後、ブラウザから"Heroku AppのDmain/create_room"にアクセスすると戻り値として得られるROOM IDを、Herokuの環境変数に登録すれば、そのトークルームに通知が飛ぶようになります。



### ②Trelloから同じ通知が3回飛んできた
デプロイ後、実際にTrelloを更新したら、同じ通知が3回飛んできました。
色々調べた結果、[Trello Developers](https://developers.trello.com/page/webhooks)にきちんと理由が書いてありました。

>Retries
>If for some reason the connection is disrupted, or unavailable, the webhook will retry 3 times before stopping.
>Trello will backoff in time with each retry. We'll wait 30 seconds after the first failure, then 60 seconds, and, finally, 120 seconds before trying the final time.
>
>再試行
>何らかの理由で接続が中断された場合、または使用できない場合、webhookは停止する前に3回再試行します。
>Trelloは、各再試行に間に合うようにバックオフします。最初の失敗から30秒待ってから60秒、最後に120秒待ってから最後の時間を試します。

コードにエラーがあったようです・・・。



# さいごに
やっとTrelloの通知をLINE WORKSで受け取れるようになりました！
LINE WORKS使っている方、是非、試してみてください。



# おまけ：残りのコード（app.pyとbot.py）
前回の記事から更新したので、一応、掲載しておきます。

```python:app.py
# coding: utf-8

import os

from flask import abort
from flask import Flask
from flask import request

from bot import LineWorksBot


# HEROKU環境変数（LINE WORKSのAPI情報）の呼び出し
ACCOUNT_ID = os.environ.get("ACCOUNT_ID")
BOT_NO = os.environ.get("BOT_NO")
ROOM_ID = os.environ.get("ROOM_ID")


app = Flask(__name__)


@app.route('/')
def index():
    ''' app本体の動作確認用
    Heroku App起動時に文字列（Start）を返します。
    :return: 文字列（Start）
    '''
    return 'Start'


@app.route('/create_room')
def create_room():
    ''' 非公開Botとのトークルームを生成
    :return:
    '''
    bot = LineWorksBot()
    message = bot.create_room()
    return message


@app.route('/webhook', methods=['GET', 'HEAD', 'POST'])
def webhook():
    ''' Trello webhook関連
    GET：本ページの動作確認用
    HEAD：Trello webhook生成時、HEADメソッドで200を返す必要あり
    POST：Trello更新時に送信されるJSONデータを、LINE WORKSのトークルームに送信※カードへのコメント時のみ
    :return: GET：文字列（GET）　HEAD：ステータス（200）　POST：ステータス（200）
    '''
    global ACCOUNT_ID
    global BOT_NO
    global ROOM_ID

    if request.method == 'GET':
        return 'GET'
    elif request.method == 'POST':

        # Trelloカードの更新タイプ
        action_type = request.json['action']['display']['translationKey']

        # コメントの場合のみ、メッセージ送信
        if action_type == 'action_comment_on_card':
            card_name = request.json['action']['data']['card']['name']
            user_name = request.json['action']['memberCreator']['fullName']
            comment = request.json['action']['data']['text']
            message = user_name + "さんがコメントしました。\n【カード】" + card_name + "\n【コメント】" + comment
            bot = LineWorksBot()
            bot.send_text_message(send_text=message)
            return '', 200
        else:
            pass
        return '', 200
    elif request.method == 'HEAD':
        return '', 200
    else:
        abort(400)


if __name__ == '__main__':
    app.run()
```


```python:bot.py
#  cording:utf-8

import base64
import datetime
import json
import os
import requests

from Crypto.Signature import PKCS1_v1_5
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA


# HEROKU環境変数（LINE WORKSのAPI情報）の呼び出し
ACCOUNT_ID = os.environ.get("ACCOUNT_ID")
API_ID = os.environ.get("API_ID")
BOT_NO = os.environ.get("BOT_NO")
DOMAIN_ID = os.environ.get("DOMAIN_ID")
PRIVATE_KEY = os.environ.get("PRIVATE_KEY")
ROOM_ID = os.environ.get("ROOM_ID")
SERVER_API_CONSUMER_KEY = os.environ.get("SERVER_API_CONSUMER_KEY")
SERVER_ID = os.environ.get("SERVER_ID")

# サーバーAPIトークンを格納する変数（後で使用）
SERVER_TOKEN = ""


class LineWorksBot(object):
    ''' LINE WORKS サーバーAPIおよびトークBot API関連
    Trelloの更新内容をLINE WORKSのトークルームに送信するために必要な、
    ①サーバーAPIの認証
    ②トークBot APIによるメッセージ送信
    に関するメソッドをまとめたクラスです。
    '''
    def dict_to_base64str(self, dict):
        ''' BASE64Unicode文字列へのエンコード（JWT生成に使用）
        :param dict: 辞書型文字列
        :return: BASE64Unicode文字列
        '''
        # 辞書型文字列をJSON形式にエンコード
        dump_text = json.dumps(dict)

        # JSON文字列をUnicode文字列にエンコード後、BASE64エンコードしたうえで、Unicode文字列にデコード
        base64_text = base64.urlsafe_b64encode(dump_text.encode('utf-8')).decode('utf-8')

        return base64_text

    def sign_rsa(self, message):
        ''' RSA SHA-256アルゴリズムで暗号化し、BASE64エンコード（JWT生成に使用）
        :param message: JWTヘッダー.JWTクレームセット
        :return: RSA SHA-256暗号化（=電子署名）及びBASE64エンコードされた文字列
        '''
        global PRIVATE_KEY

        # Server List（ID登録タイプ）で発行した認証キーの読み込み
        key = RSA.importKey(PRIVATE_KEY)

        # Unicode文字列をバイトコードに直す
        message_byte = message.encode('utf-8')

        # ハッシュ関数でダイジェストを生成
        digest = SHA256.new(message_byte)

        # 認証キーを基に、PKCSのインスタンス生成
        signer = PKCS1_v1_5.new(key)

        # 電子署名を生成
        signature = signer.sign(digest)

        # 電子署名をバイトデータのままBASE64エンコードし、それをUnicode文字列にデコード
        return base64.urlsafe_b64encode(signature).decode('utf-8')

    def jwt_create(self):
        ''' JWTの生成
        :return: JWT
        '''
        global SERVER_ID

        jwt_header = {
            "alg": "RS256",
            "typ": "JWT"
        }

        jwt_header_base64 = self.dict_to_base64str(jwt_header)

        json_claim_set = {
            "iss": SERVER_ID,

            # JWT生成日時をUNIX時間で指定（単位：sec）※timestampメソッドの戻り値（float型）はint型に型変換
            "iat": int(datetime.datetime.now().timestamp()),

            # JWT満了日時をUNIX時間で指定（単位：sec）
            "exp": int((datetime.datetime.now() + datetime.timedelta(minutes=30)).timestamp())
        }

        json_claim_set_base64 = self.dict_to_base64str(json_claim_set)

        # {header BASE64 エンコード}.{JSON Claim set BASE64 エンコード}
        plain_text = jwt_header_base64 + "." + json_claim_set_base64

        # JWT電子署名（プレーンテキストを秘密鍵で暗号化、BASE64Unicode文字列を得る）
        signature_base64 = self.sign_rsa(plain_text)

        # 結合してJWTを得る
        jwt = plain_text + "." + signature_base64

        return jwt

    def get_server_token(self):
        ''' LINE WORKS認証サーバーへのTokenリクエスト
        :return: サーバートークン
        '''

        global API_ID

        request_url = 'https://auth.worksmobile.com/b/{}/server/token'.format(API_ID)

        headers = {
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        }

        payload = {
            "grant_type": "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer",
            "assertion": self.jwt_create()
        }

        res = requests.post(url=request_url, headers=headers, params=payload)

        access_token = json.loads(res.text)["access_token"]

        return access_token

    def call_server_api(self, resource, payload):
        ''' サーバーAPIの呼び出し
        :param payload:
        :return:
        '''

        global API_ID
        global SERVER_TOKEN
        global SERVER_API_CONSUMER_KEY

        # サーバートークンの取得
        SERVER_TOKEN = self.get_server_token()

        # LINE WORKSのAPIキー設定（v2.6からAPI_URL（メッセージ送信共通のRequest URL）変わった）
        request_url = "https://apis.worksmobile.com/r/{}/{}".format(API_ID, resource)

        headers = {
            "Content-Type": "application/json; charset=UTF-8",
            "consumerKey": SERVER_API_CONSUMER_KEY,
            "Authorization": "Bearer " + SERVER_TOKEN,
        }

        r = requests.post(request_url, headers=headers, data=json.dumps(payload))

        return r.text

    def create_room(self):
        ''' 非公開Botを含むトークルームの新規作成
        LINE WORKSの仕様上、全体に公開しないと既存のトークルームにBotを招待できないため、
        非公開Botを含むトークルームを新規作成します。
        :return:作成されたトークルームID roomId(String型)
        '''
        global API_ID
        global SERVER_TOKEN
        global SERVER_API_CONSUMER_KEY
        global BOT_NO

        SERVER_TOKEN = self.get_server_token()

        request_url = "https://apis.worksmobile.com/r/{}/message/v1/bot/{}/room".format(API_ID, BOT_NO)

        headers = {
            "Content-Type": "application/json; charset=UTF-8",
            "consumerKey": SERVER_API_CONSUMER_KEY,
            "Authorization": "Bearer " + SERVER_TOKEN,
        }

        payload = {
            # accountIdsで、ユーザーIDをリスト指定すれば複数ユーザーをトークルームに招待可能
            "accountIds": ["トークルームに招待したいユーザーのID", "同左"],
            "title": "Trello Bot"
        }

        r = requests.post(request_url, headers=headers, data=json.dumps(payload))

        return r.text

    def send_text_message(self, send_text):
        ''' テキストメッセージをaccountIdまたはroomIdに送信
        :param send_text: Trello JSONデータをapp.pyで加工したもの
        :return:
        '''
        global BOT_NO
        global ACCOUNT_ID
        global ROOM_ID

        content_dict = {
            "content": {
                "type": "text",
                "text": send_text,
            }
        }

        self.send_message(botNo=BOT_NO, accountId=ACCOUNT_ID, roomId=ROOM_ID, content_dict=content_dict)

    def send_message(self, botNo, accountId, roomId, content_dict):
        ''' 汎用メッセージ送信（content_dictにテキスト以外のタイプを格納）
        :param botNo: メッセージを送信するトークBot No
        :param accountId: メッセージを送信するアカウントのID
        :param roomId: メッセージを送信するルームID※本クラスのcreate_roomメソッドで帰ってくるID
        :param content_dict: app.pyから受け取る、Trello JSONデータを加工したメッセージ
        :return:
        '''
        resource = "message/v1/bot/{}/message/push".format(botNo)

        payload = {
            "botNo": str(botNo),
        }

        # accountIdとroomIdはどちらか一方を指定
        if accountId != "":
            payload.update({"accountId": str(accountId)})
        elif roomId != "":
            payload.update({"roomId": str(roomId)})
        else:
            print("accountId and roomId error.")

        payload.update(content_dict)

        json.loads(self.call_server_api(resource, payload))
```