PythonでLINE WORKS版 Trello Botを作るまでのお話

某ICT関連企業で人事（教育・研修担当）をやっている@0yanと申します。
初めてのQiitaへの投稿ですので至らぬ点も多々あるかと存じますが、宜しくお願い致します。

# LINE WORKS版 Trello Botを作った経緯
私の部署では、Trelloを使ってタスク共有しているのですが、会社で使っているチャットツールのLINE WORKSにTrelloの更新通知機能がないため、仕方なくHangouts ChatのTrello Botを使ってタスク共有しておりました。

しかし、普段のやり取りはLINE WORKSを見て、タスク更新のお知らせが来たらHangouts Chatを見て・・・というのは正直めんどくさい。
そのため、Pythonの勉強がてら自分でTrello Botを作ることにした次第です。

なお、プログラミングは独学で学んでいる最中でして、学習期間としてはJavaが3ヶ月（基本情報技術者試験でJavaを選択）、Pythonが1ヶ月半です。
そのため、コード汚いところがあると思います。
お気づきの点がございましたら、ご指摘頂けますと幸いです。

# 概要
Trelloのカードにコメントが投稿されたら更新通知（JSON）をWebhookで受け取る。
JSONのコメント部分だけ切り出したうえで、LINE WORKSのトークルームに通知する。
そんなBotを作りました。
なお、今回はローカルPCをWebサーバ化してngrokで外部公開し、Botがきちんと動作するところまでを確認しております。

