Slackフリープラン内容変更への対応

Slackが2022年9月1日より、これまで無期限で投稿が保存されていたフリープランが、メッセージ数1万件の制限が撤廃される代わりに90日間しか保存されないようになることが発表されました。ビジネスチャットとしてヘビーには使っていなかったのですが、過去の投稿が90日しか参照できないのは痛く対応を考えていました。Slack以外のツールに変えることも考慮したのですが、周りの教員が折角Slackの使い方を覚えたのに別のツールに移るのも痛く、Slackで議論をしながら過去の投稿は、Googleドライブに保存する方法をとることとしました。様々なページを参照しながら、Google Apps Script (GAS)でSlackからGoogleスプレッドシートに投稿をコピーする方法を構築したのですが、いくつかの躓きポイントもあり、また、周りでのSlackのフリープラン変更で困っている人も居たので、ブログにて情報共有を行うこととしました。

基本的には、次の2つのページを基にスクリプトを作成しました。
SlackメッセージログをSlack APIとGASで自動保存する方法(ベストクラウドさん)
SLACK_LOG_GAS(ryota-moさん)
ryota-moさんのGithubのページを基にベストクラウドさんもスクリプトを紹介しているのですが、両方を参考にしながら作成しました。まず、ページに書かれているスクリプトをコピペで走らしたのですが、上手く実行できませんでした。理由は、Slackが提供しているAPIに変更が行われていくので、時間経過とともにエラーとなってしまう可能性は増えてきます。ryota-moさんのページには詳しく書かれているのですが、その変更に対応したスクリプトであったのにも関わらず、さらに変更があり、具体的には、APIトークンをHeaderに書き出す必要となったためryota-moさんのスクリプトでは動かくなくなって、それを修正したのが、ベストクラウドさんのスクリプトでした。そのため、ベストクラウドさんのスクリプトだと、かなり良い線まで動くのですが、「thread_not_found」というエラーを吐いてしまいました。結局、コピペだけでは動かず、エラーの原因をGASのスクリプトを細かくチェックしてSlack APIのマニュアルとにらめっこしながらの作業となってしまいました。threadが見つからなかった理由は、15桁ルールでした。Googleスプレッドシートは詳しく把握しておりませんが、Excelでは15桁を超える小数は変数のメモリーの関係で扱えません。Slackでは、投稿ごとに、その投稿時刻を元にしたtime stamp (ts)で投稿を区別しており、小数点以下6桁の小数が使われていました。スクリプトを作成した時点から経ったためか小数点以下6桁を含めて16桁となってしまっており、Googleスプレッドシートで ts を保存できなくなってしまっていました。そこで、新しいスクリプトでは、小数ではなく整数で、つまり1000000倍して数値を扱うように変更しました。
今回、GASのスクリプトでSlackの投稿をGoogleスプレッドシートに自動でコピーできるようにしましたが、上記のようにSlack APIの変更などで、いつまで使えるか不安に感じています。そんなときは、誰かが修正を加え続けていけば良いと思い、今回は、私の番だと思い修正をこのようにアップしています。ちなみに、Slackへのアップロードファイルも自動でダウンロードできなくなっていた点も修正しています。

function Run() {
  // SetProperties();
  const FOLDER_NAME = "SlackLog_Save";
  const SpreadSheetName = "Slack_Log_SS";

  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]';
  }
  let token = API_TOKEN
  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();

  // チャンネルごとにメッセージ内容を取得
  let first_exec_in_this_channel = false;
  for (let ch of channelInfo) {
    console.log(ch.name)
    let timestamp = ssCtrl.getLastTimestamp(ch, 0);
    let messages = slack.requestMessages(ch, timestamp);
    ssCtrl.saveChannelHistory(ch, messages, memberList, token);
    if (timestamp == '1') {
      first_exec_in_this_channel = true;
      // console.log('breaked')
      // break;
    }
  };

  // スレッドは重い処理なので各回に1回のみ行う
  const ch_num = (parseInt(PropertiesService.getScriptProperties().getProperty('last_channel_no')) + 1) % channelInfo.length;
  console.log('ch_num');
  console.log(ch_num);
  const ch = channelInfo[ch_num]
  console.log(ch);
  // スプレッドシートの最後(初めての書き込みのときは0にする)
  let timestamp;
  // スレッド元が1か月前の投稿から現在まで(初めての書き込みのときは全てを対象)
  let first;
  if (first_exec_in_this_channel) {
    timestamp = 0;
    first = '1';
  } else {
    timestamp = ssCtrl.getLastTimestamp(ch, 1);
    first = (parseFloat(timestamp) - 2592000).toString();
  }
  // チャンネル内のスレッド元のtsをすべて取得
  console.log('first: ' + first);
  const ts_array = ssCtrl.getThreadTS(ch, first);
  console.log('ts_array.length: ' + ts_array.length);
  // ts_arrayに存在するスレッドかつ最終更新以降の投稿を取得
  if (ts_array != '1') {
    const thread_messages = slack.requestThreadMessages(ch, ts_array, timestamp);
    // save messages and files
    // unfortunately, not all files are saved (bug)
    ssCtrl.saveChannelHistory(channelInfo[ch_num], thread_messages, memberList);

    // sort by timestamp
    ssCtrl.sortSheet(ch);
  }
  // 最後にスレッド情報を集めたチャンネルを保存
  PropertiesService.getScriptProperties().setProperty('last_channel_no', ch_num);
}

