0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

こんばんは、@0yanです。
前回、PythonでLINE WORKS版 Trello Botを作るまでのお話という記事を書きましたが、

  • 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(依存関係ファイルのリスト)
を作成します。

Procfile
web: gunicorn app:app --log-file=-
runtime.txt
python-3.7.3
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

②Settingタブ>Buildpacks>「Add buildpack」ボタンをクリック→Pythonを選択して「Save Changes」ボタンをクリックします。
image.png

③Settingタブ>Config Vars>「Reveal Config Vars」をクリック→Heroku Appの環境変数にLINE WORKS API情報(LINE WORKS Developer Consoleで発行したもの)を登録します。
image.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

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

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ドキュメントに削除ツールがあります。
使い方は下図のとおりです。
image.png

デプロイ

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にきちんと理由が書いてありました。

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)

前回の記事から更新したので、一応、掲載しておきます。

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()
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))
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?