GASを用いて無料版のSlackでも投稿とファイルを全て自動でGoogleDriveに保存する

初投稿です。
無料版のSlackでは容量制限があり、どんどん投稿が見えなくなっていってしまう。
投稿を削除しようにも、まずログをとりたいがSlackの機能ではプライベートチャンネルの投稿を保存できない...
無料版のSlackを管理している人であれば誰でも思ったことがあるであろうこの悩みをGAS(Google Apps Script)を用いて解決します。

## 1. SlackAPIContoroller.gsの準備
まず最初にコードを示します

```
// Slack へのアクセサ
var SlackAccessor = (function () {
  function SlackAccessor(apiToken) {
    this.APIToken = apiToken;
  }

  var MAX_HISTORY_PAGINATION = 10;
  var HISTORY_COUNT_PER_PAGE = 1000;

  var p = SlackAccessor.prototype;

  // API リクエスト
  p.requestAPI = function (path, params) {
    if (params === void 0) { params = {}; }
    var url = "https://slack.com/api/" + path + "?";
    var qparams = [("token=" + encodeURIComponent(this.APIToken))]; //APItokenはここで入れておき、paramがある場合はこれに追加していって&で繋ぐ
    for (var k in params) {
      qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
    }
    url += qparams.join('&');

    //console.log("==> GET " + url);

    var response = UrlFetchApp.fetch(url);
    var data = JSON.parse(response.getContentText());
    if (data.error) {
      console.log(data);
      console.log(params);
      throw "GET " + path + ": " + data.error;
    }
    return data;
  };

  // メンバーリスト取得　メンバーIDと名前の辞書を作る
  p.requestMemberList = function () { 
    var response = this.requestAPI('users.list');
    var memberNames = {};
    response.members.forEach(function (member) {
      var name = member.profile.display_name
      if (!name) {name = member.profile.real_name}
      memberNames[member.id] = name;
      //console.log("memberNames[" + member.id + "] = " + member.profile.display_name);
    });
    return memberNames;
  };

  // チャンネル情報取得
  p.requestChannelInfo = function (cursor) {
    var _this = this;
    var options = {};
    if (cursor === void 0 ) {cursor = '1';}
    //options['cursor']= cursor; //チャンネル数が1000以上の時は必要
    options['types'] = 'public_channel,private_channel';
    options['limit'] = 1000;
    var response = _this.requestAPI('conversations.list',options);
    //response.channels.forEach(function (channel) {console.log("channel(id:" + channel.id + ") = " + channel.name);});
    return response.channels;
  };

  // 特定チャンネルのメッセージ取得
  p.requestMessages = function (channel, timestamp,which_ward) {
    var _this = this;
    var messages = [];
    var options = {};
    options['count'] = HISTORY_COUNT_PER_PAGE; //一回あたりに取る投稿数
    options['channel'] = channel.id;
    options['inclusive'] = true;
    //引数で与えた最低限とれば良いoldest or latestの値を代入しておく
    options[which_ward] = timestamp; 
    
    // oldest or latestを指定してAPIを叩く関数
    var loadChannelHistory = function (timestamp) {
      if (timestamp) {
        options[which_ward] = timestamp; 
      }
      var response = _this.requestAPI('conversations.history', options);
      messages = response.messages.concat(messages);
      return response;
    };
    
    //　一回で投稿を取りきれない場合があるので、MAX_HISTORY_PAGINATION回に分けて投稿を取る
    var resp = loadChannelHistory();
    var page = 1;
    let position = 0;
    if (which_ward =='latest'){position = -1;} else if (which_ward == 'oldest'){position = 0;}
    while (resp.has_more && page <= MAX_HISTORY_PAGINATION) {
      resp = loadChannelHistory(resp.messages[position].ts);
      page++;
    }
    console.log("channel(id:" + channel.id + ") = " + channel.name + " => loaded messages.");
    // 最新レコードを一番下にする
    return messages.reverse();
  };

  // 特定チャンネルの特定のスレッドのメッセージ取得
  p.requestThreadMessages = function (channel, ts_array, oldest) {
    var all_messages = [];
    let _this = this;
    var loadThreadHistory = function (options, oldest) {
      if (oldest) {
        options['oldest'] = oldest;
      }
      Utilities.sleep(1250);
      var response = _this.requestAPI('conversations.replies', options);

      return response;
    };
    ts_array = ts_array.reverse();

    ts_array.forEach(ts => {
      if (oldest === void 0) { oldest = '1'; }

      let options = {};
      options['oldest'] = oldest;
      options['ts'] = ts;
      options['count'] = HISTORY_COUNT_PER_PAGE;
      options['channel'] = channel.id;
      options['inclusive']=true;

      let messages = [];
      let resp;
      resp = loadThreadHistory(options);
      messages = resp.messages.concat(messages);
      var page = 1;
      while (resp.has_more && page <= MAX_HISTORY_PAGINATION) {
        resp = loadThreadHistory(options, resp.messages[0].ts);
        messages = resp.messages.concat(messages);
        page++;
      }
      // 最初の投稿はスレッド元なので削除
      messages.shift();
      // 最新レコードを一番下にする
      all_messages = all_messages.concat(messages);
      // console.log("channel(id:" + channel.id + ") = " + channel.name + " ts = " + ts + " => loaded replies.");
    });
    return all_messages;
  };
  return SlackAccessor;
})();
```
1.と後述する2.でやっていることは、pythonでいうclassの作成みたいなものです。(自分はそうするとイメージしやすかった)
主な関数については以下に説明を付しておきます。
###・requestMemberList
メンバーIDと名前の辞書を作ります。
投稿者名の表示やメンション先の表示に使います。
###・requestChannelInfo
チャンネルの一覧を取得します。
これの```options['types'] = 'public_channel,private_channel'```の部分をいじることでプライベートチャンネルやDMなど、チャンネル形跡を指定することができます。
なお、取得できるチャンネルはAPItokenを発行している人が入っているチャンネルのみなのでそこはして注意ください。
###・requestMessages
チャンネルのメッセージを取得します。
これのoptionで、oldestが取得する投稿の最も古いtimestampを指定、latestが最も新しいtimestampを指定します。実行時間制限があるGASではこれの指定が重要です。
基本的にはoldestの指定の方が便利なのですが、何回か回していてlatestを指定したくなる時もあったので、これは引数で選択できるようにしました。この使い分けについては3. で後述します。

