🐴

GAS,Slack:チャンネルにユーザーを大量追加した際の、膨大な「hogeさんと他N人が参加しました」メッセージを削除する

2022/10/22に公開約16,500字

どうもbarusuです。

情シスSlackにて、全パブリックチャンネルを対象にチャンネル未参加ユーザーを全員招待する作業をしたところ、過去投稿が見えなくなるという事象が起きました。

何が起きたのか

どこのチャンネルも膨大な参加通知を読み込まないと、過去ログが表示されない
つまり、過去の投稿が見えなぁい!!

こりゃ大変だね!!!

ということで、この問題を解決していきます。

解決策を模索する

DMでおかしんさんとやり取り

なるほど、やってみよう

必要な処理を設計する

  1. Channelの投稿履歴を取得する(conversations.history)
  2. 削除対象の投稿を特定する(If文で対応)
  3. 投稿を削除する(chat.delete)

必要な処理を書いていく

1. Channelの投稿履歴を取得する(conversations.history)

使うAPI

https://api.slack.com/methods/conversations.history

GASの記述箇所

  let postOptions = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": botToken
    }
〜中略〜

let result = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/conversations.history?channel=' + [対象チャンネル], postOptions));
      let messages = result.messages;

2. 削除対象の投稿を特定する(If文で対応)

{
            "type": "message",
            "subtype": "channel_join",
            "ts": "1666439305.111629",
            "user": "U01QY----",
            "text": "<@U01------>さんがチャンネルに参加しました"
        }

GASの記述箇所

 if (messages[k].subtype == 'channel_join') {
        //処理を書く
	}

chat.delete

Reference
https://api.slack.com/methods/chat.delete

GASの記述箇所

〜中略〜
let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": [対象チャンネル],
              "ts": [対象投稿のts]
            }
          };
〜中略〜
let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));

必要なBotを準備する

作成方法は割愛します
必要なPermissionはこちら

GASを書いてみる

紆余曲折あって、完成形はこうなりました
仕上げはおかしんさんがやってくれた。感謝。

const ss = SpreadsheetApp.openById('1V7----');
const sheet = ss.getSheetByName('****');
const botToken = "xoxb-";
const userToken = "xoxp-";
function deletePost_channel_join() {
  let lastRow = sheet.getLastRow();
  let lastCol = sheet.getLastColumn();

  // シートから範囲を取得してリストにぶちこむ
  let array = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
  let postOptions = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": botToken
    }
  };
  // Channelリストから投稿履歴を取得
  for (let i = 0; i < array.length; i++) {
    if (array[i] != '') {
      let result = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/conversations.history?channel=' + array[i][0], postOptions));
      let messages = result.messages;

      // channel_join の投稿を削除
      for (let k = 0; k < messages.length; k++) {
        if (messages[k].subtype == 'channel_join') {
          let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": array[i][0],
              "ts": messages[k].ts
            }
          };
          let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
        }
      }
    }
  }
}

これを複数Projectにして並列処理する

テストしてみた結果

我々の勝利である

さいごに

コミュニティ運営って大変なのよ...
無料で運営してくれているし、別に参加者は客ってわけじゃないんだから、こういうときこそ何か自分にできることがないかな?って考えて動いていきたいですよね。

助け合いの精神を持って、コミュニティへ貢献していきましょう。

ではでは。

Appendix

その後頂いたコメント

ペンネーム:a03は情シス1年生さんからのお便り

1.APIの1000件制限

