[Backlog][Typetalk][Lambda] 所属組織が違う Backlog と Typetalk を連携する

弊社では以前から [Backlog](https://backlog.com/ja/lp/) と [Typetalk](https://www.typetalk.com/ja/) で社内業務を管理してます。
参考: [創業時から15年間「徹底したリモートワーク」を追求する企業の取り組み、コミュニケーションの要は「Backlog」](https://internet.watch.impress.co.jp/docs/column/work_from/1315918.html)

とても便利なのですが[ヌーラボアカウント](https://support.nulab.com/hc/ja/)の所属組織が違う Backlog と Typetalk を連携することができません(2021年4月現在)。
昔は別組織の Backlog と Typetalk を連携できたのに、今は自組織の Backlog プロジェクトとしか連携できなくなってしまったんです。
自組織の Backlog は大体ブラウザで開いてますが、他組織の Backlog は開いてないことが多いので、他組織の Backlog とこそ連携してほしい。
（お客様の Backlog に入って進めているプロジェクトもあるので、結構他組織の Backlog プロジェクトも多いんです）

なければ作ればいいということで Backlog の Webhook と Typetalk のボットを使って、Backlog に課題立てられたりしたら、Typetalk に通知する仕組みを Lambda + API Gateway で実装しました。

# Backlog の Webhook を受け取って Typetalk のボットに渡す

この部分は Lambda で実装しました。
こんな感じです。特に追加の npm パッケージは使ってないので `handler.js` 一つでいいです。
参考: [Backlogから送信されるPOSTリクエストの内容〜Webhook〜](https://qiita.com/rekooom/items/455c1e8ec247e3cb8abb)

```javascript:handler.js
'use strict';

const https = require('https');

function unescapeHTML(str) {
    return '> ' + str.replace(/&lt;/g,'<')
              .replace(/&gt;/g,'>')
              .replace(/&amp;/g,'&')
              .replace(/[\n\r]+/g, "\n> ");
}

function is_object(mixedVar) {
    //  discuss at: https://locutus.io/php/is_object/
    // original by: Kevin van Zonneveld (https://kvz.io)
    // improved by: Legaev Andrey
    // improved by: Michael White (https://getsprink.com)
    if (Object.prototype.toString.call(mixedVar) === '[object Array]') {
        return false
    }
    return mixedVar !== null && typeof mixedVar === 'object'
}

async function sendRequest(opts,replyData){
    // https://ky-yk-d.hatenablog.com/entry/2018/07/16/011748
    return new Promise(((resolve,reject)=>{
        let req = https.request(opts, (response) => {
            response.setEncoding('utf8');
            let body = '';
            response.on('data', (chunk)=>{
                body += chunk;
            });
            response.on('end', ()=>{
                resolve(body);
            });
        }).on('error', (err)=>{
            reject(err);
        });
        req.write(replyData);
        req.end();
    }));
};

module.exports.helloTypetalk = async (event) => {
    const { queryStringParameters, body } = event;

    // event.body をパースする
    return new Promise( (resolve, reject) => {
        try {
            resolve({
                topicId: queryStringParameters.topicId,
                typetalkToken: queryStringParameters.typetalkToken,
                body: JSON.parse(body)
            });
        }
        catch (err) {
            reject(err);
        }
    })
    // Backlog から受け取った情報をいい感じに編集する
    // 参考: https://qiita.com/rekooom/items/455c1e8ec247e3cb8abb
    .then(data => {
        const { project, content, createdUser, notifications } = data.body;

        // 課題キー
        let keyId = content.key_id ? `${project.projectKey}-${content.key_id}` : project.projectKey;
        if (is_object(content.comment) && content.comment.id) {
            keyId += `#comment-${content.comment.id}`;
        }

        // 通知タイプ
        let notificationType = '';
        switch(data.body.type) {
            case (1): notificationType = '課題を追加'; break;
            case (2): notificationType = '課題を更新'; break;
            case (3): notificationType = '課題にコメント'; break;
            case (14): notificationType = '課題をまとめて更新'; break;
            case (17): notificationType = 'お知らせを追加'; break;
            case (5): notificationType = 'wiki を追加'; break;
            case (6): notificationType = 'wiki を更新'; break;
            case (7): notificationType = 'wiki を削除'; break;
            case (8): notificationType = 'ファイルを追加'; break;
            case (9): notificationType = 'ファイルを更新'; break;
            case (10): notificationType = 'ファイルを削除'; break;
            case (11): notificationType = 'Subversion にコミット'; break;
            case (12): notificationType = 'Git にプッシュ'; break;
            case (13): notificationType = 'Git リポジトリを作成'; break;
            case (15): notificationType = 'プロジェクトに参加'; break;
            case (16): notificationType = 'プロジェクトから脱退'; break;
            case (18): notificationType = 'プルリクエストを追加'; break;
            case (19): notificationType = 'プルリクエストを更新'; break;
            case (20): notificationType = 'プルリクエストにコメント'; break;
            case (22): notificationType = '発生バージョン/マイルストーンを追加'; break;
            case (23): notificationType = '発生バージョン/マイルストーンを更新'; break;
            case (24): notificationType = '発生バージョン/マイルストーンを削除'; break;
            default: notificationType = '何をしたかわからないけど、なにかを実行';
        }
        notificationType = encodeURIComponent(`${createdUser.name} さんが ${notificationType}しました。`);

        // タイトル
        let summary = content.summary ? content.summary : (content.name ? content.name : (content.repository ? content.repository.name : ''));
        let title = encodeURIComponent(`${keyId} ${summary}`);

        // メッセージ内容
        let message;
        if (content.comment) {
            message = is_object(content.comment) && content.comment.content ? content.comment.content : content.comment;
        } else {
            message = content.description ? content.description : (content.name ? content.name : '');
        }
        message = encodeURIComponent(unescapeHTML(message));

        // お知らせした人
        let notify = '';
        notifications.forEach(notification => {
            const { user } = notification;
            if (notify) { notify += ', '; }
            notify += user.name;
            if (user.nulabAccount) { notify += ` (@${user.nulabAccount.uniqueId})`; }
        });
        if (notify) {
            notify = `\n` + encodeURIComponent(`お知らせした人: ${notify}`);
        }

        data.postDataStr = `${notificationType}\n${title}\n\n${message}${notify}`;
        return Promise.resolve(data);
    })
    // Typetalk ボットを叩く
    .then(data => {
        const contentBody = `message=${data.postDataStr}`;
        const options = {
            host:  'typetalk.com',
            port:   443,
            path:   `/api/v1/topics/${data.topicId}`,
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': Buffer.byteLength(contentBody),
                'X-TYPETALK-TOKEN': data.typetalkToken
            }
        };
        return sendRequest(options, contentBody);
    })
    .then(body => Promise.resolve({
        'statusCode': 200,
        'body': JSON.stringify(body)
    }))
    .catch(err => {
        console.log(err);
        return Promise.reject({
            'statusCode': 500,
            'body': JSON.stringify(err)
        });
    });
};
```

# Serverless Framework で API Gateway を用意する

`serverless.yml` は、こんな感じで大丈夫です。
参考: [Serverless FrameworkでAPIGateway・Lambda・DynamoDBを構築する](https://qiita.com/t_okkan/items/546330b5f4da720c71a7)

```yml:serverless.yml
service: backlog-gateway

