LINE WORKS で GitHub の Webhook 通知を受け取る

この記事は [LINE WORKS Advent Calender 2022](https://qiita.com/advent-calendar/2022/lineworks) 20 日目の記事です。

## LINE WORKS で GitHub の通知を受け取る

GitHub で対象のリポジトリにプッシュされた際の Webhook による通知を、
LINE WORKS のトークルームに送信するための Bot を開発してみます🙌

## LINE WORKS Bot 開発

今回の技術スタックをさらっとご紹介です💁‍♂️🌟

- Node.js
- AWS API Gateway
- AWS Lambda
- AWS CDK

これにプラスで GitHub の Webhook の知識が（少し）あれば OK です◎

なお、今回は LINE WORKS のアドベントカレンダーなので、
LINE WORKS の Bot 開発に関する部分の解説に焦点を当てたいと思います。

### Bot 開発の準備

API 2.0 を使用するために、アプリの新規追加をおこないます。

[LINE WORKS Developer Console](https://developers.worksmobile.com/jp/console/openapi/v2/app/list/view) > API 2.0 の「アプリの新規追加」からアプリを追加します。

追加の流れに沿って、**Service Account** と **Private Key** の発行までおこないます。

また、OAuth Scopes では **bot** を選択します。

![lineworks_アプリ追加.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641252/76de3436-4c91-68b6-5d3a-6ad5ec6a4ac3.png)

今回は Bot 登録も画面からおこないます。基本的に必須項目を入力すれば OK です！

対話型の Bot ではないので、Callback URL は Off のままとします。

また、複数の開発メンバーと Bot のトークルームを作成できるように、「複数人のトークルームに招待可」を有効にしています。

1:1 のトークで良い場合は無効のままで良いかと思いますので、ここはお好みでどうぞ😉

:::note info
Bot IDは登録が完了すると発行されます。
:::

![lineworks_bot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641252/ab317002-82f5-9cc3-71ce-7b80a0fe558a.png)

### Bot ソースコード

ソースコードは GitHub で公開しています。要チェックです🫰

https://github.com/codemountains/lw-github-bot

#### Lambda 関数

メインどころのコードを解説していきたいと思います！

```typescript:GithubWebhookFunc.ts
import {APIGatewayProxyEvent, APIGatewayProxyHandler} from "aws-lambda";
import SecretType from "./types/Secret.type";
import * as jwt from "jsonwebtoken";
import axios from "axios";
import UserAccountAuthType from "./types/AccessToken.type";

// @ts-ignore
const handler: APIGatewayProxyHandler = async (event: APIGatewayProxyEvent) => {
    console.info(event);

    const githubEvent = event.headers["X-GitHub-Event"];
    if (githubEvent !== "ping" && githubEvent !== "push") {
        return  {
            statusCode: 400,
        };
    }

    // '204 No Content' returned on event when setting up Webhook.
    if (githubEvent === "ping") {
        return  {
            statusCode: 204,
        };
    }

    const query = event.queryStringParameters;
    if (query === null) {
        return  {
            statusCode: 400,
        };
    }
    const channelId = query.channelId;
    if (channelId === undefined) {
        return  {
            statusCode: 400,
        };
    }

    const requestBody = event.body;
    console.info(requestBody);

    if (requestBody === null) {
        return  {
            statusCode: 400,
        };
    }

    const webhookBody: {
        repository: {
            name: string;
        };
        pusher: {
            name: string;
        };
        compare: string;
    } = JSON.parse(requestBody);

    const secret = init();
    const accessToken = await genAccessToken(secret);

    const text = `${webhookBody.pusher.name} committed in ${webhookBody.repository.name} repository.`;
    await sendMessageWithActions(accessToken, secret.lineWorksBotId, channelId, text, webhookBody.compare);

    return {
        statusCode: 201,
    };
};

const init = (): SecretType => {
    return {
        lineWorksBotId: process.env.LINE_WORKS_BOT_ID ?? "",
        lineWorksClientId: process.env.LINE_WORKS_CLIENT_ID ?? "",
        lineWorksClientSecret: process.env.LINE_WORKS_CLIENT_SECRET ?? "",
        lineWorksDomainId: process.env.LINE_WORKS_DOMAIN_ID ?? "",
        lineWorksPrivateKey: process.env.LINE_WORKS_PRIVATE_KEY ?? "",
        lineWorksServiceAccount: process.env.LINE_WORKS_SERVICE_ACCOUNT ?? ""
    }
}

/**
 * Generate JWT
 * @param secret {SecretType} Secret in environment variables
 */
const genJwt = (secret: SecretType): string => {
    const payload = {
        iss: secret.lineWorksClientId,
        sub: secret.lineWorksServiceAccount,
        iat: Date.now(),
        exp: Date.now() + 3600,
    };
    const privateKey = secret.lineWorksPrivateKey.replace(/\\n/g, '\n');

    return jwt.sign(payload, privateKey, {algorithm: "RS256"});
}

/**
 * Generate Access token
 * @param secret {SecretType} Secret in environment variables
 */
const genAccessToken = async (secret: SecretType): Promise<string> => {
    const jwt = genJwt(secret);

    const params = new URLSearchParams({
        assertion: jwt,
        grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
        client_id: secret.lineWorksClientId,
        client_secret: secret.lineWorksClientSecret,
        scope: "bot",
    });

    const response = await axios.post("https://auth.worksmobile.com/oauth2/v2.0/token", params);
    const auth = response.data as UserAccountAuthType;

    return auth.access_token;
}

/**
 * Send a message with action to the talk room
 * @param accessToken {string} access token
 * @param botId {string} bot id
 * @param channelId {string} channel id
 * @param text {string} text message
 * @param uri {string} GitHub comparing changes URL
 */
const sendMessageWithActions = async (
    accessToken: string,
    botId: string,
    channelId: string,
    text: string,
    uri :string,
): Promise<void> => {
    try {
        const headers = {
            Authorization: `Bearer ${accessToken}`
        }

        const url = `https://www.worksapis.com/v1.0/bots/${botId}/channels/${channelId}/messages`;
        const response = await axios.post(url, {
            content: {
                type: "button_template",
                contentText: text,
                actions: [{
                    type: "uri",
                    label: "Comparing changes",
                    uri: uri
                }]
            }
        }, { headers });
        console.info(response);
    } catch(error) {
        console.error(error);
    }
}

