Slackの「Interactive Message」を使って経費申請リマインドbotを作成する

JavaScript

やりたいこと

今回はSlackのInteractive Messageを使って、双方向性のあるBotアプリを作成したいと思います。
インタラクティブ機能を持たせることで、ユーザーの操作により、アプリがデータを操作、更新し、同じサービスに送り返すことができます。

Botアプリの内容

今回作成するアプリの内容は以下の通りです。今回はGoogle Apps Scriptでプログラムを組みます。

  1. 毎月最終営業日に経費申請のリマインドをSlackのチャンネルに自動投稿する
  2. 1.の翌日から翌月5日まで毎日通知する
  3. 申請を終えた人は自分の名前のボタンを押して申請完了となる
  4. ボタンを押して申請を完了するとGoogleスプレッドシートにチェックが入る

Slack Appの作成

まずは下記リンクからSlack Appを作成します。
https://api.slack.com/apps/new
「Create a Slack App」ダイアログが表示させますのでApp Nameにアプリ名を記入し、「Development Slack Workspace」で課題を投稿したいワークスペースを選択して「Create App」をクリックします。

OAuth & Permissionsの設定

左のメニューから[OAuth & Permissions]をクリックする。

Scopeの設定をします。「Scopes」>「Bot Token Scopes」のAdd an OAuth Scopeをクリックして「chat:write」、「incoming-webhook」を追加します。

「App Home」>「App Display Nameを編集します。

「Scopes」上部の「Install to Workspace」をクリックします。

投稿先のチャンネルを選択し、「許可する」をクリックします。

Bot User OAuth Access Tokenの欄にトークンが表示されれば成功です。

OAuth & Permissionsの設定は以上です。

Incoming Webhooksを設定

次に左のメニューから「Incoming Webhooks」をクリックして「Active Incoming Webhooks」をOnになっていることを確認します。なっていなければOnにしてください。

ページ下部にWebhook URLとChannelが追加されています。
このWebhook URLは後ほど使います。
投稿するチャンネルを増やす場合は「Add New Webhook to Workspace」で追加できます。

これでSlackでチャンネルのWebhook URLを取得できました。

Google Apps Scriptでプログラムの作成

続いてGoogle Apps Scriptでプログラムを作成します。

今回作成するプログラムの内容は以下の通りです。

  1. 毎月最終営業日(10:00AM)に「経費申請がまだの方は申請+領収書の格納をしてください。完了した方から自分の名前のボタンを押してください。」といった内容のボタン付きリマインドをSlackのチャンネルに自動投稿する
  2. 1.の翌日から翌月5日まで毎日通知する(10:00AM)
  3. リマインド最終日(毎月5日)はメッセージに申請期限最終日という内容を加える
  4. 初回(月末リマインド)は全員にメンション(@channel)する
  5. 1.の翌日〜翌月5日までは未申請者+私だけメンションをつける
  6. 申請を完了した人は、翌日の投稿からメンションとボタンが消える
  7. ボタンを押して申請を完了するとGoogleスプレッドシートにチェックを入れる

Interactive Messageとは

プログラムを組む前にInteractive Messageについて簡単にまとめます。

通常のSlack Botはアプリ側から一方向的にメッセージが送られるのに対し、Interactive MessageはBlock Kitと呼ばれるビジュアルコンポーネントを利用することでユーザーからのアクションに応答できます。

以下が完成イメージとなり、2021年5月の下に並ぶボタンがBlock Kitと呼ばれます。

スプレッドシートの作成

今回は以下のようなスプレッドシートを使用します。申請完了ボタンが押された場合、該当月のユーザーに当てはまるセルに「済」と入力されます。

毎月最終営業日にリマインドを投稿する処理

最終営業日に実行するリマインド処理は以下のコードになります。

remindExpenseApplication.gs

const SLACK_WEBHOOK_URL = "https://hooks.slack.com/your-webWhookURL";
const TARGET_SHEET = "スプレッドシート名";
const sheet = SpreadsheetApp.getActive().getSheetByName(TARGET_SHEET);
const YEAR_ROW = 2;
const MONTH_ROW = 3;
const FOLDER_URL_ROW = 4;
const FIRST_COLUMN = 4;
var ROW_OFFSET = 2;
const FEEDBACK_TYPES = [
  { name: "崔 央載", buttonText: "崔" },
  { name: "xxx ○○○", buttonText: "xxx" },
  ・・・
]

