【GAS】Slackのチャンネル内のメッセージを期間指定して一度に全部消せるスラッシュコマンドを作った

#はじめに
個人用のワークスペースにRSS用のチャンネルを作ってフィードを流していたんですが、気付いたらメッセージの合計が1万を越えようとしていて、**うち半分以上がRSSチャンネル**みたいな状態になっていました。

有料版にすれば問題ないのですが、何分先立つ物がない新卒社会人ゆえ、RSSチャンネルをクリーンにすることで他のチャンネルのメッセージへの影響を最小限に抑えようと思い、Slack APIとGASを使ってお掃除することにしました。今回はその成果を皆さんに共有できればと思います。

#仕様
実装：スラッシュコマンド（コマンド名：`/annihilate`[^1]）
書式：`/annihilate FROM UNTIL`（引数はどちらも`YYYY/MM/DD/h/m/s`の形で指定）
機能：コマンドを実行したチャンネル内で、`FROM`から`UNTIL`までの期間に投稿された全メッセージを削除する。
実行時間：およそ`1.2 * 削除件数`（秒）
備考：コマンドを実行すると削除開始のメッセージが投稿され、実際の削除処理はバッチ処理形式で行われる。削除APIは30件ずつ呼び出し、300件削除するごとに途中経過をキャッシュして中断・自動再開する。

#デモ
![annihilate_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/c611b9f5-01ff-fd18-8a6e-a9a93b27ca4a.gif)
ネーミングセンスはご愛嬌ということで・・・。

中断・自動再開のイメージは↓こんな感じです。

![スクリーンショット (57).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/90848c74-dbe8-0097-297b-c55116593d96.png)


#処理のフロー
おおまかにですが、下のような感じです。

コマンド実行時のイベントを緑、バックグラウンドで走るイベントを灰色で表しています。

トリガーを作成して時間差で実行という形にすることでバッチ処理を実現しています。
![スクリーンショット (49).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/b952f502-5f87-b240-b301-e71613fc67be.png)