function SetProperties() {
  PropertiesService.getScriptProperties().setProperty('slack_api_token', 'XXXXXXX');
  PropertiesService.getScriptProperties().setProperty('folder_id', 'XXXXXXXX');
  PropertiesService.getScriptProperties().setProperty('last_channel_no', -1);
}

function FindOrCreateFolder(folder, folderName) {
  Logger.log(typeof folder)
  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, folder, savefilePrefix, token) {
  var options = {
    "headers": { 'Authorization': 'Bearer ' + token }
  };
  var response = UrlFetchApp.fetch(url, options);
  var fileName = 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());
  }
  return folder.createFile(fileBlob);
}

// 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;
  });
};

// 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))];
    var qparams = [];
    for (var k in params) {
      qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
    }
    url += qparams.join('&');
    var headers = {
      'Authorization': 'Bearer ' + this.APIToken
    };
    console.log("==> GET " + url);

    var options = {
      'headers': headers, // 上で作成されたアクセストークンを含むヘッダ情報が入ります
    };
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response.getContentText());
    if (data.error) {
      console.log(data);
      console.log(params);
      throw "GET " + path + ": " + data.error;
    }
    return data;
  };

  // メンバーリスト取得
  p.requestMemberList = function () {
    var response = this.requestAPI('users.list');
    var memberNames = {};
    response.members.forEach(function (member) {
      memberNames[member.id] = member.name;
      console.log("memberNames[" + member.id + "] = " + member.name);
    });
    return memberNames;
  };

  // チャンネル情報取得
  p.requestChannelInfo = function () {
    var response = this.requestAPI('conversations.list');
    response.channels.forEach(function (channel) {
      console.log("channel(id:" + channel.id + ") = " + channel.name);
    });
    return response.channels;
  };

  // 特定チャンネルのメッセージ取得
  p.requestMessages = function (channel, oldest) {
    var _this = this;
    if (oldest === void 0) { oldest = '1'; }

    var messages = [];
    var options = {};
    options['oldest'] = oldest;
    options['count'] = HISTORY_COUNT_PER_PAGE;
    options['channel'] = channel.id;

    var loadChannelHistory = function (oldest) {
      if (oldest) {
      options['oldest'] = oldest;
    }
    var response = _this.requestAPI('conversations.history', options);
    messages = response.messages.concat(messages);
    return response;
  };

  var resp = loadChannelHistory();
  var page = 1;
  while (resp.has_more && page <= MAX_HISTORY_PAGINATION) {
    resp = loadChannelHistory(resp.messages[0].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;

      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;
})();

// スプレッドシートへの操作
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_REPLY_COUNT = 7; // スレッド内の投稿数
  const COL_IS_REPLY = 8; // リプライのとき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);
  }
  return sheet;
};

// チャンネルからシート名取得
p.channelToSheetName = function (channel) {
  return channel.name + " (" + channel.id + ")";
};

// チャンネルごとのシートを取得
p.getChannelSheet = function (channel) {
  var sheetName = this.channelToSheetName(channel);
  return this.findOrCreateSheet(sheetName);
};
p.sortSheet = function (channel) {
  var sheet = this.getChannelSheet(channel);
  var lastRow = sheet.getLastRow();
  var lastCol = sheet.getLastColumn();
  sheet.getRange(1, 1, lastRow, lastCol).sort(COL_TIME);
};