# You can pin your service to only deploy with a specific Serverless version
# Check out our docs for more details
frameworkVersion: '2'

provider:
  name: aws
  runtime: nodejs14.x
  lambdaHashingVersion: 20201221
  timeout: 300
  memorySize: 256

# you can overwrite defaults here
  stage: prd
  region: us-west-2

# you can add packaging information here
package:
  exclude:
    - package.json
    - package-lock.json
    - event.json

functions:
  helloTypetalk:
    handler: handler.helloTypetalk
    timeout: 30
    events:
      - httpApi:
          path: /typetalk/post
          method: post
```

これで `https://example.execute-api.us-west-2.amazonaws.com/typetalk/post` みたいな感じの Webhook 用 URL ができます。
やったね。
あとは Typetalk のトピック設定でボットを追加してあげて、Backlog のプロジェクト設定の「インテグレーション」で Webhook を追加すれば良いです。

Backlog に Webhook として登録するURLは `https://{API Gateway のドメイン}/typetalk/post?topicid={トピックID}&typetalktoken={Typetalk Token}` となります。
クエリストリングは以下の通りです。

* `{トピックID}`: Typetalk ボットの「メッセージの取得と投稿の URL」の `/topics/` 以降の数値
* `{Typetalk Token}`: Typetalk ボットの「Typetalk Token」

良い Typetalk を！