7839

雑草魂エンジニアブログ

【GAS】SlackとTrelloを連携するBotを導入した話(メンション機能を有効化する)

「ふせん」のような感覚で使えるタスク管理ツール「Trello(トレロ)」。視覚的に見やすく、様々なプラグインも豊富で、非常に使いやすい。

私の会社でも、タスク管理にTrelloを用いている。また、ビジネスコラボレーションハブの「Slack」をメインツールとしている。SlackとTrelloを連携するには、Trelloの公式アプリを用いて簡単に実現することができる。

slack.com

しかしながら、ここで問題が発覚した。

slackとTrelloのユーザー名が異なり、メンションされない。。。

Slackに通知する場合、@メンションがされていないと、情報が流れてしまい、見逃してしまうという問題が発生する。

また、公式アプリは(通知するだけなのでこの機能で十分ではあるが、)英語表記で何か味気ないw(この部分に関して、こだわるべきか否かは賛否両論あると思うが、個人的にはこだわりたいタイプであるw)

そこで、今回Google Apps Script(GAS)を用いてSlackに通知するBotを作成することとした。(今回は、公式アプリのように、Slackからカードの操作などを想定しておらず、通知のみで使うことを想定している。)なぜGASを選択したかというと、何と言っても無料で運用できることに尽きる。。。

システム概要

今回の構成は以下の通りである。

f:id:serip39:20200606214337j:plain

TrelloのWebhookで様々な更新情報を取得することができるが、実際には以下の項目のみをSlackに通知することとした。

  • メンション付きのコメントがあった場合のみ、コメントを通知する
  • カードの担当が割り振られた場合に、割り当てられたことを通知する
  • Doingに移動した場合に、着手を通知する
  • Reviewで割り当てられた人に、レビュー依頼を通知する
  • Doneに移動した場合に、完了を通知する

実装に関しては、以下の手順で行った。

  1. SlackのIncoming Webhookを作成する
  2. GASのウェブアプリケーションの公開URLの準備(TrelloのCallback URLに設定するために必要)
  3. TrelloのWebhookの登録
  4. GASにSlackに通知するためのコードを実装

SlackのIncoming Webhookを作成する

Incoming Webhookは、生成されたIncoming Webhook URL に適切なpayloadをPOSTするだけで、任意のChannelにメッセージを送信することができる、とても便利なツールである。

作成手順は以下の通りである。(公式ドキュメントを参照してください。)

slack.com

GASのウェブアプリケーションの公開URLの準備

続いて、TrelloのWebhookを設定する前に、TrelloのCallback URLを生成するために、先にGASの準備から行う。

今回は、ユーザーの変更があった際に、コードを変更するのではなく、スプレッドシートのユーザー一覧を変更することで、システムに反映させることができる設計とした。

  1. Googleにログインして、新規スプレッドシートを作成する
  2. ツール > スクリプトエディタ から、GASを起動する
  3. GAS起動時は、「無題のプロジェクト」という名前になっているので、適切な名前をつける(今回のプロジェクトは、「Slack&Trello連携Bot」とする。)
  4. 公開 > ウェブアプリケーションとして導入...を選択する
  5. 以下の設定として、「導入」を選択する

    項目 設定
    プロジェクトバージョン New
    次のユーザーとしてアプリケーションを実行 自分
    アプリケーションにアクセスできるユーザー 全員
  6. 現在のウェブアプリケーションのURLが表示されるので、コピーしておく

(※注意)
アプリケーションにアクセスできるユーザーを必ず「全員」とする。そうでないと、次のTrelloのWebhook登録時に403が返ってきて登録することができない。

下記を参考にした。本当に感謝。(このエラーにはかなり時間食ったw) Trello Webhook × GoogleAppsScript の連携してカンバンの分析を楽にして見る - Qiita

TrelloのWebHookの登録

Trello APIを用いて行う。

