Slackで営業日まで待ってメッセージを送信できるSlashコマンドを作った

前職の先輩である@shoitoさんが[似たようなことをやった記事](https://dev.classmethod.jp/tool/slack-schedule-message/)を公開していたので便乗してみました。
作った動機は似たようなもんなので割愛。

ちなみに作ったのは僕の方が先なんだからね！

# 作ったもの
`/sendinworktime {メッセージ}`と叩くと次の営業日のAM9:00まで待ってからメッセージを送信してくれるSlashコマンド。
`/sendinworktime {返信したいメッセージのリンク} {メッセージ}`とするとスレッド内にも送信できます。

メッセージはBotから送信され、誰が送信したのかわかるようにメッセージの最後に`from @{送信者のusername}`が付きます。

| 説明          |       画像 |
|:----------------:|:-----------------:|
|送信|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36902/67a6dc89-48e6-cf1d-8ede-513a84ed7331.png)|
|  Botからの返信   |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36902/adfbf39f-83b8-afb8-1366-0656f133061c.png)|
| 実際に送られるメッセージ |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36902/4da492f8-af2d-0f75-3c73-10e1338c8aa1.png)|


Slack App+GASで動かしているので、以下にどう作ったかについて書いていきます。
やった作業としては[Slash CommandsとGASでSlackのオリジナルコマンドをつくる](https://qiita.com/chikuwa111/items/7a1a349b82318a5861cc)とほぼ同じです。
わからなくなったら参照してください！(丸投げ)

# Slack側での作業
## Slack Appの作成
1. https://api.slack.com/apps の「Create New App」から新規Appを作成します。
1. App Nameとインストール先のSlackワークスペースを選択します。僕はApp Nameを「Send In Work Time」としました。
1. Basic InformationのBots、Permissionsを選択します。
    1. BotsではDisplay Name,Default usernameともに「send_in_worktime」としました。
    1. Botsでの作業をするとPermissionsには自動でチェックが付きます。Install App to Workspaceをするとメッセージ送信するためのTOKENが表示されるのでBot User OAuth Access Tokenをコピーしておきます。

一旦Slack側の作業としてはこれで終了です。
GASの準備を終えてからSlash Commandsの設定をする必要がありますが、それは後述。

# GAS側での作業
https://script.google.com にアクセスしてコードを書いていきます。

```js
var TARGET_HOUR = '9';
var TARGET_MINUTES = '00';

// main
function doPost(e) {
  var props = {
    "username": e.parameter.user_name,
    "team_domain": e.parameter.team_domain,
    "channel_id": e.parameter.channel_id,
    "text": e.parameter.text,
  };
  
  // schedule message
  var result = scheduleMessage(props);
  
  // response for command
  var text;
  if(result.ok) {
    text = "Success: message scheduled at " + Utilities.formatDate(new Date(result.post_at * 1000), "JST", "YYYY'/'MM'/'dd'('EE) ") + TARGET_HOUR + ":" + TARGET_MINUTES + '\n```\n' + result.message.text + "\n```";
  } else {
    text = "Failure: because of error `" + result.error + "`";
  }
  var response = { text: text };
  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);
};

function scheduleMessage(props) {
  var users = getUserList();
  var splited = props.text.split(" ");
  if(splited.length <= 1) {
    return send(props, users);
  }
  
  var regex = new RegExp("https:\/\/" + props.team_domain + "\.slack\.com\/archives\/([^/]*)\/p([^\?]*).*(thread_ts=([\\d.]+))?");
  var match = splited[0].match(regex);
  if(match) {
    return send({
      username: props.username,
      team_domain: props.team_domain,
      channel_id: match[1],
      ts: match[4] || match[2].substr(0, match[2].length - 6) + "." + match[2].substr(match[2].length - 6),
      text: splited.slice(1).join(" "),
      original: props.text,
    }, users);
  } else {
    return send(props, users);
  }
}

function send(data, users) {
  var user = getUser(data.username, users);
  data.text = convertMessage(data.text, users) + "\n\nfrom: `@" + (user ? user.display_name : data.username) + "`";

  var options = {
    text: convertMessage(data.text, users),
    channel: data.channel_id,
    post_at: (getTargetDate(new Date()) / 1000).toFixed(), 
  };
  if(data.ts) {
    options.thread_ts = data.ts;
  }

  return callSlackAPI("chat.scheduleMessage", options, "post");
}

function convertMessage(text, users) {
  var temp = text;
  users.forEach(function (user) {
    temp = temp.replace(new RegExp("@" + user.display_name + "(\\s|$)", 'gm'), "<@" + user.id + "> ");
    temp = temp.replace(new RegExp("@" + user.name + "(\\s|$)", 'gm'), "<@" + user.id + "> ");
  });
  return temp;
}

function getUserList() {
  var cache = CacheService.getScriptCache();
  var data = cache.get("users");
  if(data) {
    return JSON.parse(data);
  }
  
  var res = callSlackAPI("users.list", {});
  var users = res.members.filter(function(member) {
    return !member.is_bot;
  }).map(function(member) {
    return {
      id: member.id,
      name: member.name,
      display_name: member.profile.display_name || member.name,
      icon: member.profile.image_72
    };
  });
  cache.put("users", JSON.stringify(users), 60*60*24 - 10 * 60); 
  return users;
}

function getUser(username, users) {
  var filtered = users.filter(function (user) {
    return user.name === username;
  });
  return filtered.length === 1 ? filtered[0] : null;
}

