LoginSignup
1
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-04-07

弊社では以前から BacklogTypetalk で社内業務を管理してます。
参考: 創業時から15年間「徹底したリモートワーク」を追求する企業の取り組み、コミュニケーションの要は「Backlog」

とても便利なのですがヌーラボアカウントの所属組織が違う 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〜

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を構築する

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 を!

1
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
1
1