developer.atlassian.com

  1. TrelloのAPIを使うために、まずAPIキーとTokenを取得する

    Trelloにログインしている状態で、以下にアクセスするとAPIキーが表示される。

    https://trello.com/app-key

    トークンは、同ページ内の トークン リンクから、 許可 を選択することで表示される。

  2. 監視したいボードIDを取得する ボード一覧取得APIを用いてIDを取得する

    curl --request GET \  
    --url 'https://api.trello.com/1/members/{id}/boards?key={API_key}&token={API_token}' \  
    --header 'Accept: application/json'  
    

    各パラメータは以下の通りである。

    • id:Trelloのユーザー名(プロフィール画面で確認できる。または@メンション時に表示される名前)
    • API_key:(1)で取得したAPI Key
    • API_token:(1)で取得したAPI Token

    返却された一覧の中から、監視したいボードのIDを控えておく

  3. Webhookを登録する Webhook登録 for Token APIを用いて登録する

      curl --request POST \
      --url 'https://api.trello.com/1/tokens/{API_token}/webhooks?key={API_key}&callbackURL={GAS_URL}&idModel={boardId}?description={desc}' \
      --header 'Accept: application/json'
    

    各パラメータは以下の通りである。

    • API_token:(1)で取得したAPI Token
    • API_key:(1)で取得したAPI Key
    • GAS_URL:GASで生成したURL
    • boardId:(2)で取得した監視したいボードID
    • desc:(任意)説明文を追加しておくことをオススメする。例:Webhook-For-GAS

    レスポンスが200で返却されれば、登録完了である。

これで、GASとTrelloの連携が完了し、Trelloに変更があった場合は、GASのURLに変更情報がPOSTされる。

GASにSlackに通知するためのコードを実装

GASは以前までES6の書き方などができずに、少しコードを書くのが面倒であったが、Chrome V8が搭載されてES6で記載できるようになった。

GASの基本的な構成は以下である。

function doPost(e) {  //TrelloでPOSTされた情報を受け取る
   const contents = JSON.parse(e.postData.contents);  // bodyの中身

  <--- Slackに通知すべきか判別 --->
  <--- SlackにPOSTするメッセージを作成 --->

  UrlFetchApp.fetch(postUrl, options) // SlackにメッセージをPOSTする

}

また今回、スプレッドシートをDBとして利用し、ユーザー情報、SlackのWebhook URLやTrelloのAPI Key/Tokenを参照する。参考までに、私のスプレッドシートを共有する。(コピーを自分のドライブに作成し、手順の1から実行することで、同様の通知が可能となるので、お試しください。ただし、ボードの名前などが違う場合があるので、条件設定は再度お確かめください。)

docs.google.com

SlackのユーザーID(メンバーID)は以下の方法で確認できる。

個別のユーザーのユーザー ID は、メンバーのプロフィールの (その他) アイコン をクリックし、 「メンバー ID をコピー」を選択して確認することができる。

また、Trelloのactionに関しては、以下のリンク先に記載されているように、かなり細かく分類されているため、通知が必要な部分のみを選定して適切なメッセージで通知できるようにするのがベストな運用と思われる。

developer.atlassian.com

(※注意※) 最後に、コードが完成して変更を反映させるには、以下を再度必ず実行する必要がある。(通常のコードでいう、デプロイ作業的なもの)

  1. 公開 > ウェブアプリケーションとして導入...を選択する
  2. 以下の設定として、「更新」を選択する

    項目 設定
    プロジェクトバージョン New
    次のユーザーとしてアプリケーションを実行 自分
    アプリケーションにアクセスできるユーザー 全員(匿名ユーザーを含む)

以降は、「全員(匿名ユーザーを含む) 」で運用する必要があるので、変更を忘れずに。 公開用URLが変更になるわけではないので、ご安心ください。

参考までに、GASのコードも掲載しておく。(コードの最適化がされていない部分はご了承ください。)

