【LINE WORKS】Pythonでオウム返しBotを作る【API 2.0】


# はじめに
LINE WORKS APIの新しいバージョン「[API2.0](https://line.worksmobile.com/jp/pr/20220404/)」がリリースされ、従来のAPI (API1.0) は非推奨化および提供終了が予定されている ([参考](https://jp1-notice.worksmobile.com/view/#/detail-view/571?usess=1&serviceType=12&serviceLanguage=ja_JP))。

今回は、PythonによるAPI2.0に対応したBot実装例を、シンプルなオウム返しBotをベースにまとめる。

- [Node.js + Express編](https://qiita.com/mmclsntr/items/bbc4699978ba0c92b4f5) はこちら

# ソースコード
具体的な実装については以下を参照。[FastAPI](https://fastapi.tiangolo.com/) をサーバーとして使った。

https://github.com/mmclsntr/lw-api-2_0-echo-bot-python-fastapi 

以下、実装内容について一部抜粋して解説する。

# 実装解説
- 環境: Python 3.9

## 改ざんチェック
LINE WORKS APIのBotには、Callbackで受け取ったRequest Eventの改ざんチェックを行う機能がある。

参考 : https://developers.worksmobile.com/jp/reference/bot-callback?lang=ja 

Request headerの `X-WORKS-Signature` によって渡される署名と、Developer ConsoleのBot画面で発行されている `Bot Secret` を用いて、Request bodyの改ざんチェックを行う。

以下、コード例。

```python
import hashlib
import hmac
from base64 import b64encode, b64decode

def validate_request(body: bytes, signature: str, bot_secret: str) -> bool:
    """Validate request

    :param body: request body
    :param signature: value of X-WORKS-Signature header
    :param bot_secret: Bot Secret
    :return: is valid
    """
    secretKey = bot_secret.encode()
    payload = body

    # Encode by HMAC-SHA256 algorithm
    encoded_body = hmac.new(secretKey, payload, hashlib.sha256).digest()
    # BASE64 encode
    encoded_b64_body = b64encode(encoded_body).decode()

    # Compare
    return encoded_b64_body == signature
```

## Access Token取得
トークに返答するために、まずはAccess Tokenを取得する。

取得の仕方としては、用意されている認証方法のうち「Service Account認証」というJWTを使った認可の仕組みを使ってAccess Tokenを取得する。

参考 : https://developers.worksmobile.com/jp/reference/authorization-sa?lang=ja 

Access Token取得までの流れは、

1. (事前準備) Developer ConsoleからAppを作成し、以下の各種認証情報を設定・取得する。
    - Client ID
    - Client Secret
    - Service Account
    - Private Key
    - OAuth Scopeの設定
2. JWTの生成。以下の情報を利用。
    - Client ID
    - Service Account
    - Private Key
3. Access Tokenを取得する。以下の情報を利用
    - 生成したJWT
    - Client ID
    - Client Secret
    - 必要なScope
        - 今回はBotへの返答を行うのみであるため `bot` scopeを指定する。

以下、コード例。

```python
import jwt
from datetime import datetime
import urllib

import json
import requests

BASE_AUTH_URL = "https://auth.worksmobile.com/oauth2/v2.0"


def __get_jwt(client_id: str, service_account: str, privatekey: str) -> str:
    """Generate JWT for access token

    :param client_id: Client ID
    :param service_account: Service Account
    :param privatekey: Private Key
    :return: JWT
    """
    current_time = datetime.now().timestamp()
    iss = client_id
    sub = service_account
    iat = current_time
    exp = current_time + (60 * 60) # 1 hour

    jws = jwt.encode(
        {
            "iss": iss,
            "sub": sub,
            "iat": iat,
            "exp": exp
        }, privatekey, algorithm="RS256")

    return jws


def get_access_token(client_id: str, client_secret: str, service_account: str, privatekey: str, scope: str) -> dict:
    """Get Access Token

    :param client_id: Client ID
    :param client_secret: Client ID
    :param service_account: Service Account
    :param privatekey: Private Key
    :param scope: OAuth Scope
    :return: response
    """
    # Get JWT
    jwt = __get_jwt(client_id, service_account, privatekey)

    # Get Access Token
    url = '{}/token'.format(BASE_AUTH_URL)

    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }

    params = {
        "assertion": jwt,
        "grant_type": urllib.parse.quote("urn:ietf:params:oauth:grant-type:jwt-bearer"),
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": scope,
    }

    form_data = params

    r = requests.post(url=url, data=form_data, headers=headers)

    body = json.loads(r.text)

    return body
```

## 返答
トークに返答をする。返答には「メッセージ送信」のAPIを使う。

https://developers.worksmobile.com/jp/reference/bot-user-message-send?lang=ja

送信先ユーザーは、Request bodyの `source.userId` を指定する。
返答内容は、送られてきたテキストをそのまま返すため、以下のようになる。

```json
{
    "content": {
        "type": "text",
        "text": "{Reply text}"
    }
}
```

参考 : https://developers.worksmobile.com/jp/reference/bot-send-text?lang=ja

API呼び出しの際は、先に取得したAccess Tokenを `Authorization` headerに指定する。

```text
Authorization: Bearer {{Access Token}}
```

メッセージ送信のコード例は以下の通り。

```python
import json
import requests

BASE_API_URL = "https://www.worksapis.com/v1.0"


def send_message_to_user(content: dict, bot_id: str, user_id: str, access_token: str):
    """Send message to a user

    :param content: Message content
    :param bot_id: Bot ID
    :param user_id: User ID
    :param access_token: Access Token
    """
    url = "{}/bots/{}/users/{}/messages".format(BASE_API_URL, bot_id, user_id)

    headers = {
          'Content-Type' : 'application/json',
          'Authorization' : "Bearer {}".format(access_token)
        }

    params = content
    form_data = json.dumps(params)

    r = requests.post(url=url, data=form_data, headers=headers)

    r.raise_for_status()
```

また、APIにはRate Limitが設けられており、制限を超えた際は429のエラーが返る。その際は時間を置いて再送する必要がある。それを考慮した再送処理を実装する。

https://developers.worksmobile.com/jp/reference/rate-limits?lang=ja 

加えて、Access Tokenが期限切れの場合を考慮し、Tokenの再発行の処理も追加する。

以下、実装例。

```python:main.py
for i in range(RETRY_COUNT_MAX):
    try:
        # Reply message
        res = lineworks.send_message_to_user(res_content,
                                             bot_id,
                                             user_id,
                                             global_data["access_token"])
    except RequestException as e:
        body = e.response.json()
        status_code = e.response.status_code
        if status_code == 401:
            if body["code"] == "UNAUTHORIZED":
                # Access Token has been expired.
                # Update Access Token
                logger.info("Update access token")
                res = lineworks.get_access_token(client_id,
                                                 client_secret,
                                                 service_account_id,
                                                 privatekey,
                                                 SCOPE)
                global_data["access_token"] = res["access_token"]
            else:
                logger.exception(e)
                break
        elif status_code == 429:
            # Requests over rate limit.
            logger.info("Over rate limit")
            logger.info(body)
        else:
            logger.exception(e)
            break

        # wait and retry
        time.sleep(2 ** i)
    else:
        break

```

# まとめ
API2.0対応のLINE WORKS Botの実装例をまとめた。
API 2.0に対応した実装例ではあるが、改ざんチェックやAccess Token取得の部分など、API1.0のBotのコードから流用できたものが多い。

もしこれらサンプルに不具合がある場合は、この記事のコメントもしくはGithubリポジトリのIssuesでお知らせください。