概要
Slackの複数チャンネルの情報を同じGoogleスプレッドシート内に保存する手順.
背景・目的
- 所属していたSlack Organizationを抜けることになった.
- 自分が関わっていたチャンネルが多岐にわたるため,必要なメッセージの選別が面倒.
- 複数チャンネルのバックアップを一括で取れるようにしたい.
バックアップ方法
Googleスプレッドシートを作成し,Slack APIを用いてその中の各シートに各チャンネルのメッセージを保存する.
スプレッドシートの作成・スクリプト編集まではLumitecの記事, "Slackのプライベートチャンネルをバックアップする方法"を参考に行った.
以下が手順である(各項目の詳細については元の記事を確認してください).
①「Slack App」を作成する
②「Slack App」のスコープを設定する
③トークンを取得する
④プライベートチャンネルのIDを取得する
⑤「Googleスプレッドシート」を準備する
⑥スクリプトを編集する
⑦スクリプトを実行する
ただし,スコープには以下の項目を追加する.
名前 | 説明 |
---|---|
channels:history | パブリックチャンネルのメッセージ履歴 |
groups:history | プライベートチャンネルのメッセージ履歴 |
im:history | DM(1対1)メッセージ履歴 |
mpim:history | DM(複数)メッセージ履歴 |
users:read | ユーザーのプロフィール情報 |
また,履歴を残したいチャンネルそれぞれに対して,Slackの画面右上の「:」から, 「チャンネル詳細を開く」→ 「インテグレーション」 を経由して作成したSlackアプリを追加するようにする.
Google Apps script内のコード
GoogleスプレッドシートのApps Scriptを開くところまで終わったら,以下のコードを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. 必要な変数の定義
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. メイン関数の実行
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. シート作成・取得関数
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. メッセージ取得関数
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. ユーザー名取得関数
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 の特定のチャンネルのメッセージ一覧をスプレッドシートにエクスポートするツールを作ってみた
- Slack の特定のチャンネルのメッセージ一覧をスプレッドシートにエクスポートするツールを作ってみた
- Slackのプライベートチャンネルのバックアップ
本ページの作成およびコード作成に関しては,ChatGPTの助けを借りました.