function getTargetDate(base) {
  var temp = new Date(base.getTime());
  temp.setHours(Number(TARGET_HOUR));
  temp.setMinutes(Number(TARGET_MINUTES));
  temp.setSeconds(0);
  
  if(base.getTime() < temp.getTime() && !isHoliday(base)) {
    return temp;
  }

  do {
    temp.setDate(temp.getDate() + 1);
  } while(isHoliday(temp))
  return temp;
}

function isHoliday(date){
  var weekInt = date.getDay();
  if(weekInt <= 0 || 6 <= weekInt){
    return true;
  }

  var calendarId = "ja.japanese#holiday@group.v.calendar.google.com";
  var calendar = CalendarApp.getCalendarById(calendarId);
  var events = calendar.getEventsForDay(date);
  if(events.length > 0){
    return true;
  }

  return false;
}

function callSlackAPI(api, payload, method){
  payload.token = PropertiesService.getScriptProperties().getProperties().SLACK_TOKEN;

  var options = {
    "method" : method || "get",
    "payload" : payload
  };
  var res = JSON.parse(UrlFetchApp.fetch("https://slack.com/api/" + api, options));
  return res;
}
```
メニューの`公開 > ウェブ アプリケーションとして導入...`で公開しておきます。
**アプリケーションにアクセスできるユーザー**を「全員(匿名を含む)」にするのを忘れずに！
導入を押した後に表示されるURLはSlackとの連携に使うのでコピーしておいてください。

コードが汚いのは許してください :bow:
気が向いたら直します！(直すとは言ってない。一回動かしてしまうと直すのめんどくさいんだよなぁ…)

### doPost
Slashコマンドが叩かれた時に呼ばれる関数です。

1. Slackから渡されたイベントから必要な情報を取り出す
1. メッセージ登録用の関数を呼び出す
1. コマンドを叩いたユーザへの返信

を行っています。

### scheduleMessage
スレッドへの返信のためにURLがメッセージの最初についているかどうかを見てSlackのAPIにわたす情報を書き換えています。
やってることと関数名があってないのはすいません… :bow: 

### send
SlackのAPIにわたす情報をさらに書き換えています。
例によって関数名がひどい。

1. textの最後に`from @{送信者のusername}`を足す
1. textを`@{username}`→`<@{userId}>`に変換したものに差し替え
1. post_atを次の営業日のAM9:00に
1. (返信するなら)thread_tsを返信したいメッセージのものに

### convertMessage
↑の`@{username}`→`<@{userId}>`を実際に行っています。

### getUserList
↑の変換を行うためにSlackのワークスペース内のユーザ一覧を取得しています。
実際にコマンドを叩いたら遅くてタイムアウトしてしまったのでキャッシュしています。(Slashコマンドの返信までの制限時間)

### getUser
↑のユーザ一覧から特定のユーザを取り出します。

### getTargetDate
次の営業日のAM9:00を計算します。

### isHoliday
休日(日本)かどうかを判定します。

### callSlackAPI
SlackのAPIを叩きます。
今回はメッセージ送信の予約ができる[chat.scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage)を使用しています。

`PropertiesService.getScriptProperties().getProperties().SLACK_TOKEN;`は`ファイル > プロジェクトのプロパティ > スクリプトのプロパティ`に登録した値を取り出しています。
[Slack側での作業](#Slack側での作業)でコピーしておいたBot User OAuth Access Tokenを設定しています。


# SlackとGASの連携
https://api.slack.com/apps にアクセスすると[Slack側での作業](#Slack側での作業)で作成したAppが表示されているので選択します。

1. Basic Informationを選択
1. Add features and functionality内のSlash Commandsを選択し、以下のように設定
    1. Nameに「sendinworktime」
    1. Descriptionには「営業日まで待ってからメッセージを送信します」
    1. Request URLはGASでの公開時に表示されるURL

これで作業は終了です。
Slackから`/sendinworktime {メッセージ}`と送信すると次の営業日のAM9:00まで待ってメッセージを送ることができるようになっているはずです :tada: 

# 今後について
やりたかったけどできてないことがいくつかあるのでそれをやっていきたいなと思っています。(やったらまた書くかも)

## 本人に偽装する
今回は送信されるメッセージは`Send In Work Time`から送信されていますが、本当ならコマンドを叩いた本人からのように送信したいと思っていました。
できなかったのは[chat.postMessage](https://api.slack.com/methods/chat.postMessage)では設定することが出来るusernameやicon_urlが[chat.scheduleMessage](https://api.slack.com/methods/chat.scheduleMessage)では使えなかったからです。

これを実装するためには実際に送信したい時間まで待ってから[chat.postMessage](https://api.slack.com/methods/chat.postMessage)を叩いてあげる必要があります。
GASで実装する場合には、doPostでSlackから受け取ったメッセージをスプレッドシートに一旦保存しておいて、日次のトリガーで保存されたメッセージを[chat.postMessage](https://api.slack.com/methods/chat.postMessage)で送信します。

## ファイル送信に対応する
これは調べてないのでできるのかどうかよくわかってないです。
現状ではGoogle Driveなどに一度アップロードした後にそのURLをメッセージで共有するというワークアラウンドで対処しています。

===追記===
そもそもファイル添付時のメッセージではSlashコマンドが反応しないので無理そうでした…。
(何かいい方法があればおしえてください！)