Slackのチャンネルバックアップ方法（管理者以外・複数チャンネルをまとめて保存）


## 概要
Slackの複数チャンネルの情報を同じGoogleスプレッドシート内に保存する手順．

## 背景・目的
- 所属していたSlack Organizationを抜けることになった．
- 自分が関わっていたチャンネルが多岐にわたるため，必要なメッセージの選別が面倒．
- 複数チャンネルのバックアップを一括で取れるようにしたい．


## バックアップ方法

Googleスプレッドシートを作成し，Slack APIを用いてその中の各シートに各チャンネルのメッセージを保存する．
スプレッドシートの作成・スクリプト編集まではLumitecの記事, "[Slackのプライベートチャンネルをバックアップする方法](https://lumitec.co.jp/hp/slack%E3%81%AE%E3%83%97%E3%83%A9%E3%82%A4%E3%83%99%E3%83%BC%E3%83%88%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E3%82%92%E3%83%90%E3%83%83%E3%82%AF%E3%82%A2%E3%83%83%E3%83%97%E3%81%99%E3%82%8B/,"")"を参考に行った．

以下が手順である（各項目の詳細については元の記事を確認してください）．

>　①「Slack App」を作成する
　②「Slack App」のスコープを設定する
　③トークンを取得する
　④プライベートチャンネルのIDを取得する
　⑤「Googleスプレッドシート」を準備する
　⑥スクリプトを編集する
　⑦スクリプトを実行する
>

ただし，スコープには以下の項目を追加する．
<table>
  <caption>スコープに追加する項目</caption>
  <thead>
    <tr>
      <th>名前</th> <th>説明</th>
    </tr>
  </thead>
  <tr>
    <td> channels:history </td> <td>パブリックチャンネルのメッセージ履歴</td>
  </tr>
  <tr>
    <td> groups:history </td> <td>プライベートチャンネルのメッセージ履歴</td>
  </tr>
  <tr>
    <td> im:history </td> <td>DM（1対1）メッセージ履歴</td>
  </tr>
  <tr>
    <td> mpim:history </td> <td>DM（複数）メッセージ履歴</td>
  </tr>
  <tr>
    <td> users:read </td> <td>ユーザーのプロフィール情報</td>
  </tr>
</table>


また，履歴を残したいチャンネルそれぞれに対して，Slackの画面右上の「：」から，　「チャンネル詳細を開く」→ 「インテグレーション」　を経由して作成したSlackアプリを追加するようにする．


## Google Apps script内のコード<a id="apps"></a>
GoogleスプレッドシートのApps Scriptを開くところまで終わったら，以下のコードをmain.gs内に貼り付ける．

### コードの全体像