<コメント引用>
https://api.slack.com/methods/conversations.history
Optional arguments のcursorを使うことで、ページネーション分の処理を回すことで1000件以上の処理が可能になります。
ページャー対応の例(手前味噌でごめんなさい
https://github.com/ymgcmnk/Slack_conversations.list/blob/main/channel_List.gs


barusuコメント

これはちょっと前提が違うので訂正させてください。
本件でAPI limitが問題になったのはDelete処理とGASの実行時間制限なので1000件うんたらは異なります。
が、ご指摘はご尤もです。
普通に今回の処理を書くならChannelHistoryのResponseにNextCursorいる場合は再度実行する旨のループを書くべきです。
よって、forではなくDoWhile文を用いるのが正攻法と思います。
ではなぜ今回forで回したのかというと、完全に私の手癖というか、状況に応じてささっと書いたからですね。
(言い訳がましいですけど)既に本番に影響が出ていて、とりあえずこれでいけそうだという方策が出てきた、API単独でのテストも問題ないぞ、じゃあ作るかという段階で書いたのですね。
実行時に未知の問題が出ても、GAS実行による影響がコントローラブルな方が望ましいという前提に立ち、対象チャンネルの全投稿を取得して書く処理にするよりは1000件のLimitはあるけども多少手作業で実施してでも、取り急ぎ小さい機能単位を担保できるGASが完成される方が望ましいだろうなと考えました。

2.反復メソッドを使ったfor文の高速化

<コメント引用>
2.反復メソッドを使ったfor文の高速化
配列に対してはfilterやmap処理することで、for文で回すよりも高速化できるかもしれません。
filter
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
map
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

barusuコメント

そうですねー
今回のケースで試してはいませんけど...
私の考えとしては、たかだか数千件程度であれば有意差は生まれない&API実行時の制約の方がボトルネックになるからforでいいやって考えた次第です。
▼参考
https://matsnow.hatenablog.com/entry/benchmark/foreach-vs-map
https://qiita.com/t-yama-3/items/84e28a24fc2e12dc9ebb
https://qiita.com/t-yama-3/items/84e28a24fc2e12dc9ebb
こちらの記事だと100万件で比較しており、それでもms単位での差なのでまあいいかーって感じです。
検索だと差があるんですけどね。今回は検索しないやろって思ったので。

3.エンプラプランでは、参加、退出メッセージの制限できないんですね

https://slack.com/intl/ja-jp/help/articles/115002695043-参加��%[…]ージを管理する-

barusuコメント

まあそうですねー
でも困ったことないなーって感じです。
まあそもそもEnterpriseGridだったらAdmin.channel.invite が使えるし今回のようなことにはならんかったかもなって感じで捉えてます。
AdminAPI系をGASで使うときは認証の取り回しがだるいですけど、だいたいやりたいことできるしまあいんじゃねって思います

https://api.slack.com/admins

コメントを頂いたので追記:今回のケースを最初から完璧に対応するならば

  1. これやってればそもそも発生しないよねってやつ
    a. チャンネルにユーザー追加→投稿削除をセットで実施

  2. 発生後に対応するなら
    a. ChannelHistoryのResponseにNextCursorが入ってる場合の処理を考慮(Do-While構文)
    b. さらに、削除対象とする範囲を絞る(今回は土曜に実施したので2022/10/22 0:00 〜23:59のtsで範囲を限定する)
    c. 管理者のSlackUserを複数作成し、UserTokenを複数払い出して分散実行
    d. そもそもGASなんて使わずにLambdaなりCloudFunctionなり使う
    e. 終了条件を決めて、Script実行完了して対象チャンネルの全ての削除対象投稿が削除できたらall-announceに投稿Postする処理を追加

とかは盛り込むと思います。
リードタイムが1週間あるなら上記a~c + (d | e)って感じですね。
全部対応しろって言われたら2週間は欲しいかも。

SlackのAPI Limitを回避するためにGASを並列で動かすに至るまでのDMやり取り


[okash1n - BTCON2023皆来てね]
単に一回の処理が結構かかるのよね。tsのkに対してforしてるんだけど、そのforが結構時間かかってGASがタイムアウトする
Slackの方は秒間50だから50並列くらいまで全然問題なくて
チャンネルID変えたGAS何個も作って並列で走らせればGASのタイムアウトは避けれるでしょ


[barusu]
per minuteだから1分間で50じゃない?
https://api.slack.com/docs/rate-limits


[okash1n - BTCON2023皆来てね]
perか
じゃあきびしいな
まあでも並列が一番はやいと思われる
さっき7000くらい消さないといけないチャンネルあってlimit7000で回したら全然すぐにタイムアウトした


[barusu]
SlackAPIのLimitに到達してGASがwaitして処理しきれなくてGASがタイムアウトって感じかな


[okash1n - BTCON2023皆来てね]
GASのタイムアウト何分だっけ


[barusu]
標準環境だと6分
だったはず
Enterpriseだと30分


〜中略〜


[okash1n - BTCON2023皆来てね]
limitは2000でも1000でも一緒だなあ。
一回たたくごとに100件くらいしかきえてない
deleteの方はlimitの仕様ないかー
こりゃだめだー。
deleteの方のAPIのLimitがボトルネックだ


[barusu]
なるほどー


[okash1n - BTCON2023皆来てね]
GASのトリガーで1分ごとに回すか
1リクエストで1tsしか消せないから、
推定 6000x20 =12万ポストくらい消さないといけないから
40時間くらいかかるぞこれw
GASのトリガーで毎分回すのが正攻法な気がする
まずは、無事なチャンネル(もとからたくさん入ってたチャンネル)をリストから除外して
んでから回すか


[barusu]
GASを毎分動かして50件削除で回し続けるってことか


[okash1n - BTCON2023皆来てね]
それしかないかなと


[barusu]
そうだなぁ


[okash1n - BTCON2023皆来てね]
だからむしろ history のlimitは50の方がいい気がする
いや subtypeに合致しないものもあるから100くらいがいいのか
messagesに1000入ったところであまり意味がない


[barusu]
100にして、kループをちょっと変える感じかな


[okash1n - BTCON2023皆来てね]
そうそう
タイミングによっては60件とか80件消せるときもあるからきっちり分間50ってわけでもなさそう
前か後の1分間の分を使ってるのかもだけど


[barusu]

k < messages.length
を k < deleteCount にして
let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
の下に deleteCount += 1かな
a,ちがう


[okash1n - BTCON2023皆来てね]
subtypeの判定した後に一回とじて、 deleteはもう一個 for作って j<50 とかでまわすとか?
それだと変数のスコープがめんどくさいのか。
もういっそ 決め打ちで k<60とかでいいのかも
いや違う。それだとその中にマッチが一つもなかったら何もおこらねえ


[barusu]
今最新投稿はほとんどないからそれでも一旦良いんじゃないかな?


[okash1n - BTCON2023皆来てね]
だよねー
トリガーするなら安定稼働するものを作ってから回したい


[barusu]
じゃちょっと直すね


[okash1n - BTCON2023皆来てね]
ワイはチャンネルのリストのスリム化やる

〜10分後〜


[barusu]
直した
動くと思うけどこっちの環境だとテストできない条件だから確認よろしく


[okash1n - BTCON2023皆来てね]
勝ったわ
5並列で↑まわして、さらにそれぞれを1分ごとに再実行してる


[barusu]
すばらしい

〜なお、この後も課題発生と解決のやり取りが続く〜

インクリメントどうするか問題が発生し、協議し、一旦暫定解決をし、根本解決をするまでのDM

[okash1n - BTCON2023皆来てね]
よくみると deletecountがインクリメントされていない?

for (let k = 0; k < 70; k = k + deleteCount) {
        deleteCount = 0;
        if (messages[k].subtype == 'channel_join') {
          let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": channel,
              "ts": messages[k].ts
            }
          };
          let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
          deleteCount = 1;
        }
      }