// 来月の最終営業日を取得する
function getLastBusinessDate(int) {
  // intが1なら今月、2なら来月
  var date = new Date();
  
  // 「月末最終日」を求める
  var eomonth_date = new Date(date.getFullYear(), date.getMonth() + int ,0);

  var lastBusinessDate = eomonth_date;
  
  while(isHoliday_(lastBusinessDate)) {
    lastBusinessDate.setDate(lastBusinessDate.getDate() - 1);
  }
  return lastBusinessDate;
}

// 月末に経費申請リマインドをSlackに投稿する
function notify(){
  var sheet = SpreadsheetApp.getActive().getSheetByName(TARGET_SHEET);
  var today = new Date();
  var thisYear = today.getFullYear();
  var thisMonth = today.getMonth() + 1;
  var yearMonth = (thisYear + '年' + thisMonth + '月').toString();
  var finder = sheet.createTextFinder(thisYear);
  var ranges = finder.findAll(); // 年号が一致する全てのカラム
  for (i=0; i < ranges.length; i++) {
    // 月が一致するカラム
    var value = ranges[i].offset(1,0).getValue().toString();
    if(value == thisMonth) {
      var column = ranges[i].getColumn();
    }
  }
  var folder_url = getFolderUrl(column);
  var title = getNotifyMessageForSlack(thisMonth,folder_url);
  
  var usersArray = sliceByNumber(FEEDBACK_TYPES,5);
  // ボタンはattachment毎に最大で5個までなのでボタン5個につき1件のattachmentを送信する
  usersArray.forEach(function(users,i) {
    var attachments = getNotifyAttachments(users,yearMonth);
    var text = "";
    // Slackに投稿する
    if(i==0) {
      sendToSlack(title, attachments);
    }else{
      sendToSlack(text, attachments);
    }
  }); 

}

function sliceByNumber(array, number){
  const length = Math.ceil(array.length / number)
  return new Array(length).fill().map((_, i) =>
    array.slice(i * number, (i + 1) * number)
  )
}

function getNotifyMessageForSlack(month,folder_url){
  // リマインド文言を返す
  var text = "@channel\n" + month + "月分の経費申請と領収書の格納をお願いします。\n領収書はこのフォルダにお願いします。" + folder_url + " \n以下のフォーマットにの通りに記載して申請いただくようお願いいたします。\n```【経費内容】※\n例)出張代として\n【値段】※\n合計30000円\n内訳\n・○月×日 新幹線代(行き) 10000円\n・○月×日 宿泊費 10000円\n・○月×日 新幹線代(帰り) 10000円\n✴︎支払いが一つの場合も記述\n【支払方法】※\n例)現金建替\n【領収書】※\n格納済 or 未格納\nファイルのURL\n✴︎未格納の場合、格納したタイミングで崔にメンションつけてこの投稿に返信してください。\n【請求月】\n例)○月分の請求書に上乗せします。\n✴︎社員の場合、建て替えでない場合も不要です。\n※…必須項目```\n申請を完了しましたら自身のボタンを押してください。";
  return text;
}

function getNotifyAttachments(users,yearMonth){
  var actions = [];
  users.forEach(function(user) {
    var action = {
      "name": "feedback",
      "text": user.buttonText, // ボタン内のテキスト
      "style": "primary",
      "type": "button", // blockのタイプにボタンを指定
      "value": user.name,
      "confirm": { // ボタン押した際の確認メッセージ
        "title": user.name + "さん",
        "text": "経費申請を完了します。よろしいですか?",
        "ok_text": "完了",
        "dismiss_text": "キャンセル"
      }
    }
    actions.push(action);
  });
  return [
    {
      "title": yearMonth,
      "fallback": "もう一回おしてね",
      "callback_id": 1,
      "color": "#3AA3E3",
      "attachment_type": "default",
      "actions": actions,
    }
  ]
}

// Slackに投稿する
function sendToSlack(text, attachments){
  // Slackに投稿
  var payload = {
    "text": text,
    "attachments": attachments,
    "link_names": 1
  };
  var options = {
    "method" : "post",
    "payload" : JSON.stringify(payload)
  };
  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
}

ボタンが押された後の処理

申請を完了した方が自身の名前のボタンを押した際の処理は以下の通りです。

responseToAnswer.gs