```Javascript:main.gs

var OAuthAccessToken = "<Bot User OAuth Token>"; 
var channelIDs = [
  
  "<ChannelID_1>", //Channel name1
  "<ChannelID_2>", //Channel name2
  ...
]; // Add your channel IDs here

// Start date for fetching messages (e.g., "2021-01-01")
var startDate = new Date("2021-01-01");
var endDate = new Date();

function main() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();

  // Loop through each channel ID
  channelIDs.forEach(function(channelID) {
    var sheet = getOrCreateSheet(spreadsheet, channelID);
    sheet.getRange('A2:H').clearContent(); // Clear previous data at the start

    // Store existing message IDs in a Set for uniqueness
    var existingMessages = new Set(sheet.getRange('A2:A' + sheet.getLastRow()).getValues().flat());

    // Loop through each month between startDate and endDate
    for (var currentDate = new Date(startDate); currentDate <= endDate; currentDate.setMonth(currentDate.getMonth() + 1)) {
      var oldestTimestamp = Math.floor(currentDate.getTime() / 1000);
      var nextMonth = new Date(currentDate);
      nextMonth.setMonth(currentDate.getMonth() + 1);
      var latestTimestamp = Math.floor(nextMonth.getTime() / 1000);

      Logger.log("Fetching messages from " + new Date(oldestTimestamp * 1000).toLocaleString() + " to " + new Date(latestTimestamp * 1000).toLocaleString() + " for channel: " + channelID);
      fetchMessagesForDateRange(oldestTimestamp, latestTimestamp, sheet, existingMessages, channelID);
    }
  });
}

// Function to get or create a sheet for the given channel ID
function getOrCreateSheet(spreadsheet, channelID) {
  var sheetName = channelID; // You can customize this naming convention
  var sheet = spreadsheet.getSheetByName(sheetName);
  
  if (!sheet) {
    sheet = spreadsheet.insertSheet(sheetName);
    sheet.appendRow(['Client Msg ID', 'Type', 'Text', 'User', 'Timestamp', 'Formatted Date', 'Reply Count', 'Reply Users Count']);
  }
  
  return sheet;
}

function fetchMessagesForDateRange(oldestTimestamp, latestTimestamp, sheet, existingMessages, channelID) {
  var fetchUrl = 'https://slack.com/api/conversations.history?channel=' + channelID + '&limit=1000&oldest=' + oldestTimestamp + '&latest=' + latestTimestamp;
  const options = {
    "headers": { 'Authorization': 'Bearer ' + OAuthAccessToken },
    "muteHttpExceptions": true
  };

  var messages = [];
  var hasMore = true;
  var cursor = '';

  do {
    var urlWithCursor = fetchUrl + (cursor ? '&cursor=' + cursor : '');
    var res;
    var retryCount = 0;
    var success = false;

    // Attempt to fetch with exponential backoff in case of rate limiting
    while (!success && retryCount < 5) {
      try {
        res = UrlFetchApp.fetch(urlWithCursor, options);
        success = true;
      } catch (e) {
        Logger.log("Error fetching messages: " + e);
        if (e.toString().includes('429')) {
          Utilities.sleep(Math.pow(2, retryCount) * 1000);
          retryCount++;
        } else {
          throw e;
        }
      }
    }

    if (!success) {
      throw new Error("Failed to fetch messages after multiple retries due to rate limiting.");
    }

    res = JSON.parse(res.getContentText());

    if (!res.ok) {
      Logger.log('Error fetching messages: ' + res.error);
      return; // Exit the function if the API call fails
    }

    Logger.log("Fetched " + res.messages.length + " messages for channel: " + channelID);

    var newMessages = [];
    res.messages.forEach(function (v) {
      if ('thread_ts' in v) {
        var threadUrl = 'https://slack.com/api/conversations.replies?channel=' + channelID + '&ts=' + v.thread_ts;
        var reply;
        var replyRetryCount = 0;
        var replySuccess = false;
        
        while (!replySuccess && replyRetryCount < 5) {
          try {
            reply = UrlFetchApp.fetch(threadUrl, options);
            replySuccess = true;
          } catch (e) {
            Logger.log("Error fetching replies: " + e);
            if (e.toString().includes('429')) {
              Utilities.sleep(Math.pow(2, replyRetryCount) * 1000);
              replyRetryCount++;
            } else {
              throw e;
            }
          }
        }

        if (!replySuccess) {
          throw new Error("Failed to fetch thread messages after multiple retries due to rate limiting.");
        }

        reply = JSON.parse(reply.getContentText());

        // Check if reply.messages exists before reversing
        if (reply.messages && Array.isArray(reply.messages)) {
          newMessages = newMessages.concat(reply.messages.reverse());
        }
      } else {
        newMessages.push(v);
      }
    });

    newMessages = newMessages.map(function (v) {
      var date = new Date(parseFloat(v.ts) * 1000); // Convert timestamp to Date
      var formattedDate = date.toLocaleString(); // Format date to readable format
      return {
        client_msg_id: v.client_msg_id || '',
        type: v.type || '',
        text: v.text || '',
        user: v.user || '',
        ts: v.ts || '',
        formattedDate: formattedDate, // Include formatted date
        reply_count: v.reply_count || 0,
        reply_users_count: v.reply_users_count || 0
      };
    });

    messages = messages.concat(newMessages);
    hasMore = res.has_more;
    cursor = res.response_metadata && res.response_metadata.next_cursor;

    Utilities.sleep(1000);  // Regular delay between requests
  } while (hasMore);

  // Append new messages for this date range
  if (messages.length > 0) {
    // Map user IDs to display names
    var userIds = [...new Set(messages.map(m => m.user))];
    var userDisplayNames = getUserDisplayNames(userIds);

    var finalMessages = messages.map(function (msg) {
      return [
        msg.client_msg_id,
        msg.type,
        msg.text,
        userDisplayNames[msg.user] || msg.user, // Use display name or fallback to user ID
        msg.ts,
        msg.formattedDate,
        msg.reply_count,
        msg.reply_users_count
      ];
    });

    // Filter out duplicates before appending
    finalMessages = finalMessages.filter(msg => !existingMessages.has(msg[0])); // Check against existing IDs

    // Append messages to the first available row
    if (finalMessages.length > 0) {
      sheet.getRange(sheet.getLastRow() + 1, 1, finalMessages.length, finalMessages[0].length).setValues(finalMessages.reverse());
      Logger.log("Appended " + finalMessages.length + " new messages to the sheet for channel: " + channelID);
    } else {
      Logger.log("No new messages to append for channel: " + channelID);
    }
  } else {
    Logger.log("No messages found for this date range for channel: " + channelID);
  }
}

// Function to get user display names
function getUserDisplayNames(userIds) {
  var displayNames = {};
  
  userIds.forEach(function(userId) {
    var userFetchUrl = 'https://slack.com/api/users.info?user=' + userId;
    const options = {
      "headers": { 'Authorization': 'Bearer ' + OAuthAccessToken },
      "muteHttpExceptions": true
    };

    var success = false;
    var retryCount = 0;

    while (!success && retryCount < 5) {
      try {
        var response = UrlFetchApp.fetch(userFetchUrl, options);
        var userInfo = JSON.parse(response.getContentText());
        
        if (userInfo.ok) {
          displayNames[userId] = userInfo.user.profile.display_name || userInfo.user.name; // Fallback to the user's real name if display_name is not set
        } else {
          Logger.log('Error fetching user info for ' + userId + ': ' + userInfo.error);
        }
        success = true;
      } catch (e) {
        Logger.log("Error fetching user info: " + e);
        if (e.toString().includes('429')) {
          Utilities.sleep(Math.pow(2, retryCount) * 1000);
          retryCount++;
        } else {
          throw e;
        }
      }
    }
  });

  return displayNames;
}

```


