はじめに
個人用のワークスペースに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件削除するごとに途中経過をキャッシュして中断・自動再開する。
デモ
中断・自動再開のイメージは↓こんな感じです。
処理のフロー
おおまかにですが、下のような感じです。
コマンド実行時のイベントを緑、バックグラウンドで走るイベントを灰色で表しています。
トリガーを作成して時間差で実行という形にすることでバッチ処理を実現しています。
ソースコード
長い(と言っても200行くらいですが)のでGitHubに置いてます。比較的丁寧に注釈を入れたので、上で述べた仕様がどう実装されているかはご覧いただければおわかりになるかと思います。
改変含めご自由に使っていただいて構いませんが、念の為最初は重要度の低いチャンネルで1件ずつの削除を試していただくことをお勧めします。
スラッシュコマンドの作り方
先人による素晴らしいハウツーがございますので、ここでは割愛します。
Slash CommandsとGASでSlackのオリジナルコマンドをつくる
コマンド受付&キューイング命令&削除開始メッセージの投稿
例外処理は割愛しています。
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
UNIXタイムスタンプの生成
後にconversations.history
を叩くために引数で指定した日時を元にUNIX時のタイムスタンプを生成しています。この変換の仕方については以下の記事で説明しているのでそちらをご覧ください。
【GAS】YYYY/MM/DD/hh/mm/ssからUNIXタイムスタンプを生成する - Qiita
キューの追加&トリガーの設定
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
[GAS]実行時間6分の壁を越えよう(不死鳥関数編) - Qiita
トリガーが発火するタイミングは割と適当ですが、キャッシュの保持期間を過ぎないように設定する必要があります。
キャッシュの保持期間はデフォルトで10分ですが、put()
の第3引数を指定することで6時間までは延ばせます。
GASのキャッシュには他にもデータサイズ等の規定があるので、詳しいことはマニュアルをご覧ください。
Class Cache | Apps Script | Google Developers
キャッシュの取得&削除処理の呼び出し
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ため、放っておくとトリガーが作れなくなってエラーが生じてしまうからです。
function deleteTriggers() {
var triggers = ScriptApp.getProjectTriggers();
for(var i=0; i < triggers.length; i++) {
ScriptApp.deleteTrigger(triggers[i]);
}
}
削除対象となるメッセージの一覧を取得
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
conversations.history
を使えば、DMやプライベートチャンネルなどチャンネルの種類を気にせず履歴を取得できます。その代わりアプリケーションにあらゆるチャンネルのread権限を付与する必要があるので注意してください。
一度に取得するメッセージ件数の上限は1000件ですが、後述するSlack APIのrate limitのため一度に300件以上削除しようとするとannihilate()
がタイムアウトしてしまうため、メッセージの取得も300件単位にしています。
また、レスポンスには該当期間にcount
で指定した数より多くのメッセージが含まれているかどうかを表すhas_more
パラメータが含まれており、300件処理したあとで削除を続けるかの判定でこの値を利用しています。
削除APIの実行
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
Google Apps Scriptで並列処理をしたい - Qiita
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
を実行すれば割り込みができます。
感想
今まで個人で作ったものの中では一番仕組みが複雑だったので、いい経験になりました。
途中annihilate()
がタイムアウトしていることに気付かず、何度試しても処理が途中で中断してしまうせいで一度開発を諦めたんですが、数カ月後に再開して中断するまでに削除した件数を数えたら毎回300件だったので、そこで何とか軌道修正できて一応の完成を見ることができました。
GASやSlack APIの仕様の壁をどう乗り越えるか考えるのはスリリングで楽しかったので、また何か作ってみたいです。
最後までご覧頂きありがとうございました。
-
英語で「殲滅する」 ↩
-
GASの関数の実行時間は(通常のアカウントの場合)最大6分です。
参考:https://developers.google.com/apps-script/guides/services/quotas#current_limitations ↩ -
上限は1プロジェクト当たり20個です。
参考:同上 ↩