【GAS】 slack に新規作成されたチャンネルを定期的に通知する

# 新規チャンネルに気づけない・気づかれない
新しくチャンネルを作成してもワークスペースの全員に都度お知らせするのはなんだか差し出がましいような気がしてしまう…… そんなときは BOT に通知してもらえばいいのです。
![20191072222625_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/09e748b0-0545-44c8-b5d3-42fed6de8c53.png)


# 事前準備
## Event Subscription
アプリの Event Subscription を有効にして、 `channel_created` `channel_archive` `channel_deleted` のイベントを監視させます。
![20191072222253.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/501098/5bc128d3-a671-eab4-b198-d1dc55b6c266.png)

Response URL には後述する GAS への公開 URL を貼り付けます。チャンネル関連でイベントが発生すると、イベント内容を含んだ HTTP リクエストがこの URL に向けて送られます。

## アプリの Scope を設定
チャンネルに投稿するための権限として `chat:write:bot` のスコープでアプリに権限を与えておきます。

# GAS 側の処理
## シートにチャンネルのログを残す
GAS に用意されている `doPost()` 関数を使用して、イベントによって送信された HTTP リクエストを受け取って処理します。
シートには `イベント発生日時` `イベントが発生したチャンネル名` `チャンネル ID` `イベントの種類` の4列でログを残します。