---
### コードの概要
上のコードの大まかな説明を以下に記す．

#### 1. 必要な変数の定義

```javascript
var OAuthAccessToken = "<Bot User OAuth Token>"; 
var channelIDs = [
  "<ChannelID_1>", // Channel name1
  "<ChannelID_2>", // Channel name2
  // 他のチャンネルIDをここに追加
];

var startDate = new Date("2021-01-01"); // メッセージ取得開始日
var endDate = new Date(); // メッセージ取得終了日（今日）
```

- `OAuthAccessToken`: Slack APIにアクセスするための認証トークン（Botユーザーのトークンを指定する）．
- `channelIDs`: メッセージを取得するSlackチャンネルのIDリスト．
- `startDate` と `endDate`: メッセージを取得する期間の開始日と終了日（終了日を指定しない場合は最新の日付になる）．

---

#### 2. メイン関数の実行

```javascript
function main() {
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();

  channelIDs.forEach(function(channelID) {
    var sheet = getOrCreateSheet(spreadsheet, channelID);
    sheet.getRange('A2:H').clearContent();

    var existingMessages = new Set(sheet.getRange('A2:A' + sheet.getLastRow()).getValues().flat());

    for (var currentDate = new Date(startDate); currentDate <= endDate; currentDate.setMonth(currentDate.getMonth() + 1)) {
      var oldestTimestamp = Math.floor(currentDate.getTime() / 1000);
      var nextMonth = new Date(currentDate);
      nextMonth.setMonth(currentDate.getMonth() + 1);
      var latestTimestamp = Math.floor(nextMonth.getTime() / 1000);

      Logger.log("Fetching messages from " + new Date(oldestTimestamp * 1000).toLocaleString() + " to " + new Date(latestTimestamp * 1000).toLocaleString() + " for channel: " + channelID);
      fetchMessagesForDateRange(oldestTimestamp, latestTimestamp, sheet, existingMessages, channelID);
    }
  });
}
```