ループごとにdeleteCountが0に戻るから実質 kは k++ っぽい?


[barusu]
あっ
やべ、それ直さなきゃ


[okash1n - BTCON2023皆来てね]
まあでも動いてはいますよ。 k++でも回数は多くなる気はしますけど


[barusu]
消す投稿がなくなったらdeleteCountが増えないからループ止まらない


[okash1n - BTCON2023皆来てね]
いやそんなことはないでしょ
いやそんなことあるわ


[barusu]
あるのよ


[okash1n - BTCON2023皆来てね]
deletecountの宣言は上のほうか


[barusu]
そうなの


[okash1n - BTCON2023皆来てね]
kはk++でよくて
deleteCountを一番したで deleteCount ++すればいいのでは
forのなかの deleteCount =0はいらなさそう


[barusu]
deleteCount または k が50超えたらって式にするべきやな


[okash1n - BTCON2023皆来てね]
(let k = 0; deleteCount < 50; k ++)
こうか
ん?
kじゃなくていいのか
わかんなくなってきた


[barusu]
消すべき元の投稿量が多いから問題になってないだけで、対象投稿が50を下回る処理になったらGASのプロセスが止まらなくなると思う


[okash1n - BTCON2023皆来てね]

const ss = SpreadsheetApp.openById('****');
const sheet = ss.getSheetByName('****');
const botToken = "xoxb-60h6";
const userToken = "xoxp-60";
function deletePost_channel_join() {
  //let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('ChannelHistory');
  let lastRow = sheet.getLastRow();
  let lastCol = sheet.getLastColumn();
  let deleteCount = 0;
  // シートから範囲を取得してリストにぶちこむ
  let array = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
  let postOptions = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": botToken
    }
  };
  // Channelリストから投稿履歴を取得
  for (let i = 0; i < array.length; i++) {
    if (array[i] != '') {
      let result = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/conversations.history?channel=' + array[i][0], postOptions));
      let messages = result.messages;
      // channel_join の投稿を削除
      for (let k = 0; deleteCount < 50; k++) {
        if (messages[k].subtype == 'channel_join') {
          let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": array[i][0],
              "ts": messages[k].ts
            }
          };
          let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
          deleteCount++;
        }
      }
    }
  }
}

多分こう?
kは取得したhistoryの件数(100件分)インクリメントしていって、終了は deleteCount つまり subtypeの条件の数が50に達したら止まる


[barusu]
これだとまだ止まらない
deleteCount + k <50
でいけると思う (編集済み)
あ、違うな
(deleteCount + k )/2 <50
かな?
取り急ぎ直すなら (編集済み)
条件一致で投稿削除→deleteCount ++, k ++
条件一致せず→k ++


[okash1n - BTCON2023皆来てね]
deleteCount < 50 || k < 50
これは?


[barusu]
それでもok


[okash1n - BTCON2023皆来てね]

forの条件式の中に deleteCountが入ってくると何故か↓になるな
TypeError: Cannot read property 'subtype' of undefined


