【1283日目】Slackのプライベートチャンネルのメッセージをバックアップした話

2022.08.07

こんにちは。あとむです。

突然ですが皆さんは2022年9月から実施されるSlackの料金体系の変更についてご存知でしょうか。

こちらにあるように

  • プロプランが値上げ
  • フリープランのメッセージが90日間しか保存されなくなる

というようにSlackの仕様が変更されるようです。

僕はフリープランで使用しているので値上げについては正直どちらでもいいくらいの感じなのですが、メッセージが90日間しか保存されないのはなかなか打撃だなと感じています。

まあ別に3ヶ月前のメッセージを見ることなんてほぼないんですが念の為残しておきたいというのは多くの人が考えることなのではないでしょうか。
(お客さんと昔言った言わなかったみたいな醜い争いしたくないですしね。。。)

なので今回はSlackのメッセージを定期的にバックアップできるように色々頑張ってみました。

早速進めてみるぜ

と言う訳で早速進めていきます。

いろいろ調べてみた感じpythonで頑張ってたりツールを使ってjsonを吐き出したりと色々やり方がありそうな感じだったのですが、1番確認のしやすいGASを使ってスプレッドシートに書き出す方法を選択することにしました。

参考にしたのは下記です。

はちゃめちゃに丁寧に説明されているのでほぼほぼ悩まずにスクリプトを動かすところまでできました。マジでありがとうございます。

スクリプトも最初は順調に動いて「もしやこれでこの記事終わり?」と嬉しいような悲しいような不安が襲ってきたのですが、最後の方でよく分からないエラーが、、

どうやら特定のスレッドのメッセージが取得できないらしい。

あとよくよくみたらプライベートチャンネルは一つも取れておらず、パブリックチャンネルのメッセージのみとれているみたい。

うーん、、、

コードの中身を読んでいくぜ

残念ながら最後まで動かなかったのでここからはコードを読んで自分で直すことにします。

編集その①プライベートチャンネルも取得する

まずはプライベートチャンネルも取得できるようにします。

どうやらSlacl APIのconversations.listはデフォルトだとパブリックチャンネルしか取得できないみたいですね。

チャンネルを取得するAPIのパラメーターを追加してプライベートチャンネルも取得できるようにしました。

// チャンネル情報取得
p.requestChannelInfo = function () {
  // 第2引数を追加
  var response = this.requestAPI("conversations.list", {"types" : "public_channel,private_channel"});
  response.channels.forEach(function (channel) {
    console.log("channel(id:" + channel.id + ") = " + channel.name);
  });   return response.channels;
};

これで実行したところ権限がないと怒られたので、Slack APIの作成したアプリのOAuth & Permissionsに戻って、groups:historygroups:readを追加しました。

追加したあとに再インストールして動かしたところプライベートチャンネルの取得についてはうまく動かすことができました。よかった!

thread_not_foundを解決する

次にthread_not_foundを解決します。

一体どういうメッセージでこのエラーが起きているのか調べたところ、どうやらgeneralチャンネルのメッセージでエラーが発生しているようでした。

たまたまgeneralチャンネルでエラーが起きたのか、generalチャンネルだかエラーが起きたのかどちらかは分からなかったのですが、今回のバックアップについてはgeneralチャンネルがなくても別に問題にならなかったので、エラーを解決することは諦めてスルーすることにしました。

  // 特定チャンネルの特定のスレッドのメッセージ取得
  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;
      let is_error = false; // 新規追加

      // try catchでAPI連携部分を囲む
      try{
        resp = loadThreadHistory(options);
      }catch(e){
        console.log("エラー発生: " + e);
        is_error = true;
      }

      // エラーがない時だけ動かす
      if(!is_error){
        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;
  };

エンジニアとしてかなり恥ずかしい修正だなとは思いながら、とりあえずこれで動かしたところ、、、

最後まで動いた!!!

先人の人たちありがとう

と言う訳でSlackのメッセージをとても分かりやすい形でバックアップすることができました。

参考にした記事を書いてくれた人、またその記事を書いた人が参考にしたGithubリポジトリを作成してくれたryota-moさんには感謝しかないです。ありがとうございました。

いつか自分も上記のような記事だったりリポジトリだったりを作れるように頑張ります。

一応最後にコードの全量を書いておきたいと思います。(といっても99.9%パクリですが)

ありがとうございました!!

function Run() {
  // 環境変数 ここから
  const SLACK_API_TOKEN = "XXXXXX";
  const GOOGLE_DRIVE_FOLDER_ID = "XXXXXX";
  const FOLDER_NAME = "XXXXXX"
  // 環境変数 ここまで

  const SpreadSheetName = "メッセージ_" + FOLDER_NAME;

  // プロパティのセット
  PropertiesService.getScriptProperties().setProperty(
    "slack_api_token",
    SLACK_API_TOKEN
  );
  PropertiesService.getScriptProperties().setProperty(
    "folder_id",
    GOOGLE_DRIVE_FOLDER_ID
  );
  PropertiesService.getScriptProperties().setProperty("last_channel_no", -1);

  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 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(/</g, "<")
    .replace(/>/g, ">")
    .replace(/"/g, '"')
    .replace(/&/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 () {
    // 第2引数を追加
    var response = this.requestAPI("conversations.list", {"types" : "public_channel,private_channel"});
    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;
      let is_error = false;
      try{
        resp = loadThreadHistory(options);
      }catch(e){
        console.log("エラー発生: " + e);
        is_error = true;
      }

      if(!is_error){
        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()
      );
      return sheet.getRange(row_of_last_update, COL_TIME).getValue();
    }
    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();
        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();
          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 (msg.upload == true) {
        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.alternateLink;
        }
      }
      row[COL_URL - 1] = url;
      row[COL_LINK - 1] = alternateLink;
      row[COL_TIME - 1] = msg.ts;
      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;
})();