- `getOrCreateSheet` 関数を呼び出してチャンネルごとのシートを作成・取得．
- チャンネルIDごとにループして、指定期間内のメッセージを月ごとに取得し、スプレッドシートに出力．

---

#### 3. シート作成・取得関数

```javascript
function getOrCreateSheet(spreadsheet, channelID) {
  var sheetName = channelID;
  var sheet = spreadsheet.getSheetByName(sheetName);
  
  if (!sheet) {
    sheet = spreadsheet.insertSheet(sheetName);
    sheet.appendRow(['Client Msg ID', 'Type', 'Text', 'User', 'Timestamp', 'Formatted Date', 'Reply Count', 'Reply Users Count']);
  }
  
  return sheet;
}
```

- チャンネルごとのシートが存在しない場合は新規作成する．
- ヘッダー行を追加する．

---

#### 4. メッセージ取得関数

```javascript
function fetchMessagesForDateRange(oldestTimestamp, latestTimestamp, sheet, existingMessages, channelID) {
  var fetchUrl = 'https://slack.com/api/conversations.history?channel=' + channelID + '&limit=1000&oldest=' + oldestTimestamp + '&latest=' + latestTimestamp;
  const options = {
    "headers": { 'Authorization': 'Bearer ' + OAuthAccessToken },
    "muteHttpExceptions": true
  };

  var messages = [];
  var hasMore = true;
  var cursor = '';

  do {
    var urlWithCursor = fetchUrl + (cursor ? '&cursor=' + cursor : '');
    var res;
    var retryCount = 0;
    var success = false;

    while (!success && retryCount < 5) {
      try {
        res = UrlFetchApp.fetch(urlWithCursor, options);
        success = true;
      } catch (e) {
        Logger.log("Error fetching messages: " + e);
        if (e.toString().includes('429')) {
          Utilities.sleep(Math.pow(2, retryCount) * 1000);
          retryCount++;
        } else {
          throw e;
        }
      }
    }

    if (!success) {
      throw new Error("Failed to fetch messages after multiple retries due to rate limiting.");
    }

    res = JSON.parse(res.getContentText());

    if (!res.ok) {
      Logger.log('Error fetching messages: ' + res.error);
      return;
    }

    Logger.log("Fetched " + res.messages.length + " messages for channel: " + channelID);

    var newMessages = [];
    res.messages.forEach(function (v) {
      if ('thread_ts' in v) {
        var threadUrl = 'https://slack.com/api/conversations.replies?channel=' + channelID + '&ts=' + v.thread_ts;
        var reply;
        var replyRetryCount = 0;
        var replySuccess = false;
        
        while (!replySuccess && replyRetryCount < 5) {
          try {
            reply = UrlFetchApp.fetch(threadUrl, options);
            replySuccess = true;
          } catch (e) {
            Logger.log("Error fetching replies: " + e);
            if (e.toString().includes('429')) {
              Utilities.sleep(Math.pow(2, replyRetryCount) * 1000);
              replyRetryCount++;
            } else {
              throw e;
            }
          }
        }

        if (!replySuccess) {
          throw new Error("Failed to fetch thread messages after multiple retries due to rate limiting.");
        }

        reply = JSON.parse(reply.getContentText());

        if (reply.messages && Array.isArray(reply.messages)) {
          newMessages = newMessages.concat(reply.messages.reverse());
        }
      } else {
        newMessages.push(v);
      }
    });

    newMessages = newMessages.map(function (v) {
      var date = new Date(parseFloat(v.ts) * 1000);
      var formattedDate = date.toLocaleString();
      return {
        client_msg_id: v.client_msg_id || '',
        type: v.type || '',
        text: v.text || '',
        user: v.user || '',
        ts: v.ts || '',
        formattedDate: formattedDate,
        reply_count: v.reply_count || 0,
        reply_users_count: v.reply_users_count || 0
      };
    });

    messages = messages.concat(newMessages);
    hasMore = res.has_more;
    cursor = res.response_metadata && res.response_metadata.next_cursor;

    Utilities.sleep(1000);
  } while (hasMore);

  if (messages.length > 0) {
    var userIds = [...new Set(messages.map(m => m.user))];
    var userDisplayNames = getUserDisplayNames(userIds);

    var finalMessages = messages.map(function (msg) {
      return [
        msg.client_msg_id,
        msg.type,
        msg.text,
        userDisplayNames[msg.user] || msg.user,
        msg.ts,
        msg.formattedDate,
        msg.reply_count,
        msg.reply_users_count
      ];
    });

    finalMessages = finalMessages.filter(msg => !existingMessages.has(msg[0]));

    if (finalMessages.length > 0) {
      sheet.getRange(sheet.getLastRow() + 1, 1, finalMessages.length, finalMessages[0].length).setValues(finalMessages.reverse());
      Logger.log("Appended " + finalMessages.length + " new messages to the sheet for channel: " + channelID);
    } else {
      Logger.log("No new messages to append for channel: " + channelID);
    }
  } else {
    Logger.log("No messages found for this date range for channel: " + channelID);
  }
}
```