###・requestThreadMessages
指定したtimestampの投稿に対するスレッドを取得します。
```Utilities.sleep(1250)```でAPI利用制限にかからないようにしています。

## 2. SpreadSheetContoroller.gsの準備
こちらもコードを最初に示しておきます。

```
// スプレッドシートへの操作
var SpreadsheetController = (function () {
  function SpreadsheetController(spreadsheet, folder) {
    this.ss = spreadsheet;
    this.folder = folder;
  }

  const COL_DATE = 1; // 日付・時間(タイムスタンプから読みやすい形式にしたもの)
  const COL_USER = 2; // ユーザ名 
  const COL_TEXT = 3; // テキスト内容
  const COL_URL = 4;  // URL
  const COL_LINK = 5; // ダウンロードファイルリンク
  const COL_TIME = 6; // 差分取得用に使用するタイムスタンプ
  const COL_THREAD_TS = 7; // スレッドの投稿はTHREAD_TS、リプライなし投稿はTS　並べ替えにも使用
  const COL_IS_REPLY = 8; // リプライのとき2，リプライ元は1,リプライなし投稿は0
  const COL_JSON = 9; // 念の為取得した JSON をまるごと記述しておく列

  const COL_MAX = COL_JSON;  // COL 最大値

  const COL_WIDTH_DATE = 130;
  const COL_WIDTH_TEXT = 800;
  const COL_WIDTH_URL = 400;

  var p = SpreadsheetController.prototype;

  // シートを探してなかったら新規追加
  p.findOrCreateSheet = function (sheetName) {
    var sheet = null;
    var sheets = this.ss.getSheets();
    sheets.forEach(function (s) {
      var name = s.getName();
      if (name == sheetName) {
        sheet = s;
        return;
      }
    });
    if (sheet == null) {
      sheet = this.ss.insertSheet();
      sheet.setName(sheetName);
      // 各 Column の幅設定
      sheet.setColumnWidth(COL_DATE, COL_WIDTH_DATE);
      sheet.setColumnWidth(COL_TEXT, COL_WIDTH_TEXT);
      sheet.setColumnWidth(COL_URL, COL_WIDTH_URL);
      sheet.deleteColumns(10, 17); //セル数節約のため不要な列は削除
      sheet.deleteRows(2, 999); //セル数節約のため不要な列は削除
    }
    return sheet;
  };

  // チャンネルからシート名取得
  p.channelToSheetName = function (channel) {
    return channel.name + " (" + channel.id + ")";
  };
  
  // チャンネル名に基づくシート名一覧を記載するシートを作る
  p.saveChannelsName = function(channelInfo,sheetName){
    var sheet = this.findOrCreateSheet(sheetName);
    var record = [];
    for (let ch of channelInfo){
      var row = [];
      var channelSheetName = ch.name + " (" + ch.id + ")";
      row.push(channelSheetName);
      record.push(row);
    } 
    if (record.length > 0) {
      var range = sheet.getRange(2, 1, record.length, 1);
      range.setValues(record);
    }
    sheet.getRange(2,1,record.length,1).sort(1);
  };
  // チャンネルごとのシートを取得
  p.getChannelSheet = function (channel) {
    var sheetName = this.channelToSheetName(channel);
    return this.findOrCreateSheet(sheetName);
  };
  // シートをtsで重複削除した上で投稿別スレッド別でソートする
  p.sortSheet = function (channel) {
    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    var lastCol = sheet.getLastColumn();
    var range = sheet.getRange(1, 1, lastRow, lastCol);
    range.removeDuplicates([COL_TIME]);
    range.sort(COL_TIME);//純粋な投稿時間でソート
    range.sort(COL_THREAD_TS); //thread_ts = 大元の投稿の投稿時間でソート
    var lastRow = sheet.getLastRow();
    var lr = sheet.getRange("A:A").getLastRow();
    var len = lr-lastRow;
    if (len>0){sheet.deleteRows(lastRow+1, len);} //セル数節約のため不要な列は削除
  };

  // 最初に記録したタイムスタンプ取得
  p.getFirstTimestamp = function (channel, is_reply) {
    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    if (lastRow > 0) {
      let row_of_first_update = 0;
      if (is_reply) {
        for (let row_no = 1; row_no <= lastRow; row_no++) {
          if (parseInt(sheet.getRange(row_no, COL_IS_REPLY).getValue()) == is_reply) {
            row_of_first_update = row_no;
            break;
          }
        }
        if (row_of_first_update === 0) {
          return '9999999999.9999';
        }
      } else {
        row_of_first_update = 1;
      }
      console.log('first timestamp row: ' + row_of_first_update + ' first timestamp: ' + sheet.getRange(row_of_first_update, COL_TIME).getValue());
      return sheet.getRange(row_of_first_update, COL_TIME).getValue();
    }
    return '9999999999.9999';
  };
  
  // 最後に記録したタイムスタンプ取得
  p.getLastTimestamp = function (channel, is_reply) {
    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    if (lastRow > 0) {
      let row_of_last_update = 0;
      if (is_reply) {
        for (let row_no = lastRow; row_no >= 1; row_no--) {
          if (parseInt(sheet.getRange(row_no, COL_IS_REPLY).getValue()) == is_reply) {
            row_of_last_update = row_no;
            break;
          }
        }
        if (row_of_last_update === 0) {
          return '1';
        }
      } else {
        row_of_last_update = lastRow;
      }
      console.log('last timestamp row: ' + row_of_last_update + ' last timestamp: ' + sheet.getRange(row_of_last_update, COL_TIME).getValue());
      return sheet.getRange(row_of_last_update, COL_TIME).getValue();
    }
    return '1';
  };

  // スレッドが存在するもの(IS＿REPLYが1)を取得
  p.getThreadTS = function (channel, first_ts) {
    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    if (lastRow > 0) {
      console.log('lastRow > 0');
      let first_row = 0;
      for (let i = 1; i <= lastRow; i++) {
        ts = sheet.getRange(i, COL_TIME).getValue();
        if (ts > first_ts) {
          first_row = i;
          break;
        }
      }
      let ts_array = [];
      if (first_row == 0) {
        return '1';
      }
      for (let i = first_row; i <= lastRow; i++) {
        if (sheet.getRange(i, COL_IS_REPLY).getValue()==1) {
          ts = sheet.getRange(i, COL_TIME).getValue();
          ts_array.push(ts.toFixed(6).toString());
        }
      }

      return ts_array;
    }
    return '1';
  };
  
  p.getThreadTSBW = function (channel, last_ts) {
    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    if (lastRow > 0) {
      console.log('lastRow > 0');
      let last_row = 0;
      for (let i = 1; i <= lastRow; i++) {
        var ts = sheet.getRange(i, COL_TIME).getValue();
        if (ts >= last_ts) {
          last_row = i;
          break;
        }
      }
      let ts_array = [];
      if (last_row == 0) {
        return '1';
      }
      for (let i = last_row; i >= 1; i--) {
        if (sheet.getRange(i, COL_IS_REPLY).getValue()==1) {
          ts = sheet.getRange(i, COL_TIME).getValue();
          ts_array.push(ts.toFixed(6).toString());
        }
      }

      return ts_array;
    }
    return '1';
  };

  // ダウンロードフォルダの確保
  p.getDownloadFolder = function (channel) {
    var sheetName = this.channelToSheetName(channel);
    return FindOrCreateFolder(this.folder, sheetName);
  };

  // 取得したチャンネルのメッセージを保存する
  p.saveChannelHistory = function (channel, messages, memberList) {
    // console.log("saveChannelHistory: " + this.channelToSheetName(channel));
    var _this = this;

    var sheet = this.getChannelSheet(channel);
    var lastRow = sheet.getLastRow();
    var currentRow = lastRow + 1;

    // チャンネルごとにダウンロードフォルダを用意する
    var downloadFolder = this.getDownloadFolder(channel);

    var record = [];
    // メッセージ内容ごとに整形してスプレッドシートに書き込み
    for (let msg of messages) {
      var date = new Date(+msg.ts * 1000); //tsをわかりやすい形式に変換
      //console.log("message: " + date);

      var row = [];

      // 日付
      var date = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
      row[COL_DATE - 1] = date;
      // ユーザー名
      row[COL_USER - 1] = memberList[msg.user] || msg.username;
      // Slack テキスト整形
      row[COL_TEXT - 1] = UnescapeMessageText(msg.text, memberList);
      // アップロードファイル URL とダウンロード先 Drive の Viewer リンク
      var url = null;
      var alternateLink = null;
      if (msg.files) {
        for (let file of msg.files){
          if (!(file.mode == 'tombstone' || file.mode == 'hidden_by_limit'|| file.mode == 'external')) {//tombstoneは画像が削除されている,hidden_by_limitは容量制限で表示されていない、externalはスプシなど外部アプリ
            // ダウンロードとダウンロード先
            //console.log('filemode: '+file.mode);
            //console.log('file:'+file.name);
            var downloadUrl = file.url_private_download;
            var driveUrl = DownloadData(downloadUrl, file.name, downloadFolder, date);
            if (url) {url = url + '\n' + downloadUrl;} else {url = downloadUrl;}
            if (alternateLink) {alternateLink = alternateLink + '\n' + driveUrl;} else {alternateLink = driveUrl;}
          }
        }
        console.log("url: " + url)
      }
      row[COL_URL - 1] = url;
      row[COL_LINK - 1] = alternateLink;
      row[COL_TIME - 1] = msg.ts;
      row[COL_IS_REPLY - 1] = 0;
      // threadの判定処理
      if ('thread_ts' in msg) {
        if (msg.ts != msg.thread_ts){
          row[COL_IS_REPLY - 1] = 2;
        } else {
          row[COL_IS_REPLY - 1] = 1;
        }
        row[COL_THREAD_TS - 1] = msg.thread_ts;
      } else {
        row[COL_THREAD_TS - 1] = msg.ts;
      }
      // メッセージの JSON 形式
      row[COL_JSON - 1] = JSON.stringify(msg);

      record.push(row);
    };
    //メッセージ挿入部分
    if (record.length > 0) {
      var range = sheet.insertRowsAfter(lastRow || 1, record.length)
        .getRange(lastRow + 1, 1, record.length, COL_MAX);
      range.setValues(record);
    }
    this.sortSheet(channel);
  };

  return SpreadsheetController;
})();
```