2019/8/14追記:Herokuにアップロードするまでの記事を書きましたので、よければ参考にしてください。
>[LINE WORKS用Trello BotをHerokuにデプロイするまで](https://qiita.com/0yan/items/492ca08bb3bfde32dab8)

2019/8/23追記:PyPIに公開したライブラリを使って楽にBot実装できるようにしました。よければご覧ください。
>[【備忘録】PythonによるLINE WORKS版Trello Botの実装（PyPI lineworks インストールVer.）](https://qiita.com/0yan/items/b112024cfe9eed443795)

### 環境
- Windows 10 Home
- Python 3.6.8（Anaconda3）
- Flask 0.12.2
- gunicorn 19.9.0
- virtualenv 16.6.0
- py-trello 0.15.0

### Bot作成の大まかな流れ
1. LINE WORKS API情報取得
2. LINE WORKS トークBot登録
3. コーディング①（トークBot）
4. Trello API情報取得
5. コーディング②（Trello Webhook）
6. コーディング③（Webhookサーバ）
7. ローカルPCをWebサーバ化してngrokで外部公開

# 1. LINE WORKSのAPI情報取得
1番から3番まではitoxさんの記事と、LINE WORKSの公式ドキュメントを参考にしました。
> itoxさんの記事：PythonでJWT生成からボット作成、投稿までやってみた
> URL：https://www.slideshare.net/itoxdev/pythonjwt
>
> LINE WORKS Developers＞概要
> URL：https://developers.worksmobile.com/kr/document/13?lang=ja

最初に行うのは、公式ドキュメントのAPI共通ガイド＞[API認証の準備](https://developers.worksmobile.com/kr/document/1002002?lang=ja)の部分です。
[LINE WORKS Developers](https://developers.worksmobile.com/jp/?lang=ja)の右上にあるログインボタンを押下しログイン後、以下四点を順次発行していきます。

- API ID
- Server API Consumer Key
- Server List(ID登録タイプ)　ID
- Server List(ID登録タイプ)　認証キー 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/dc6e76d8-679d-f312-0518-8547f5c9ce7a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/7fea2aa0-76e1-c1fc-5685-2741ba70de4c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/6a08b79f-d409-46b1-52bf-de57b6858b3a.png)


# 2. LINE WORKS トークBot登録
Developer Consoleのサイドバー「Bot」をクリックし、遷移した画面の「登録」ボタンを押下するとBot登録画面になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/cd59ce2f-a187-1fa1-cdc1-9bc313435663.png)
この画面で、下表のとおり、登録していきます。

|項目　|内容|備考|
|:-:|:-:|:-:|
|Bot名　|Trello Bot|違う名前でも可　|
|「説明」　|任意の説明文|　|
|Callback URL　|ラジオボタンOff|今回はメッセージ送信のみのため　|
|Botポリシー　|チェックボックスON|Botに、複数人のトークルームへのTrello更新通知を送信させたいため　|
|管理者（主担当）　|開発者のアカウント|組織の事情に合わせて変更可　|

# 3.コーディング①（トークBot）
### ライブラリとグローバル変数
ライブラリとグローバル変数は以下の通りです。
なお、1で発行したServer List(ID登録タイプ)の認証キーのファイルについては、コーディングするファイル（bot.py）と同じ階層に格納します。

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

import base64
import datetime
import json
import requests

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


# グローバル変数
API_ID = "1で発行したAPI ID"
SERVER_ID = "1で発行したServer List(ID登録タイプ)のID"
KEY_FILE_NAME = "1で発行したServer List(ID登録タイプ)の認証キーのファイル名"
# APIトークンを格納する変数（後で使用）
API_TOKEN = ""
SERVER_API_CONSUMER_KEY = "1で発行したServer API Consumer Key"
DOMAIN_ID = "LINE WORKS Developer Consoleのサイドバー下部に記載のID"
BOT_NO = "2で登録したトークBotのBot No."
ACCOUNT_ID ="開発者のID（テスト用。トーク画面の個人情報から見れます）"
```

### Botクラスの全体像
ここからBotクラスを作っていきますが、LINE WORKSのAPIを叩くには公式ドキュメント＞API共通ガイド＞[API認証の準備](https://developers.worksmobile.com/kr/document/1002002?lang=ja)に記載のとおり、Server Tokenを発行しなければなりません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/0d851b91-3cfd-d941-5f87-461aac4227e3.png)
出典：Works Mobile Corp.(発行年不明)「API認証の準備」,<https://developers.worksmobile.com/kr/document/1002002?lang=ja>2019年6月2日アクセス.

そのため、BotクラスにはTrello通知を送るためのメッセージ送信メソッド以外のメソッドも複数含まれております。
具体的には以下の通りです。

|メソッド名|内容|
|:-:|:-:|
|dict_to_base64str|辞書型文字列からBASE64Unicode文字列にエンコード（jwt_createメソッドで使用）|
|sign_rsa|RSA SHA-256アルゴリズムで暗号化し、BASE64エンコード（jwt_createメソッドで使用）|
|jwt_create|JWTの生成（上図：JWT作成及び電子署名）|
|get_server_token|サーバートークンの取得（上図：JWTを使ってLINE WORKS認証サーバーにToken要請）|
|call_server_api|サーバーAPIの呼び出し（上図：Tokenを使ったServer Api使用）|
|send_message|汎用メッセージ送信（content_dictにテキスト以外のタイプを格納）|
|send_text_message|テキストメッセージ送信（テキストメッセージをaccountIdまたはroomIdに送信）|

### Botクラスのコーディング
##### ①JWTの生成に必要なメソッド
上表に記載の通り、JWTの生成にあたり、
- BASE64 Unicode文字列へのエンコード
- RSA SHA-256アルゴリズムで暗号化したうえでBASE64エンコード
を行う必要があるため、そのメソッドをコーディングします。

```python:bot.py
class Bot(object):
    def dict_to_base64str(self, dict):
        # 辞書型文字列を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):
        global KEY_FILE_NAME

        # Server List（ID登録タイプ）で発行した認証キーのファイルを読み込み
        key = RSA.importKey(open(KEY_FILE_NAME).read())

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

        # ハッシュ値の計算
        digest = SHA256.new(message_byte)

        # 鍵を基にしてPKCSのインスタンス生成
        signer = PKCS1_v1_5.new(key)

        # 署名を作成（The signature encoded as a string.:bytes型のstring）
        signature = signer.sign(digest)

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

##### ②JWTの生成
①の準備が出来たら、JWTの生成メソッドをコーディングします。

```python:bot.py
    def jwt_create(self):
        global SERVER_ID

        jwt_header = {
            "alg": "RS256",
            "typ": "JWT"
        }
        jwt_header_base64 = self.dict_to_base64str(jwt_header)

        json_claim_set = {
            # Server List（ID登録タイプ）で発行したサーバーID
            "iss": SERVER_ID,
            # JWT生成日時 UNIX時間で指定（単位：sec）
            # "iat": int(datetime.datetime.now().strftime('%S')),　←うまくいかないので修正
            # timestampメソッドはfloat型で返すので、int型に直している
            "iat": int(datetime.datetime.now().timestamp()),
            # JWT満了日時 UNIX時間で指定（単位：sec）
            # "exp": int((datetime.datetime.now() + datetime.timedelta(minutes=30)).strftime('%S'))　←同上
            "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
```

##### ③サーバートークンの取得
JWT生成メソッドの準備が出来たら、次はサーバートークンを取得するメソッドです。

```python:bot.py
    def get_server_token(self):
        global API_ID

        api_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=api_url, headers=headers, params=payload)

        access_token = json.loads(res.text)["access_token"]
        print("API_TOKEN:" + access_token)

        return access_token
```

##### ④サーバーAPIの呼び出し
取得したサーバートークンを使ってサーバーAPIを呼び出すメソッドです。

```python:bot.py
        global API_ID
        global API_TOKEN
        global SERVER_API_CONSUMER_KEY

        # トークンを取得
        API_TOKEN = self.get_server_token()

        # LINE WORKSのAPIキー設定
        API_URL = "https://apis.worksmobile.com/{}/{}".format(API_ID, resource)

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

        r = requests.post(API_URL, headers=headers, data=json.dumps(payload))
        return r.text
```

##### ⑤メッセージ送信
LINE WORKSでは、テキスト以外にもテンプレート等を送ることができるため、後々のことを考えてコンテンツを入れ替えられるよう、二つのメソッドに分割しております。

```python:bot.py
    def send_message(self, botNo, accountId, roomId, content_dict):
        resource = "message/sendMessage/v2"
        payload = {
            "botNo": 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)

        res = json.loads(self.call_server_api(resource, payload))
        result = str(res["code"]) + " " + res["message"]
        print("send_message:" + result)
        return result

    def send_text_message(self, botNo, accountId, roomId, send_text):
        content_dict = {
            "content": {
                "type": "text",
                "text": send_text,
            }
        }
        return self.send_message(botNo, accountId, roomId, content_dict)
```

以上でトークBotのコーディングは終了です。

# 4. Trello API情報取得
こちらについては、Issei Komatsuさん（@isseium）の記事を参考にさせて頂きました。
[ここ](https://trello.com/app-key)をクリックするとAPI情報を取得できます。
> タイトル：Trello API を叩いてカードを作成する方法（curl利用）
> URL：https://qiita.com/isseium/items/8eebac5b79ff6ed1a180

# 5. コーディング②（Trello Webhook）
てぃるとさんの記事を参考にさせて頂きました。
> タイトル：Python3 + Flask + py-trello + slackerでTrelloの更新情報をslackに流す(1)
> URL：http://makemove.hatenablog.com/entry/2017/09/17/191507

Trelloのカードを更新したら、JSON形式のデータをコールバックURLに投げるWebhookを作ります。
なお、コールバックURL自体は、次のWebhookサーバのコーディングが終わり、ngrokで外部公開してから入力します。
初心者なのでここでハマったのですが、ファイアウォール内のローカルPCをコールバックURLに指定することはできないためです。

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

from trello import TrelloClient
from trello import Board

def createhook():
    # Trello API情報
    api_key = "4番で取得したAPI Key"
    token = "4番で取得したToken"
    api_secret = "4番で取得したAPI Secret"

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

    # コールバックURLの指定
    callback_url = "7番でローカルPCのWebサーバを外部公開してから入力"

    # ボードクラスのインスタンス生成
    board = Board(client=client, board_id="通知を飛ばしたいボードのID")

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

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

# 7番でローカルPCを外部公開し、コールバックURLを入力した後に実行、Webhook作成する
if __name__ == '__main__':
    createhook()
```

# 6. コーディング②（Webhookサーバ）
こちらも5番同様、てぃるとさんの記事を参考にさせて頂きました。

```python:webhook_server.py
# -*- coding: utf-8 -*-

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

from bot import Bot


# LINE WORKS(LW)必要情報
API_TOKEN = ""
BOT_NO = "2で登録したトークBotのBot No."
ACCOUNT_ID ="開発者のID（テスト用。トーク画面の個人情報から見れます）"


app = Flask(__name__)


# appの動作確認用
@app.route('/')
def index():
    return 'Hello World!'


# Webhook
@app.route('/webhook', methods=['GET', 'POST', 'HEAD'])
def webhook():

    # LWのメッセージ送信に必要な情報
    global API_TOKEN
    global ACCOUNT_ID
    global BOT_NO

    # webhookのメイン処理
    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 + "さんが「" + card_name + "」にコメントしました。\n" + "コメント：" + comment
            bot = Bot()
            bot.send_text_message(botNo=BOT_NO, accountId=ACCOUNT_ID, roomId="", send_text=message)
            return '', 200
        else:
            pass
        return '', 200

    # Trello webhook登録時にHEADで200を返す必要があるため用意する
    elif request.method == 'HEAD':
        return '', 200
    else:
        abort(400)

if __name__ == '__main__':
    app.run(port=5000, host='127.0.0.1')

```

# 7. ローカルPCのWebサーバをngrokで外部公開
@mininobuさんの記事を参考にさせて頂きました。
> タイトル：ngrokが便利すぎる
> URL：https://qiita.com/mininobu/items/b45dbc70faedf30f484e

ngrokコマンドを実行し、外部公開用のURLの払い出しが成功してコンソールに表示される画面内に、「Forwarding　　http://XXXXXXXX.ngrok.io -> localhost:8080」という表示があります。
このURLをcreate_hook.pyのコールバックURLに指定した後、同ファイルのcreate_hookメソッドを実行します。
Webhook作成に成功したら、コンソールに「Success!」の表示がされますので、Trelloの適当なカードでコメントに入力してみてください。
下図のように通知が来たら成功です！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373977/97357faa-8912-c61c-fe9f-974d2dfe7cd3.png)

# さいごに
初めての投稿且つプログラミング初心者が見様見真似で書いたので至らぬ点も多々あったかと思いますが、私と同じように「LINE WORKSでTrelloの通知を受け取りたい！」という方のお役に立てば幸いです。
長文にも関わらず、ご覧いただき誠にありがとうございました。