export { handler };
```

**User Account認証 (OAuth)** に関する処理で、アクセストークンを発行しているのは `genAccessToken` 関数です。

上記関数内で `genJwt` 関数を呼び出して、JWT の生成をしています。

そして、Botによるトークルームへのメッセージ送信は `sendMessageWithActions` 関数でおこなっています。

今回はユーザーではなくトークルームにメッセージを送信するため、
`https://www.worksapis.com/v1.0/bots/${botId}/channels/${channelId}/messages` に対して POST でリクエストを送信します。

`channelId` は URL クエリパラメータで受け取るようにしています。

メッセージには GitHub の Comparing changes 画面に遷移するボタンをつけたいので、
`button_template` をタイプのメッセージを利用します。

また、ハードコーディングを避けたい認証情報などは **Lambda の環境変数** に定義しておき、`init` 関数で取得しています。

:::note info
認証情報は環境変数に定義するより [AWS Secrets Manager](https://aws.amazon.com/jp/secrets-manager) を利用する方が望ましいようです。
今回は楽な実装方法ということで環境変数に定義しています🙇
:::

GitHub の Webhook 通知については、プッシュ時に送られてくる以下の情報をメッセージとして送るために利用しています。

- repository.name: 対象のリポジトリ名
- pusher.name: プッシュしたユーザー名
- compare: Comparing changes 画面の URL

### デプロイと環境変数の設定

AWS CDK コマンドでデプロイをおこない、デプロイされた Lambda 関数の環境変数を設定します。

```
npm i
cdk synth --quiet
cdk deploy
```
デプロイが完了したら、Lambda のコンソール画面から以下の認証情報を環境変数に設定します。

すべて [LINE WORKS Developer Console](https://developers.worksmobile.com/jp/console/openapi/v2/app/list/view) で確認・コピーできます。

- LINE_WORKS_BOT_ID
    - Bot ID
- LINE_WORKS_CLIENT_ID
    - Client ID
- LINE_WORKS_CLIENT_SECRET
    - Client Secret
- LINE_WORKS_DOMAIN_ID
    - Domain ID
- LINE_WORKS_PRIVATE_KEY
    - Private Key
- LINE_WORKS_SERVICE_ACCOUNT
    - Service Account

:::note warn
Private Key については、JWT 生成時にエラーが発生したため、`\n`を追加し置換処理を入れました。
例) `-----BEGIN PRIVATE KEY-----\nABC...XYZ\n-----END PRIVATE KEY-----`
:::

## Github Webhooks の設定

### トークルームの作成

チャンネルID（トークルームID）を先に作っておく必要があるため、トークルームを作成します。

チャンネルIDをコピーして控えておきます。

### GitHub Settings

GitHub で通知を受け取りたいリポジトリの Settings > Webhoooks で、Payload URL に API Gateway の URL を設定します。

クエリパラメータ `channelId` を忘れないようにしましょう。

```
https://{example}.execute-api.ap-northeast-1.amazonaws.com/prod/github-webhooks?channelId={チャンネルID}
```

![github_webhooks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641252/d9609a84-6186-17d0-9568-c7dae266b5c4.png)

また、その他の設定は以下のように変更します。

- Content tyep: application/json
- Which events would you like to trigger this webhook?: Just the push event

## 通知を受け取ってみる

GitHub の画面上で直接ファイルを編集してコミット・プッシュして、通知が届くか確認してみます！

![lineworks_github.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641252/c3c4f3d2-78ee-3b18-692c-b7bcdd605a23.gif)

無事、プッシュ時の通知を受け取ることができましたー🍻🥂🍾

## さいごに

他のサービスと連携できる Bot を何か作れないかなと思い、GitHub の Webhook 通知を受け取ってみました。

この記事では GitHub ですが、Webhook 機能が提供されているサービスであれば、同じような仕組みで連携ができそうですよね！

他のサービス連携しまくって、より便利な LINE WORKS にしちゃっていましょう🥳

以上です🫰✨