#ソースコード
長い（と言っても200行くらいですが）ので[GitHub](https://github.com/Nagyon-Jessica/annihilate_messages)に置いてます。比較的丁寧に注釈を入れたので、上で述べた仕様がどう実装されているかはご覧いただければおわかりになるかと思います。

改変含めご自由に使っていただいて構いませんが、念の為最初は重要度の低いチャンネルで1件ずつの削除を試していただくことをお勧めします。

#スラッシュコマンドの作り方
先人による素晴らしいハウツーがございますので、ここでは割愛します。

[Slash CommandsとGASでSlackのオリジナルコマンドをつくる](https://qiita.com/chikuwa111/items/7a1a349b82318a5861cc)

#コマンド受付＆キューイング命令＆削除開始メッセージの投稿
例外処理は割愛しています。

```js
function doPost(e) {
  (...)
  
    var channel_id = e.parameter.channel_id
    var parameter = e.parameter.text;
    var param_array = parameter.split(/\s/)
    
    var old = param_array[0].split(/\//)
    var late = param_array[1].split(/\//)
    var oldest = Utilities.formatString("%s-%s-%sT%s:%s:%s+09:00", old[0], old[1], old[2], old[3], old[4], old[5])
    var latest = Utilities.formatString("%s-%s-%sT%s:%s:%s+09:00", late[0], late[1], late[2], late[3], late[4], late[5])
    oldest = Date.parse(oldest) / 1000 + ''
    latest = Date.parse(latest) / 1000 + ''
  
  //キューを追加
  addQueue(oldest, latest, channel_id);
  
  var response = {text: Utilities.formatString("チャンネル：%s のメッセージ削除を開始します！", e.parameter.channel_id)};
  
  //削除開始メッセージを送信
  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);
}
```

###コマンド受付
コマンドからのリクエストを`doPost`で受け付けています。リクエスト本体は引数`e`に格納されており、`e.parameter.(パラメータ名)`で取得できます。スラッシュコマンドの引数は`e.parameter.text`ですが、スペース区切りで複数指定していても一つの文字列として格納されているので、`split`で分割して使用する必要があります。

>this is the text portion, it includes everything after the first space following the command. It is treated as a single parameter that is passed to the app that owns the command

[Enabling interactivity with Slash Commands | Slack - Slack API](https://api.slack.com/interactivity/slash-commands)

###UNIXタイムスタンプの生成
後に`conversations.history`を叩くために引数で指定した日時を元にUNIX時のタイムスタンプを生成しています。この変換の仕方については以下の記事で説明しているのでそちらをご覧ください。

[【GAS】YYYY/MM/DD/hh/mm/ssからUNIXタイムスタンプを生成する - Qiita](https://qiita.com/yagaodekawasu/items/9bfce1a45147ffd7d9ba)

#キューの追加&トリガーの設定
```js
function addQueue(oldest, latest, channel_id){
    //引数をオブジェクトとしてまとめる
    var newQueue = {
        "oldest": oldest,
        "latest": latest,
        "channel_id": channel_id
    }
  
    cache = CacheService.getScriptCache();
  
    //キャッシュが残っている場合は削除
    if(cache.get("dates") != null){
        cache.remove("dates");
    }
    
    //キャッシュを登録
    cache.put("dates", JSON.stringify(newQueue)); 
    
    //1秒後に実行
    ScriptApp.newTrigger('executeDeletion').timeBased().after(1 * 1000).create();
  
    return;
}
```
最初はコマンドを受け付けた時点でAPIを叩いて削除が完了したらメッセージを返すという仕様にしていたんですが、削除する件数が増えた時に`doPost()`がタイムアウトするようになってしまった[^2]ため、トリガーだけ積んで一旦終了するという実装にしました。

参考：[Webhookで起動したGAS(Google AppsScript)の応答を非同期処理で高速化する - Qiita](https://qiita.com/zensai3805/items/1dc8e6f1be0499add5a6)
　　　[[GAS]実行時間6分の壁を越えよう(不死鳥関数編) - Qiita](https://qiita.com/s_maeda_fukui/items/d194c6408803229fe1b9)

トリガーが発火するタイミングは割と適当ですが、キャッシュの保持期間を過ぎないように設定する必要があります。

キャッシュの保持期間はデフォルトで**10分**ですが、`put()`の第3引数を指定することで**6時間**までは延ばせます。

GASのキャッシュには他にもデータサイズ等の規定があるので、詳しいことはマニュアルをご覧ください。

[Class Cache  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/cache/cache.html#put(String,String,Integer))

#キャッシュの取得＆削除処理の呼び出し
```js
function executeDeletion(){
    //cacheを取得
    cache = CacheService.getScriptCache();
    var data = cache.get("dates");
  
    //cacheの読み書きの競合が怖いのでなるべく早く消しておく
    cache.remove("dates");
  
    //TODO:cacheの中身がnullなら例外処理
    if(data==null){
        return;
    }
  
    //配列の中身をstrからJSON(object)に戻し，処理を実行する
    data = JSON.parse(data);
    annihilateMessages(data.oldest, data.latest, data.channel_id);
    return;
}
```

トリガーによって実行される関数です。登録したキャッシュを回収して`annihilate()`に渡す役割をしています。

本当はキャッシュが自然消滅した時の例外処理を入れるべきなんでしょうけど面倒なので`return`だけで済ませてしまっています（怠慢）。

#メインの削除処理

いよいよメッセージを削除します。大まかな流れは、

 - 削除対象となるメッセージの一覧を取得
 - 1件ずつメッセージのタイムスタンプを取得
 - リクエストにタイムスタンプを入れ、30件ずつまとめてAPI実行
 - 1.2 × (削除した件数)秒待機
 - 300件削除したら途中経過をキャッシュして中断・自動で最高

となっています。

###トリガーの削除
削除をする前に、最初に作ったトリガーを消しておきます。というのも、**プロジェクトのトリガーは自動で消えない上に上限がある**[^3]ため、放っておくとトリガーが作れなくなってエラーが生じてしまうからです。

```js
function deleteTriggers() {
    var triggers = ScriptApp.getProjectTriggers();
    for(var i=0; i < triggers.length; i++) {
        ScriptApp.deleteTrigger(triggers[i]);
    }
}
```

###削除対象となるメッセージの一覧を取得
```js
function collectHistory(oldest, latest, channel_id) {
    var payload = {
      "token" : appToken,
      "channel" : channel_id,
      "count" : 300,
      "inclusive": true,
      "latest": latest,
      "oldest": oldest
    }

    var options = {
      "method" : "GET",
      "payload" : payload
    }

    var response = UrlFetchApp.fetch("https://slack.com/api/conversations.history", options);
    return response;
}
```
API仕様：[conversations.history method | Slack](https://api.slack.com/methods/conversations.history)

`conversations.history`を使えば、DMやプライベートチャンネルなどチャンネルの種類を気にせず履歴を取得できます。その代わりアプリケーションにあらゆるチャンネルのread権限を付与する必要があるので注意してください。

一度に取得するメッセージ件数の上限は1000件ですが、後述するSlack APIのrate limitのため一度に300件以上削除しようとすると`annihilate()`がタイムアウトしてしまうため、メッセージの取得も300件単位にしています。

また、レスポンスには該当期間に`count`で指定した数より多くのメッセージが含まれているかどうかを表す`has_more`パラメータが含まれており、**300件処理したあとで削除を続けるか**の判定でこの値を利用しています。

###削除APIの実行
```js
    var response = collectHistory(oldest, latest, channel_id)
    var parsed = JSON.parse(response)
    var count = parsed.messages.length
    
    for(var i = 0; i < Math.floor(count / 30) + 1; i++) {
        var requests = []
    
        //一度にfetchする件数をカウントするための変数
        var num = 0
        
        for(var j = 0; j < 30; j++){
            //残り30件を下回った場合、インデックスエラーを起こさないための処理
            if(j >= count - 30 * i) {
                break
            }
          
            var timestamp = parsed.messages[j + i * 30].ts;
            
            var payload = {
                "token": appToken,
                "channel" : channel_id,
                "ts": timestamp
            }
            
            var request = {
                "url": "https://slack.com/api/chat.delete",
                "method": "post",
                "payload": payload
            }
            
            requests.push(request);
            num += 1;
            
            //300件以上ある時は途中経過をキャッシュして中断＆自動再開
            if(parsed.has_more == true && 30 * i + j == 299){
                addQueue(oldest, timestamp, channel_id);
                postMessage(channel_id, "annihilateの実行時間がまもなく6分を越えるため、途中経過をキャッシュします！中断した処理は自動的に再開されます！");
            }
        }
        
        var responses = UrlFetchApp.fetchAll(requests);
        
        //TooManyRequestsException用例外処理
        responses.forEach(function(r) {
            if(r.getResponseCode() == 429) {
                var text = Utilities.formatString("rate limitを超過しました。%s件削除しましたが、%s件はスキップされます。", 30 * i, count - 30 * i);
                postMessage(channel_id, text);
                return
            }
        })
        
        //最終ラウンド（has_more: falseかつi == Math.floor(count / 30)）以外は、rate limit対策のため（1.2 * num）秒スリープ
        if(parsed.has_more == true || i != Math.floor(count / 30)){
            Utilities.sleep(1200 * num);
        }
    }
    
    postMessage(channel_id, Utilities.formatString("%s件削除しました！", count))
}
```
`UrlFetchApp.fetchAll()`は「**並行処理ってかっこよくない？**」っていう理由だけで使ってみました（もっともらしい理由を頑張って探してみましたが、特に思いつきませんでした）。エラーなく同時に実行できる上限は約30件だそうです。今の所問題なく動いています。

参考：[Class UrlFetchApp  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app#fetchallrequests)
　　　[Google Apps Scriptで並列処理をしたい - Qiita](https://qiita.com/tanaike/items/f6da88f0f769f6096c37#worker%E3%81%AE%E6%9C%80%E5%A4%A7%E6%95%B0)

1.2秒というのは、`chat.delete`のrate limitが`50+ per minute`だったため、60 ÷ 50 = 1.2ということです。GASの関数の実行時間の上限が6分 = 360秒なので、360 ÷ 1.2 = 300件を一度に削除する上限としています。

なので、300件目を削除し終えたところで先程の`has_more`を確認して、`true`（＝まだ削除していないメッセージがある）であれば続行ということになります。

全て削除し終えたら、チャンネルに削除完了のメッセージを投稿して終了します。

#中断したい時
うっかり指定する日時を間違えてしまって処理を途中で止めたい時は、未来の日付など、削除件数が0件になるように引数を設定して再度`/annihilate`を実行すれば割り込みができます。

![スクリーンショット (58).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507842/f099a5a3-f561-3787-3b74-e5e3450b6d8b.png)


#感想
今まで個人で作ったものの中では一番仕組みが複雑だったので、いい経験になりました。

途中`annihilate()`がタイムアウトしていることに気付かず、何度試しても処理が途中で中断してしまうせいで一度開発を諦めたんですが、数カ月後に再開して中断するまでに削除した件数を数えたら毎回300件だったので、そこで何とか軌道修正できて一応の完成を見ることができました。

GASやSlack APIの仕様の壁をどう乗り越えるか考えるのはスリリングで楽しかったので、また何か作ってみたいです。

最後までご覧頂きありがとうございました。

[^1]: 英語で「殲滅する」
[^2]: GASの関数の実行時間は（通常のアカウントの場合）最大6分です。
 参考：https://developers.google.com/apps-script/guides/services/quotas#current_limitations
[^3]: 上限は1プロジェクト当たり20個です。
 参考：同上