###・findOrCreateSheet
その名の通り、シートを取ってくる関数です。なかった場合は作ります。
###・channelToSheetName
チャンネル情報からシート名を取得します。
###・saveChannelsName
これはチャンネル名を一覧形式で出力したくなったので用意しましたが、本質的には必要ではありません。
###・getChannelSheet
チャンネル情報からシートを取得します。
###・sortSheet
timestampとthread_timestampでシートの内容をソートします。
また、スプレッドシートのセル数制限に引っかからないように、不要なセルは削除するようにしています。
###・getFirstTimeStamp
SlackAPIでlatestを指定したいときに使います。
###・getLastTimeStamp
SlackAPIでoldestを指定したいときに使います。
###・getThreadTS
SlackAPIで新しい方のthread_timestampを取得したいときに使います。
###・getThreadTSBW
SlackAPIで古い方のthread_timestampを取得したいときに使います。
###・getDownloadFolder
GoogleDrive上の、Slackファイルを保存したいフォルダを選択します。
###・saveChannelHistory
スプレッドシートに投稿やGoogleDriveにファイルを保存する上でのメインの関数です。
現状保存するファイル名は元々のファイルの名前をそのまま利用しているので、同名ファイルがあるとどんどん置き換えられてしまいます。

## 3. main.gsの準備
最後にメインのスクリプトです。

