Backlog通知をサーバレス構成でslackに飛ばす

# Backlogで公式に機能追加されました（２０１９年２月２１日追記）
２０１９年２月２１日のBacklogブログにて、[SlackとBacklogの連携機能](https://backlog.com/ja/blog/backlog-with-typetalk-slack-chatwork/)が発表されました！
ということで、この記事のLambdaを使った方法でなくとも、公式機能を使ってポチポチッと通知の設定が行なえますね！👍
Backlogの使い方ページは[こちら](https://backlog.com/ja/help/adminsguide/slack-integration/userguide5409/)です。

この記事は、備忘録としてそのままにしておきます。

# 概要
最近、業務上の色んな通知がメールからSlackに集約されてきている中で、バックログ通知がまだメールのままだったので、Slackに集約してしまおう！と思い立ってみました。
そして、せっかくならAPI Gateway＋Lambdaを使用したサーバレス構成でやってみよう！と考え、以下のような流れで通知が行えるようにしてみました。

![2017-05-04_145514.png](https://qiita-image-store.s3.amazonaws.com/0/128331/a050ed9a-2e8c-661f-db34-4a41fb01a3d3.png)

対象のバックログプロジェクトのWebhookにAPI GatewayのURLを登録すると、課題の登録・更新のタイミングで、指定したSlackチャンネルに通知を行います。

# つくってみる
実際に行った内容を順番にまとめていきます。
(試行錯誤しながらつくっていったので、手順が前後しているところがあるかもしれないです。また、コード書くの久々なので変なとこがあったらすいません。。)

## slack側の準備
まずはじめに、slackで通知するbotを用意します。
slackのApp Directoryにて「incomming webhooks」を検索し、作成。
設定内容は以下の通り。

Post to Channel: `#generalなどの消えないチャンネルを設定`
Customize Name: `「backlog」などbot名を設定`
Customize Icon: `バックログからの通知だ！とわかるアイコン画像を設定`

この時、Webhook URL( https://hooks.slack.com/services/XXXX/XXXX/XXXX )が発行されるので、メモっておきます。

## AWS上の準備

### Lambda用のIAM Roleの作成
IAM Roleを作成します。
[こちらの記事](http://qiita.com/u-minor/items/57e68dd183925b3e6897#aws-lambda-%E7%94%A8%E3%81%AE-iam-role-%E3%81%AE%E4%BD%9C%E6%88%90)を参考に、lambda-backlog-slackというRoleを作成しました。

ロール名: `lambda-backlog-slack`
アタッチするポリシー: `AWSLambdaExecute`

### スクリプトの作成
Backlog Webhookのjsonデータを整形し、SlackへPOSTするスクリプトを作成します。Node.jsで作ってみます。
ローカル環境で「lambda」フォルダを作成し、必要なモジュールを準備します。
lambdaフォルダ内で以下を実行すると、node_modulesフォルダ配下にモジュールが置かれます。

```$ npm install slack-node```

そしてlambdaフォルダ直下にindex.jsを作成します。
このjsでは、通知先のチャンネル名をパラメータで受け取り、jsonを整形し、Slackに投稿します。

※設置環境によって変わる値はLambdaの環境変数に外出しします。
※通知する内容が長い場合は後ろをカットしています。
※一部の通知イベント(削除系,ファイル系,プロジェクトに関するイベントなど)には対応していません。必要に応じてswitch文を追加してください。

```index.js
'use strict';

//requires
const Slack = require('slack-node');
const slack = new Slack();

// settings (lambda環境変数)
var BOTNAME = process.env['BOTNAME'];
var BASEURL = process.env['BASEURL'];
var WEBHOOKURI = process.env['WEBHOOKURI'];

slack.setWebhook(WEBHOOKURI);

// Format chat message
function makeChatMessage(body) {
  var msgObj = new Object();
  var label = "";
  var bl_key = "";
  var bl_summary = "";
  var bl_comment = "";
  var bl_url = "";

  switch (body.type) {
    case 1:
      label = "追加";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id;
      bl_comment = body.content.description;
      break;
    case 2:
      label = "更新";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id;
      bl_comment = body.content.description;
      break;
    case 3:
      label = "コメント";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id+"#comment-"+body.content.comment.id;
      bl_comment = body.content.comment.content;
      break;
    case 14:
      label = "課題まとめて更新";
      bl_key = "";
      bl_summary = "";
      bl_url = BASEURL+"projects/"+body.project.projectKey;
      bl_comment = body.createdUser.name+"さんが課題をまとめて操作しました。";
      break;
    case 5:
      label = "Wiki追加";
      bl_key = "";
      bl_summary = "「"+body.content.name+"」";
      bl_url = BASEURL+"alias/wiki/"+body.content.id;
      bl_comment = body.createdUser.name+"さんがWikiページを追加しました。";
      break;
    case 6:
      label = "Wiki更新";
      bl_key = "";
      bl_summary = "「"+body.content.name+"」";
      bl_url = BASEURL+"alias/wiki/"+body.content.id;
      bl_comment = body.createdUser.name+"さんがWikiページを更新しました。";
      break;
    case 11:
      label = "SVNコミット";
      bl_key = "[r"+body.content.rev+"]";
      bl_summary = "";
      bl_url = BASEURL+"rev/"+body.project.projectKey+"/"+body.content.rev;
      bl_comment = body.content.comment;
      break;
    case 12:
      label = "Gitプッシュ";
      var git_rev = body.content.revisions[0].rev;
      git_rev = git_rev.substr(0,10);
      bl_key = "["+git_rev+"]";
      bl_summary = "";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/"+body.content.revision_type+"/"+body.content.revisions[0].rev;
      bl_comment = body.content.revisions[0].comment;
      break;
    case 18:
      label = "プルリクエスト追加";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "「"+body.content.summary+"」";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number;
      bl_comment = body.content.description;
      break;
    case 19:
      label = "プルリクエスト更新";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "「"+body.content.summary+"」";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number;
      bl_comment = body.content.description;
      break;
    case 20:
      label = "プルリクエストコメント";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number+"#comment-"+body.content.comment.id;
      bl_comment = body.content.comment.content;
      break;

    default:
      return;
  }
  console.log(label);

  if(label){
    msgObj['message'] = bl_key+" "
    + label
    + bl_summary
    + " by " + body.createdUser.name
    + "\n "+bl_url;
    // 長いコメントは後ろカット
    if(bl_comment.length > 200){
      bl_comment = bl_comment.substr(0,200)+"...";
    }
    msgObj['comment'] = bl_comment;
  }
  return msgObj;
}

// POST Slack
function postSlack(channel,message,comment){
  // 引用コメント部分整形
  var attachments_opts = "";
  if(comment){
    attachments_opts = {
      "color": "#42ce9f",
      "fields": [
        {
          "value":comment, 
          "short": false
        }
      ]
    };
  }

  return new Promise(function(resolve,reject) {
    slack.webhook({
      channel: channel,
      username: BOTNAME,
      text: message,
      attachments: [ attachments_opts ]
    }, function(err, response) {
      if (err) {
        console.log(response);
        reject(new Error('Error'));
        return;
      } else {
        resolve(response);
      }
    });
  });

}

// Main Handler
exports.handler = function (event, context, callback) {
  var room;
  var body;
  var slackObj = new Object();

  console.log('event: ' + JSON.stringify(event, null, 4));

  if(event.room && event.requestParameters){
    // 通知先チャンネル取得
    console.log('room='+event.room);
    room = event.room;
    // json整形・メッセージ作成
    body = event.requestParameters;
    slackObj = makeChatMessage(body);

    // Slack投稿
    if(slackObj){
      postSlack(room, slackObj['message'], slackObj['comment']);
    }
  }else{
    console.log('対象チャンネルが指定されていないか、データが取得できません。');
  }

  callback(null, 'Done.');

};
```

### Lambda関数の作成
ここまで用意出来たら、AWS上でLamda関数を作っていきます。
AWSサービス画面にてLambdaを探し、アクセスします。リージョンは東京(ap-northeast-1)で。
※AWSの言語設定を日本語にしてるので、ラベル名などは日本語表記で記載しますね。。

「Lambda関数の作成」に入り、「設計図の選択」で「ブランク関数」を選択します。
「トリガーの設定」では何も設定せず、次の「関数の設定」に進みます。
「関数の設定」では以下のように設定します。

名前: `backlog2slack`
説明: `backlog to slack`
ランタイム: `Node.js 6.10`

「Lambda 関数のコード」にて「.zipファイルをアップロード」を選びます。
ローカル環境にて、index.jsとnode_modulesフォルダを選択した状態でzip化し、アップロードします。
(※zip化する際の階層に注意してください)

続いて環境変数を3つ、設定していきます。

WEBHOOKURI: `https://hooks.slack.com/services/...`　※slack側の準備時に発行されたWebhook URL
BASEURL: `https://xxx.backlog.jp/`　※xxx部分は自身の環境に合わせて変更
BOTNAME: `backlog`　※slack発言時の名前

※本サンプルは単一のバックログスペース(環境変数BASEURLに設定したスペース)にのみ対応します。複数のスペースに対応したい場合は拡張してみてください。

「Lambda 関数ハンドラおよびロール」は以下のように設定。

ハンドラ: `index.handler`
ロール: 既存のロールを選択→`lambda-backlog-slack`

それ以外の設定はデフォルトのままにし、関数を作成します。

### API Gatewayの設定

続いてAPI Gatewayの設定を進めます。
API GatewayでBacklogのWebhookに通知先として登録するURLを作成し、実行時に先程作ったLambda関数が叩かれるように設定します。

AWSサービス画面にてAPI Gatewayを探し、アクセスします。こちらもリージョンは東京(ap-northeast-1)で。
APIを以下の内容で新規作成します。

API名: `Backlog2SlackAPI`
説明: `Backlog to SlackAPI`

次に、リソースとメソッドの作成を進めていきます。

#### リソースの作成
「/」配下に「/backlog2slack」を作成します。
アクションから「リソースの作成」を選択。

リソース名: `backlog2slack`　※リソースパスに自動で同じものが入力されます。

更にその下に「/{room}」を作成します。

リソース名: `{room}`　※{なんちゃら}で、パラメータ扱いとなります。
リソースパス: `{room}`　※自動で入力された値を書き換えます。

#### メソッドの作成
「/{room}」配下にPOSTメソッドを作成します。
アクションから「メソッドの作成」を選択。
「/{room}」の配下にプルダウンがでますので、POSTを選択。

そのままPOSTメソッドのセットアップに進みます。

統合タイプ: `Lambda 関数`
Lambda リージョン: `ap-northeast-1`
Lambda 関数: `backlog2slack`

保存時に「Lambda 関数に権限を追加する」ダイアログが出ますので、OKを押します。

最終的なリソースのツリーは以下のようになります。

![2017-05-04_133949.png](https://qiita-image-store.s3.amazonaws.com/0/128331/088e2c11-56d3-ebe0-1891-f4e85dad5d51.png)


#### パラメータ設定
slack発言チャンネル用パラメータ「room」の設定(確認)を行います。
リソースの、 /backlog2slack/{room} - POST の「メソッドリクエスト」にて、リクエストパスにroomが追加されていることを確認します。

#### マッピングテンプレート設定
リソースの、 /backlog2slack/{room} - POST の「統合リクエスト」にて、「本文マッピングテンプレート」の設定を行います。
「本文マッピングテンプレート」の「リクエスト本文のパススルー」で「テンプレートが定義されていない場合 (推奨)」を選択しつつ、マッピングテンプレート「application/json」を追加します。

内容は[こちらの記事](http://qiita.com/r7kamura/items/6420538789da95cd2f47#%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%82%92%E7%B7%A8%E9%9B%86%E3%81%99%E3%82%8B)にあるコードに加え、以下を追加しました。

`"room": "$input.params('room')"`

#### APIのデプロイ
APIをデプロイして、URLの形で叩けるようにします。
リソースのアクションから「APIのデプロイ」を選択。

デプロイされるステージ: `[新しいステージ]`
ステージ名: `xxx` ※ひとまず、バックログのスペース名にしました。

デプロイされた後、API Gateway左メニューの「ステージ」をクリック、デプロイされたツリーを開いていきます。
最下層のPOSTメソッドを開くと、画面右側の「URL の呼び出し」で、呼び出しURLが表示されるのでメモっておきます。

https://XXXX.execute-api.ap-northeast-1.amazonaws.com/ステージ名/backlog2slack/{room}


## Backlog側の準備
最後にバックログのWebhook URL設定を行います。
なお、バックログのWebhook設定には、対象のバックログプロジェクトの管理権限が必要です。

対象のバックログプロジェクトに入り、「プロジェクト設定」＞Webhookと辿っていき、以下内容でWebhookを追加します。

![2017-05-04_141219.png](https://qiita-image-store.s3.amazonaws.com/0/128331/e99f6df3-4395-9fca-6d05-152c09326ca7.png)

Webhook名: `Backlog2Lambda`　※お好みの内容で
説明: `Backlog2Lambda`　※お好みの内容で
WebHook URL: `https://(略)/ステージ名/backlog2slack/{Slackチャンネル名}`
通知するイベント: `すべて`　※Lambda側で通知するイベントを制御してるので、ここではすべてに。

Webhook URLですが、randomチャンネルに通知したい場合は以下のように設定します。
https://XXXX.execute-api.ap-northeast-1.amazonaws.com/ステージ名/backlog2slack/random


# 動かしてみる
では、実際に動かしてみます。
バックログのWebhook URLを設定した画面の下の方で、実行テストが行なえるので試してみます。

![2017-05-04_141248.png](https://qiita-image-store.s3.amazonaws.com/0/128331/8e6890a0-7f8e-9fd6-9de3-0981016f69a2.png)

こんな感じで通知がやってきます。

![2017-05-04_141458.png](https://qiita-image-store.s3.amazonaws.com/0/128331/3fe05863-d996-b03e-193f-b39ed87f652f.png)

また、「送信履歴」タブでは実際に送信されたjson内容が確認できます。
バックログのリファレンスに全パターンのjson内容の説明がないので、作成時、実行テスト→送信履歴で内容確認を繰り返してましたｗ

![2017-05-04_141733.png](https://qiita-image-store.s3.amazonaws.com/0/128331/202dda54-b423-4bda-e0b8-a60e94822c21.png)

また、AWSのCloudWatch画面ではLambdaの実行ログが確認できます。
CloudWatch画面の「ログ」から「/aws/lambda/backlog2slack」のストリームを参照してみてください。index.jsの中でconsole.logとして出力している内容がログの中で確認できます。


# おわりに
導入してみて気づいたのですが、バックログ通知＝slackの発言になるので、その発言に対してリアクションしたり、スレッドを起こしてコメントできるのが地味に便利だなと思いました。
例えば、「○○さん、これ見てもらえる？」といった分担決めをslackのスレッド機能でササッと済ませるなど。

こうやって、便利に使えるものはどんどん取り入れていきたいですね～。