// アンケートに回答が来てからの処理
function doPost(e){
  var string = JSON.stringify(e.parameter.payload);  
  var string = string.replace(/\\|\//g,'');
  var json = JSON.parse(e.parameter.payload)
  addFeedback(json);
  var date = new Date();
  var today = Utilities.formatDate(date,'JST','yyyy/MM/dd');
  var returnText = today + " " + json.actions[0].value + "さんの経費申請が完了しました。";
  var payload = {
    "text": returnText
  };
  var options = {
    "method" : "post",
    "contentType" : "application/json",
    "payload" : JSON.stringify(payload)
  };
  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
  
  return ContentService.createTextOutput();
}

// スプレッドシートに対する処理
function addFeedback(json){
  var sheet = SpreadsheetApp.getActive().getSheetByName(TARGET_SHEET);

  // 行を取得
  var st = json.actions[0].value;
  var textFinder = sheet.createTextFinder(st);
  // 名前で検索して行を取得
  var targetRows = textFinder.findAll();
  // 最初のセルを取得
  var targetRow = targetRows[0].getRow();

  // カラムを取得
  // 2021年○月
  var yearMonth = json.original_message.attachments[0].title
  var year = yearMonth.substring(0, yearMonth.indexOf('年'));
  var month = yearMonth.substring(yearMonth.indexOf('年')+1, yearMonth.indexOf('月'));
  var finder = sheet.createTextFinder(year);
  var ranges = finder.findAll(); // 年号が一致する全てのカラム
  for (i=0; i < ranges.length; i++) {
    // 月が一致するカラム
    var value = ranges[i].offset(1,0).getValue().toString();
    if(value == month) {
      var column = ranges[i].getColumn();
    }
  }

  // 該当者に済
  sheet.getRange(targetRow,column).setValue('済');
  return '成功';
}

// Slackに完了メッセージを返す
function responseToSlack(responseUrl, payload){
  var options = {
    "method" : "post",
    "payload" : JSON.stringify(payload)
  };

  UrlFetchApp.fetch(responseUrl, options);
}

毎月5日まで毎日リマインドする処理

最終営業日の翌日から翌月5日まで実行される、未申請者にリマインドする処理は以下の通りです。

remindToUnapplicants.gs

// 未申請の人へリマインドを送る
function remindToUnapplicants() {
  var today = new Date();
  var date = today.getDate();
  var thisLastBusinessDate = getLastBusinessDate(1).getDate();

 // 日付が最終営業日以降か5日までか
  if(date <= 5 || date > thisLastBusinessDate) {
    // 該当列を取得する
    var incompleteColumn = getIncompleteColumn();
    console.log(incompleteColumn);
    var unapplicants = getUnapplicants(incompleteColumn);
    // アタッチメントを取得する
    var thisYear = sheet.getRange(YEAR_ROW, incompleteColumn).getValue();
    var thisMonth = sheet.getRange(MONTH_ROW, incompleteColumn).getValue();
    var yearMonth = (thisYear + '年' + thisMonth + '月').toString();
    var filteredUsers = FEEDBACK_TYPES.filter(function(element,index,array) {
      return(unapplicants.names.includes(element.name));
    });

    var usersArray = sliceByNumber(filteredUsers,5);
    usersArray.forEach(function(users,i) {
      var attachments = getNotifyAttachments(users,yearMonth);
      // リマインド文言の作成
      var folder_url = getFolderUrl(incompleteColumn);
      var remindText = createRemindText(unapplicants.mentions,thisMonth,date,folder_url);
      var text = "";
      console.log(remindText);
      // リマインドを投稿する
      if(i==0) {
        sendToSlack(remindText, attachments);
      }else{
        sendToSlack(text, attachments);
      }
    });
  }
}

// 休日・祝日か判定
function isHoliday_(date) {
  //土日か判定
  var weekInt = date.getDay();
  if(weekInt === 0 || 6 === weekInt){
    return true;
  }

  //祝日か判定
  const calendarId = "your-google-calendar-id";
  var calendar = CalendarApp.getCalendarById(calendarId);
  var todayEvents = calendar.getEventsForDay(date);
  if(todayEvents.length > 0){
    return true;
  }

  return false;
}

// 該当列の取得
function getIncompleteColumn() {
  var today = new Date();
  var date = today.getDate();
  console.log(date)
  var thisLastBusinessDate = getLastBusinessDate(1).getDate();
  var thisYear = today.getFullYear();
  var lastMonth = today.getMonth()+1;
  if(lastMonth == 1 && date <= 5) {
    var thisYear = thisYear -1;
    var lastMonth = 12;
  }else if(date <= 5) {
    var lastMonth = today.getMonth();
  }
  var finder = sheet.createTextFinder(thisYear);
  var ranges = finder.findAll(); // 年号が一致する全てのセル
  for(i=0; i < ranges.length; i++) {
    // 月が一致するカラム
    var value = ranges[i].offset(1,0).getValue().toString();
    if(value == lastMonth) {
      var column = ranges[i].getColumn();
    }
  }
  return column;
}

// 申請が未完了のユーザーを取得する
function getUnapplicants(column) {
  // 月ごとの未申請者を取得
  var unapplicants = {};
  unapplicants.names = [];
  unapplicants.mentions = [];
  var lastRow = sheet.getLastRow();
  for(var i=5;i <= lastRow; i++) {
    var check = sheet.getRange(i,column);
    if(check.isBlank()) {
      var name = sheet.getRange(i, 2).getValue();
      var mention = sheet.getRange(i, 3).getValue();
      unapplicants.names.push(name);
      unapplicants.mentions.push(mention);
    }
  }
  // 月ごとの未申請者を返す
  return unapplicants;
}

function getFolderUrl(column) {
  var value = sheet.getRange(FOLDER_URL_ROW,column).getValue();
  return value;
}

// リマインド文言の作成
function createRemindText(mentions,month,date,folder_url) {
  var text = "";
  mentions.forEach(function(mention) {
    text += "<@" + mention + "> ";
  });
  text += "<@U01DT148GKX>";

  if(date == 5) {
    text += "\n*※" + month + "月分の経費申請期限最終日です。*"
  }
  
  // リマインド文言を返す
  text += "\n" + month + "月分の経費申請がまだ完了していません。申請と領収書の格納をお願いします。\n領収書はこのフォルダにお願いします。" + folder_url + " \n申請を完了しましたら自身のボタンを押してください。\n既に申請を終えている場合もお手数ですが自身のボタンを押していただくようお願いします。";
  return text;
}

デプロイ

月末に実行する処理とトリガーを設定する処理をまとめて定期実行する処理を作り、その後デプロイします。

定期実行する処理は以下の通りです。

auto.gs

// 最終営業日の翌日から翌月5日まで定期実行する処理
function dailiyFunction() {
 // 次のトリガーの設定
  setDailyTrigger()
  // 未申請の人へリマインドを送る
  remindToUnapplicants();
}

// 毎月最終営業日に定期実行する処理
function monthlyFunction() {
 // 次のトリガーの設定
  setMonthlyTrigger();
  // 月末に経費申請リマインドを投稿する
  notify();
}

// 翌日の10時にトリガーを設定
function setDailyTrigger() {
  var setTime = new Date();
  setTime.setDate(setTime.getDate() + 1);
  setTime.setHours(10);
  setTime.setMinutes(00);
  ScriptApp.newTrigger('dailyFunction').timeBased().at(setTime).create();
}

// 次の最終営業日にトリガーを設定
function setMonthlyTrigger() {
  var setTime = getLastBusinessDate(2);
  setTime.setHours(10);
  setTime.setMinutes(00);
  ScriptApp.newTrigger('monthlyFunction').timeBased().at(setTime).create();
}

それではデプロイしていきます。

Google Apps Scriptの青い「デプロイ」をクリックし、新しいデプロイを選択します。
種類:ウェブアプリ、実行ユーザー:自分、アクセスできるユーザー:自分としデプロイします。

動作確認

auto.gsファイルでdailyFunctionとmonthlyFunctionをデバックします。

※ デバック時はそれぞれのnew Date()に最終営業日〜翌月5日までの日付を入れて実行します。

例えば、2021年5月31日を想定してmonthlyFunctionを実行した場合、Slackのチャンネルに以下のようなメッセージが投稿され、次のトリガーが2021年6月30日に設定されていれば成功です。

次に、先ほどのメッセージでいくつかボタンを押して申請を完了させた後、2021年5月1日を想定してdailyFunctionを実行します。Slackのチャンネルに以下のようなメッセージが投稿され、次のトリガーが2021年5月2日に設定されていれば成功です。

以上で双方向性のあるリマインドBotアプリは完成です。

コメント

タイトルとURLをコピーしました