```javascript
var SHEET_ID = PropertiesService.getScriptProperties().getProperty("SHEET_ID");
var sht = SpreadsheetApp.openById(SHEET_ID).getSheets();

function doPost (e) {

  // リクエスト内容をパース
  var postData = JSON.parse(e.postData.getDataAsString());
  var contents = JSON.parse(e.postData.contents);
  
  // 認証（ url_verification が送信された場合は challenge をオウム返しする）
  if(postData.type == "url_verification") {
    var res = {"challenge":postData.challenge}
    return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
  }
  
  // 無効な入力を弾く
  if (e == null || e.postData == null) {
    return;
  }
  
  // シートに加筆
  var eventType = contents.event.type;
  if (eventType == "channel_created") {
    var channelName = contents.event.channel.name;
    var channelID = contents.event.channel.id;
  }
  else {
    var channelName = "obsolete_channel";
    var channelID = contents.event.channel;
  }
  
  sht[0].appendRow([new Date(), channelName, channelID, eventType]);
  
}
```
### ハマったポイント1：認証
イベントが発生すると、イベント内容が送信される前に `url_verification` という内容の JSON が送信され、そこに含まれている `challenge` を slack 側にオウム返ししてやる必要があります。この認証段階を経て初めてイベント内容に関するリクエストが飛んできます（[公式ドキュメント](https://api.slack.com/events/url_verification)）。

ちなみに認証が済んだあとも処理がタイムアウト（3秒）などすると slack は [最大3回](https://api.slack.com/events-api#app_events) リトライしてくるようです。

> We'll knock knock knock on your server's door, retrying a failed request up to 3 times in a gradually increasing timetable:

リトライ回避のためには slack へ返す HTTP ヘッダに `X-Slack-No-Retry: 1` を指定すればいいそうなのですが GAS でそれを行う方法がわからず、シートに行を追加するだけならタイムアウトしないのをいいことに放置しています。

最終的に処理が終わった段階でなにも return していないのに特にリトライ処理されていない点など、色々気になり始めるとキリがないのですが ~~とりあえず動いているので良しとします~~ 今後検討していこうと思います。

### ハマったポイント2：イベントによる JSON 形式の違い
+ https://api.slack.com/events/channel_created
+ https://api.slack.com/events/channel_archive
+ https://api.slack.com/events/channel_deleted

:sob: 統一してほしい！

## 追加されたチャンネルの情報を取得する
```javascript
function getNewChannel () {
  // 日付
  var now = new Date();
  var startOfToday = new Date(now.getYear(), now.getMonth(), now.getDate(), 00, 00);
  var startOfYesterday = new Date(now.getYear(), now.getMonth(), now.getDate()-1, 00, 00);
  
  // チャンネル
  var newChannel = [];
  //  削除・アーカイブされたもの
  var exception = [];
  
  // データ取得
  var maxRow = sht[0].getLastRow();
  var data = sht[0].getRange(1, 1, maxRow, 4).getValues();
  for (var r = maxRow - 1; r >= 0; r--) {
    // 2日以上前の場合は抜ける
    var addDate = new Date(data[r][0]);
    if (addDate.getTime() < startOfYesterday.getTime()) {
      break;
    }
    
    var info = "  <#" + data[r][2] + "|CHANNELNAME>"; // ID 優先でリンクが貼られるのでチャンネル名は適当
    if (data[r][3] == "channel_archive" || data[r][3] == "channel_deleted") {
      exception.push(info);
    }
    
    // 今日になってから追加されたチャンネルはスキップ
    if (addDate.getTime() > startOfToday.getTime()) {
      continue;
    }
    
    newChannel.push(info);
  }
  
  // 消されていないチャンネルだけ取得
  var ret = [];
  for (var i = 0; i < newChannel.length; i++) {
    if (exception.indexOf(newChannel[i]) == -1) {
      ret.push(newChannel[i]);
    }
  }
  return ret;
}
```

前日に作成されたチャンネルの ID を取得してから、実行時までに削除かアーカイブされたチャンネルを除外しています。

## 定期実行
これまでに書いた関数を組み合わせて下記の関数を作り、 GAS の時間トリガーで `notifyNewChannel()` 定期実行します。

```javascript
var ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("ACCESS_TOKEN");
var CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");

// slack に chat.postMessage 経由でメッセージを投稿する関数
function postAsBot (text) {
  var payload = {
    "token": ACCESS_TOKEN,
    "channel": CHANNEL_ID,
    "text": text
  };
  var options = {
    "method" : "POST",
    "payload": payload
  }
  UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
}

// 新規チャンネル情報を投稿
function notifyNewChannel () {
  var newChannel = getNewChannel();
  if (newChannel.length < 1) {
    return;
  }
  var msg = "昨日、" + newChannel.length + "個のチャンネルが作成されました！\n" + newChannel.join("\n")
  postAsBot(msg);
}
```

# コード全体
<details><summary>全文表示</summary><div>

```javascript
///////////////////////////////////////////////////////
// グローバル変数
///////////////////////////////////////////////////////

// シート
var SHEET_ID = PropertiesService.getScriptProperties().getProperty("SHEET_ID");
var sht = SpreadsheetApp.openById(SHEET_ID).getSheets();

// token
var ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("ACCESS_TOKEN");

// チャンネル
var CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");

///////////////////////////////////////////////////////
// 応答処理
///////////////////////////////////////////////////////

function doPost (e) {

  // リクエスト内容をパース
  var postData = JSON.parse(e.postData.getDataAsString());
  var contents = JSON.parse(e.postData.contents);
  
  // 認証（ url_verification が送信された場合は challenge をオウム返しする）
  if(postData.type == "url_verification") {
    var res = {"challenge":postData.challenge}
    return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
  }
  
  // 無効な入力を弾く
  if (e == null || e.postData == null) {
    return;
  }
  
  // シートに加筆
  var eventType = contents.event.type;
  if (eventType == "channel_created") {
    var channelName = contents.event.channel.name;
    var channelID = contents.event.channel.id;
  }
  else {
    var channelName = "obsolete_channel";
    var channelID = contents.event.channel;
  }
  
  sht[0].appendRow([new Date(), channelName, channelID, eventType]);
  
}

///////////////////////////////////////////////////////
// slack 関連
///////////////////////////////////////////////////////

// slack に chat.postMessage 経由でメッセージを投稿する関数
function postAsBot (text) {
  var payload = {
    "token": ACCESS_TOKEN,
    "channel": CHANNEL_ID,
    "text": text
  };
  var options = {
    "method" : "POST",
    "payload": payload
  }
  UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
}

///////////////////////////////////////////////////////
// 通知処理
///////////////////////////////////////////////////////

// 昨日追加されたチャンネルを取得
function getNewChannel () {
  // 日付
  var now = new Date();
  var startOfToday = new Date(now.getYear(), now.getMonth(), now.getDate(), 00, 00);
  var startOfYesterday = new Date(now.getYear(), now.getMonth(), now.getDate()-1, 00, 00);
  
  // チャンネル
  var newChannel = [];
  //  削除・アーカイブされたもの
  var exception = [];
  
  // データ取得
  var maxRow = sht[0].getLastRow();
  var data = sht[0].getRange(1, 1, maxRow, 4).getValues();
  for (var r = maxRow - 1; r >= 0; r--) {
    // 2日以上前の場合は抜ける
    var addDate = new Date(data[r][0]);
    if (addDate.getTime() < startOfYesterday.getTime()) {
      break;
    }
    
    var info = "  <#" + data[r][2] + "|CHANNELNAME>"; // ID 優先でリンクが貼られるのでチャンネル名は適当
    if (data[r][3] == "channel_archive" || data[r][3] == "channel_deleted") {
      exception.push(info);
    }
    
    // 今日になってから追加されたチャンネルはスキップ
    if (addDate.getTime() > startOfToday.getTime()) {
      continue;
    }
    
    newChannel.push(info);
  }
  
  // 消されていないチャンネルだけ取得
  var ret = [];
  for (var i = 0; i < newChannel.length; i++) {
    if (exception.indexOf(newChannel[i]) == -1) {
      ret.push(newChannel[i]);
    }
  }
  return ret;
}

// 新規チャンネル情報を投稿
function notifyNewChannel () {
  var newChannel = getNewChannel();
  if (newChannel.length < 1) {
    return;
  }
  var msg = "昨日、" + newChannel.length + "個のチャンネルが作成されました！\n" + newChannel.join("\n")
  postAsBot(msg);
}
```
</div></details>