```
// FOLDER IDとSlack API tokenの取得
const FOLDER_ID = PropertiesService.getScriptProperties().getProperty('folder_id');
if (!FOLDER_ID) {
  throw 'You should set "folder_id" property from [File] > [Project properties] > [Script properties]';
}
const API_TOKEN = PropertiesService.getScriptProperties().getProperty('slack_api_token');
if (!API_TOKEN) {
  throw 'You should set "slack_api_token" property from [File] > [Project properties] > [Script properties]';
}
// フォルダ名とスプシ名の取得
const FOLDER_NAME = "*****";
const SpreadSheetName = "*****";
const AnalysisSheetName = '*****';
//エラー管理用
var exceptions = [];

// Google Driveを管理するための関数
function FindOrCreateFolder(folder, folderName) {
  var itr = folder.getFoldersByName(folderName);
  if (itr.hasNext()) {
    return itr.next();
  }
  var newFolder = folder.createFolder(folderName);
  newFolder.setName(folderName);
  return newFolder;
}

function FindOrCreateSpreadsheet(folder, fileName) {
  var it = folder.getFilesByName(fileName);
  if (it.hasNext()) {
    var file = it.next();
    return SpreadsheetApp.openById(file.getId());
  }
  else {
    var ss = SpreadsheetApp.create(fileName);
    folder.addFile(DriveApp.getFileById(ss.getId()));
    return ss;
  }
}

// Slack 上にアップロードされたデータをダウンロード
function DownloadData(url,name, folder, savefilePrefix) {
  var options = {
    "headers": { 'Authorization': 'Bearer ' + API_TOKEN },
    'muteHttpExceptions': true,
  };
  try {
    var response = UrlFetchApp.fetch(url, options);
    var fileName = name; //savefilePrefix + "_" + url.split('/').pop();
    var fileBlob = response.getBlob().setName(fileName);
    
    //console.log("Download: " + url + "\n =>" + fileName);
    
    // もし同名ファイルがあったら削除してから新規に作成
    var itr = folder.getFilesByName(fileName);
    if (itr.hasNext()) {
      folder.removeFile(itr.next());
    }
    var downloadfile = folder.createFile(fileBlob);
    var downloadUrl = downloadfile.getUrl()
    return downloadUrl;
  } catch(e) { // 取得失敗した場合
    exceptions.push(e);
    return 'Error occured';
  }
  /*var responseCode = response.getResponseCode();
  if (responseCode != 200) {
    var responseBody = response.getContentText();
    exceptions.push(Utilities.formatString("Request failed. Qiita Expected 200, got %d: %s", responseCode, responseBody));
    return [];
  }*/
  
}

// Slack テキスト整形
function UnescapeMessageText(text, memberList) {
  return (text || '')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&amp;/g, '&')
    .replace(/<@(.+?)>/g, function ($0, userID) {
      var name = memberList[userID];
      return name ? "@" + name : $0;
    });
};


function Run() {
  let folder = FindOrCreateFolder(DriveApp.getFolderById(FOLDER_ID), FOLDER_NAME);
  let ss = FindOrCreateSpreadsheet(folder, SpreadSheetName);

  let ssCtrl = new SpreadsheetController(ss, folder);
  let slack = new SlackAccessor(API_TOKEN);

  // メンバーリスト取得
  const memberList = slack.requestMemberList();
  // チャンネル情報取得
  const channelInfo = slack.requestChannelInfo();
  
  // チャンネル情報をAnalysisシートに記載
  ssCtrl.saveChannelsName(channelInfo,AnalysisSheetName);
  
  let runall = false; // falseの時は最適化したforward、trueの時はbackwardに処理が進む(投稿削除などで表示されなくなっていた投稿が見えるようになった時のみ使う)
  // チャンネルごとにメッセージ内容を取得。
  for (let ch of channelInfo) {
    var timestamp;
    if (runall){
      timestamp = ssCtrl.getFirstTimestamp(ch, 0); //最初の行のtimestampを読み取り
      var which_ward = 'latest';
    } else {
      timestamp = ssCtrl.getLastTimestamp(ch, 0);//最終行のtimestampを読み取り
      var which_ward = 'oldest';
    }
    let messages = slack.requestMessages(ch, timestamp,which_ward); //timestampをwhich_wardとしたメッセージの配列を取得。
    ssCtrl.saveChannelHistory(ch, messages, memberList); //メッセージ配列のslackメンバーIDを名前に変換しながらスプシに記録
    if (timestamp == '1') {//初回実行のチャンネルに当たったら処理時間がかかるのでそのチャンネルで処理を終了する
      break;
    }
  };

  // スレッドは重い処理なので各回に1回のみ行う
  console.log('start thread loading');
  const ch_num = (parseInt(PropertiesService.getScriptProperties().getProperty('last_channel_no')) + 1) % channelInfo.length; //今回スレッド取得するチャンネル番号
  const ch = channelInfo[ch_num]
  console.log('ch_num: '+ch_num+' ch: '+ch.name);
    
  if (runall){
    timestamp = ssCtrl.getFirstTimestamp(ch, 0); //最初の行のtimestampを読み取り
    var ts = ssCtrl.getFirstTimestamp(ch, 2); //最初のスレッドのtimestampを読み取り
    ts = (parseFloat(ts) + 2592000).toString();  //取得したタイムスタンプから一ヶ月の猶予を持たせる
    var ts_array = ssCtrl.getThreadTSBW(ch, ts);//  チャンネル内のスレッド元のtsをすべて取得
  } else {
    timestamp = ssCtrl.getLastTimestamp(ch, 1);//最後の被スレッドのtimestampを読み取り
    var ts = (parseFloat(timestamp) - 2592000).toString();  //取得したタイムスタンプから一ヶ月の猶予を持たせる
    var ts_array = ssCtrl.getThreadTS(ch, ts);//  チャンネル内のスレッド元のtsをすべて取得
  }
  var date = new Date(ts * 1000);
  var _ts = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');//tsをわかりやすい形式に変換
  console.log('ts_boundary: ' + _ts + ' ts_array.length: ' + ts_array.length);
  //  ts_arrayに存在するスレッドかつ最終更新以降の投稿を取得
  if (ts_array != '1') {
    try {
      const thread_messages = slack.requestThreadMessages(ch, ts_array, timestamp);
      ssCtrl.saveChannelHistory(channelInfo[ch_num], thread_messages, memberList);
    } catch(e) { // 取得失敗した場合
      exceptions.push(e);
      console.log('thread Not Found'); //前回実行時から投稿削除されている場合に起こる
    }
    // sort by timestamp
    ssCtrl.sortSheet(ch);
  }
  // 最後にスレッド情報を集めたチャンネル番号を保存
  PropertiesService.getScriptProperties().setProperty('last_channel_no', ch_num);

  // Exceptionを出力
  if (exceptions.length > 0) {
    for(var i = 0; i < exceptions.length; i++) {
      var e = exceptions[i];
      console.log(e);
    }
    throw new Error('エラーがありました。ログを確認してください。');
  }
}
```
これらのスクリプトを用意したら、あとはプロジェクトプロパティのセットと定時実行のトリガーをセットしたら完成です。