function doPost(e) {
  const contents = JSON.parse(e.postData.contents);
  
  // ユーザーデータ読み込み
  const users = setUserData();

  // データ成形
  const actionType = contents.action.type;
  
  const cardName = contents.action.data.card.name;
  const shortLink = 'https://trello.com/c/' + contents.action.data.card.shortLink;
  
  const operatorId = contents.action.memberCreator.username;
  const operator = users.filter(user => user.trelloId === operatorId)[0]
  
  let message = "";
  
  // カードにコメントが追加された場合
  if (actionType === 'commentCard') {
    const comment = contents.action.data.text;
    const userToMention = users.reduce((acc, user) => {
      let trelloId = '@' + user.trelloId;
      if (comment.includes(trelloId)) {
        acc += '<@' + user.slackId + '> ';
      }
      return acc
    }, "")

    if (!!userToMention) {
      message += userToMention + '\n';
      message += ' *<' + shortLink + '|' + cardName + '>* のコメント確認お願いします。 by' + operator.firstName;
      
      const blocks = [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: message
          }
        }, {
          type: 'divider'
        },{
          type: "section",
          text: {
            type: "mrkdwn",
            text: comment
          }
        }
      ]
      postSlack(message, blocks);
    }
  
  // カードが移動された場合
  } else if (actionType === 'updateCard') {
    const toListName = contents.action.data.listAfter.name
    if (toListName === 'DOING') {
      message = '<!here>' + '\n *<' + shortLink + '|' + cardName + '>* のタスクに着手。 by ' + operator.firstName + '\n';
      message += '期限厳守。報連相の徹底で。';
    } else if (toListName === 'DONE') {
      message = '<!here>' + '\n *<' + shortLink + '|' + cardName + '>* のタスク完了。 by ' + operator.firstName + '\n';
      message += 'お疲れ様でした。';
    }
    postSlack(message);
    
  // カードに担当が割り振られた場合
  } else if (actionType === 'addMemberToCard') {
    const addedUserId = contents.action.member.username;
    const addedUser = users.filter(user => user.trelloId === addedUserId)[0];
    const userToMention = '<@' + addedUser.slackId + '>';
    
    const cardId = contents.action.data.card.id;
    const listName = getListName(cardId)
    if (listName === 'REVIEW') {
      message = userToMention + '\n *<' + shortLink + '|' + cardName + '>* のレビューをお願いします。 by ' + operator.firstName + '\n';
    } else {
      message = userToMention + '\n *<' + shortLink + '|' + cardName + '>* のタスクは任せた。 by ' + operator.firstName + '\n';
      message += 'よろしく頼む!!PDCA回すぞ!!';
    }
    postSlack(message);
  }

  return ContentService.createTextOutput();
}

function setUserData() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('users');
  const LastRow = sheet.getLastRow();
  const LastColumn = sheet.getLastColumn();
  const keys = sheet.getRange(1, 1, 1, LastColumn).getValues()[0];
  const values = sheet.getRange(2, 1, LastRow - 1, LastColumn).getValues();
  return values.map(row => {
    return keys.reduce((acc, key, index) => {
      acc[key] = row[index]
      return acc
    }, {})
  })
}
    
function getListName(cardId) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('setting');
  const trelloAPIKey = sheet.getRange(2, 2).getValue();
  const trelloAPIToken = sheet.getRange(3, 2).getValue();
  const url = 'https://api.trello.com/1/cards/' + cardId + '/list?key=' + trelloAPIKey + '&token=' + trelloAPIToken;
  const response = UrlFetchApp.fetch(url);
  const data = JSON.parse(response.getContentText());
  return data.name
}

function postSlack(text, blocks=[]) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('setting');
  const postUrl = sheet.getRange(1, 2).getValue();
  const data = blocks.length ? { blocks } : { text };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(data)
  };

  UrlFetchApp.fetch(postUrl, options);
}

Botの様子

以下のような感じで、Botから通知がきていい感じ!!

f:id:serip39:20200606201805p:plain

みなさんも素敵な開発ライフをお過ごしください!!!