- `fetchMessagesForDateRange`：Slack APIを使用して指定した期間内のメッセージを取得し、スプレッドシートに追加する．


---

#### 5. ユーザー名取得関数

```javascript
function getUserDisplayNames(userIds) {
  var displayNames = {};
  
  userIds.forEach(function(userId) {
    var userFetchUrl = 'https://slack.com/api/users.info?user=' + userId;
    const options = {
      "headers": { 'Authorization': 'Bearer ' + OAuthAccessToken },
      "muteHttpExceptions": true
    };

    var success = false;
    var retryCount = 0;

    while (!success && retryCount < 5) {
      try {
        var response = UrlFetchApp.fetch(userFetchUrl, options);
        var userInfo = JSON.parse(response.getContentText());
        
        if (userInfo.ok) {
          displayNames[userId] = userInfo.user.profile.display_name || userInfo.user.name;
        } else {
          Logger.log('Error fetching user info for ' + userId + ': ' + userInfo.error);
        }
        success = true;
      } catch (e) {
        Logger.log("Error fetching user info: " + e);
        if (e.toString().includes('429')) {
          Utilities.sleep(Math.pow(2, retryCount) * 1000);
          retryCount++;
        } else {
          throw e;
        }


      }
    }
  });

  return displayNames;
}
```

- `getUserDisplayNames`：Slack APIからユーザーの表示名を取得する（スプレッドシートの"User"列に表示名を出力するため）．

コメント
---
今回初めて記事作成に挑戦してみました．至らぬ点もあると思いますので，内容等に関しまして何かご指摘・質問がありましたら連絡いただけますと幸いです．




## 参考文献・関連ページ
- [Slack の特定のチャンネルのメッセージ一覧をスプレッドシートにエクスポートするツールを作ってみた](https://qiita.com/tanabee/items/0d17d58ac6008329fdaa, "Slack の特定のチャンネルのメッセージ一覧をスプレッドシートにエクスポートするツールを作ってみた")
- [Slack の特定のチャンネルのメッセージ一覧をスプレッドシートにエクスポートするツールを作ってみた](https://qiita.com/tanabee/items/0d17d58ac6008329fdaa, "")
- [Slackのプライベートチャンネルのバックアップ](https://qiita.com/ynaito/items/8f2f1de8426fe1dcf7c5, "")

:::note
本ページの作成およびコード作成に関しては，ChatGPTの助けを借りました．
:::