[barusu]
🤔
ためしてみる


[barusu]
再現した
普通の投稿を拾ったときにエラー吐く模様
ちょっと直しが必要
暫定でTry-Catchでくくるのも良いかもしれない


[okash1n - BTCON2023皆来てね]
latestから見ていってるからarray[i]をとりあえず全部やればちゃんときえていくはずなので
k < array[i].length
にした
余計な試行もあるけどまあいいでしょう


[barusu]
pjt-outputとかみたいに最新投稿が普通の投稿の場合はエラーで止まると思う


[okash1n - BTCON2023皆来てね]
deletecountをやめてみた
Log見てる感じではいいかんじ

const ss = SpreadsheetApp.openById('****');
const sheet = ss.getSheetByName('****');
const botToken = "xoxb-";
const userToken = "xoxp-";
function deletePost_channel_join() {
  //let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  let lastRow = sheet.getLastRow();
  let lastCol = sheet.getLastColumn();
  //let deleteCount = 0;
  // シートから範囲を取得してリストにぶちこむ
  let array = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
  let postOptions = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": botToken
    }
  };
  // Channelリストから投稿履歴を取得
  for (let i = 0; i < array.length; i++) {
    if (array[i] != '') {
      let result = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/conversations.history?channel=' + array[i][0], postOptions));
      let messages = result.messages;
      // channel_join の投稿を削除
      for (let k = 0; k < array[i].length; k++) {
        if (messages[k].subtype == 'channel_join') {
          let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": array[i][0],
              "ts": messages[k].ts
            }
          };
          let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
          Logger.log(deleteResult)
        }
      }
    }
  }
}

array[i]が常に100件なので、これで大丈夫そう。
違う違う。
messages.lengthか
あー、いい感じになりました

const ss = SpreadsheetApp.openById('****');
const sheet = ss.getSheetByName('****');
const botToken = "xoxb-";
const userToken = "xoxp-";
function deletePost_channel_join() {
  //let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet1');
  let lastRow = sheet.getLastRow();
  let lastCol = sheet.getLastColumn();
  //let deleteCount = 0;
  // シートから範囲を取得してリストにぶちこむ
  let array = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
  let postOptions = {
    "method": "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": botToken
    }
  };
  // Channelリストから投稿履歴を取得
  for (let i = 0; i < array.length; i++) {
    if (array[i] != '') {
      let result = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/conversations.history?channel=' + array[i][0], postOptions));
      let messages = result.messages;
      // channel_join の投稿を削除
      for (let k = 0; k < messages.length; k++) {
        if (messages[k].subtype == 'channel_join') {
          let deleteOptions = {
            "method": "post",
            "contentType": "application/x-www-form-urlencoded",
            "payload": {
              "token": userToken,
              "channel": array[i][0],
              "ts": messages[k].ts
            }
          };
          let deleteResult = JSON.parse(UrlFetchApp.fetch('https://slack.com/api/chat.delete', deleteOptions));
          Logger.log(deleteResult)
        }
      }
    }
  }
}

いいかんじ。失敗してるのは全部API limit

not_foundはおそらく並列で回してるから、他のやつが先に削除しちゃってる
あと16チャンネル。
残ってるやつは全部4000件超えてるのでこのまま放置する。
並列は5に落とした


[barusu]
念のためこっちで準備した検証環境で試してみる


[okash1n - BTCON2023皆来てね]
さんきゅー
とりあえず家かえる


[barusu]
おつかれ
普通の投稿だとsubtypeは無いんだよね
問題ないっぽい
放置で大丈夫そうね


[okash1n - BTCON2023皆来てね]
ほんまや
subtypeないのかw


[barusu]
そうなのよw


[okash1n - BTCON2023皆来てね]

messages[k]が存在してる限りsubtypeはUndefinedではないけどnullなんじゃないかな
messages[k]が存在してないとUndefined
そもそもさっき出てたsubtype Undefinedも割と謎だった


[barusu]
そうっすよね


[okash1n - BTCON2023皆来てね]
理屈では条件式に deleteCount入れたってsubtypeには関係なかったはず


[barusu]
うん


[okash1n - BTCON2023皆来てね]
謎エラーだった


[barusu]
だから不可解
まぁ動いてるからヨシ!!


[okash1n - BTCON2023皆来てね]

forの条件式部分のところの変数のスコープってなんか特殊だったりすんのかね。
まあうごいてるからヨシ


〜2時間後〜


[okash1n - BTCON2023皆来てね]

if(typeof subtype === "undefined"){
          continue;

魔法のことば


[barusu]
あーそれそれ
ぽぽぽぽーん

蛇足のまとめ

色々あったけどおかしんさんと一緒に問題解決できて楽しかったです (^q^)

終わり

Discussion

ログインするとコメントできます