###・プログラムの流れ
一回のプログラム実行では、
1. 全チャンネルの主投稿を取得(ただし初回実行のチャンネルに当たった場合はそのチャンネルで終了する)
2. 前回スレッド投稿を取得したチャンネルの番号last_channel_noの次のチャンネルに対して、スレッド投稿を取得(初回実行の場合は全て、2回目以降は直近の投稿の一ヶ月前から投稿を取得)

という流れになっています。一回の実行ごとにlast_channel_noがインクリメントされて行きます。
一回に1チャンネルずつしかスレッド投稿を取れないのでGASの定時実行のトリガーはできるだけ短い間隔で行いたいと思いますが、初回実行はかなり処理に時間がかかるので、最初の方はトリガー間隔は1時間くらいで設定するといいと思います。

###・注意点
1. プロジェクトプロパティに<br>
・folder_id<br>
・slack_api_token<br>
・last_channel_no<br>
の3つをセットする必要があるので注意してください。<br>
last_channel_noは最初は-1が良いと思います。<br>
2. 容量を回復するために投稿を削除する方も多いと思います。<br>
その場合、削除したタイミング次第でthreadが見つからない場合があるので、threadを取得するところはtrycatchを入れています。<br>
3. 同様に、投稿を削除すると古い投稿が復活して、そのログも取りたくなることがあると思います。<br>
その場合は、Run()の中の```runall=false```を```true```に変えれば古い投稿も取ってくれます。<br>
4. slack_dumberというGo言語を用いた投稿保存用のアプリケーションもあるようです。<br>
個人的にはGoogledrive上で管理できるこちらの方が好みです。

###・終わりに
初投稿で、プログラム知識の浅いところやエラーなど稚拙なところがあるかと思いますが、その場合は指摘してくださると幸いです。
最後までご覧いただきありがとうございました。