// 最後に記録したタイムスタンプ取得
p.getLastTimestamp = function (channel, is_reply) {
  var sheet = this.getChannelSheet(channel);
  var lastRow = sheet.getLastRow();
  if (lastRow > 0) {
    let row_of_last_update = 0;
    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';
    }
    console.log('last timestamp row: ' + row_of_last_update);
    console.log('last timestamp: ' + sheet.getRange(row_of_last_update, COL_TIME).getValue()/1000000);
    return sheet.getRange(row_of_last_update, COL_TIME).getValue()/1000000;
  }
  return '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()/1000000; // 整数を小数に戻してtsとする
      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_REPLY_COUNT).isBlank())) {
        ts = sheet.getRange(i, COL_TIME).getValue()/1000000;
        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, token) {
  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);
    console.log("message: " + date);

    if ('subtype' in msg) {
      if (msg.subtype === 'thread_broadcast') {
        continue;
      }
    }

    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 = "";
    var alternateLink = "";
    if (JSON.stringify(msg).match(/url_private_download/)) {
      url = msg.files[0].url_private_download;
      console.log("url: " + url)
      if (msg.files[0].mode == 'tombstone' || msg.files[0].mode == 'hidden_by_limit') {
        url = "";
      } else {
        // ダウンロードとダウンロード先
        var file = DownloadData(url, downloadFolder, date, token);
        var driveFile = DriveApp.getFileById(file.getId());
        alternateLink = driveFile.getDownloadUrl(); // alternateLink;
      }
    }
    row[COL_URL - 1] = url;
    row[COL_LINK - 1] = alternateLink;
    row[COL_TIME - 1] = msg.ts * 1000000; // 桁数が増えたので整数型に変更
    if ('reply_count' in msg) {
    row[COL_REPLY_COUNT - 1] = msg.reply_count;
    }
    row[COL_IS_REPLY - 1] = 0;
    if ('thread_ts' in msg) {
      if (msg.ts != msg.thread_ts) {
        row[COL_IS_REPLY - 1] = 1;
      }
    }
    // メッセージの 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);
    }
    // downloadFolder.setTrashed(true);
  };

  return SpreadsheetController;
})();

こちらのスクリプトをコピペで使ってもらって構いませんが、詳しい方法はベストクラウドさんのページに書かれていますので、そちらを参照して下さい。簡単に書いておくと、Google Apps Scriptを新規作成して、コードをコピペする。3行目と4行目の “SlackLog_Save”と”Slack_Log_SS”はSlackを保存するフォルダとスプレッドシートの名前なので好きなものに変えて構わない(このままでもよい)。ベストクラウドさんのページでは、トークンとフォルダーIDをスクリプトに直接書き込んでいますが、スクリプトプロパティで設定した方がセキュリティー上好ましいかと思います。

スクリプト プロパティ 編集画面

GASの画面で左側の下の歯車マークをクリックすると現れ、スクロールすると「スクリプト プロパティーを追加」のボタンがあります。slack_api_tokenの値としては、slack apiのページでトークンを作成します。channels:history, channels:read, files:read, users:read の4つのOAuth Scopeを追加する必要があります。トークンはxoxp-から始まる文字列だと思います。folder_idの値は、Slackのデータを格納するGoogleドライブのフォルダIDであり、https://drive.google.com/drive/u/0/folders/XXXXXXXXXXXXなどのURLのうちXXXXXXXXXXXXの部分です。last_channel_noは初回動作時には、-1の値に設定して下さい。実行すると毎回すべてのチャンネルの投稿を確認してコピーしていきますが、スレッドに関しては1つのチャンネルのみを実行します。その実行するチャンネル番号が入っています。最初の実行で0番目のチャンネルのスレッドが読み込まれます。(つまり、すべてのチャンネルのスレッドを読み込むには、チャンネルの数だけの回数スクリプトを実行する必要があります。)

スクリプトの実行は、「Run」が選ばれている状態で「実行」をクリックするだけです。「トリガー(時計マーク)」で1日1回自動で実行するなどの設定も行えます。私の場合は、作成されたGoogleスプレッドシートをSlackのメンバーで共有して、90日より前の過去の投稿も参照できる状態とする予定です。

注意点と追加(8月28日)

OAuth Tokensの設定は、User Token Scopesです。Slack APIのページにはBot Token Scopesもありますが、Bot Token Scopesだと実行途中でエラーを吐きます。User Token Scopesで設定を行いUser OAuth Tokenの値をコピーしてGASに張り付けて下さい。

Public Channels だけでなくPrivate Channelsも保存したいという要望がありましたが、コードを少し修正すると対応できましたので紹介します。190行目あたりのコードを修正します。

  // チャンネル情報取得
  p.requestChannelInfo = function () {
    var options = {};
    options['types'] = 'public_channel,private_channel';
    var response = this.requestAPI('conversations.list', options);
    response.channels.forEach(function (channel) {
      console.log("channel(id:" + channel.id + ") = " + channel.name);
    });
    return response.channels;
  };

Private Channelsにアクセスするためには、OAuth Tokensとしてgroups:historyとgroups:readを追加する必要があると思うのだけど、イロイロ試していて、追加しなくてもコードの修正だけでPrivate Channelsを読み込めました。(わからん)
細かな動作など試行錯誤で行っている段階ですので、コードの利用は自己責任でお願いします。何かコメントなどありましたら、問合せフォームからお願いします。