メッセージを定期的に取得するGAS
定期的にSlackのワークスペース内の全てのメッセージをSpreadSheetに記録します。
特徴は次の通りです。
- チャンネルごとにシートをつくる
- 定期実行時に記録されていないメッセージだけ記録する
起きやすい thread_not_found
対策
実はSlackのAPI操作に関するコードは調べると色々出てきますが、conversations.replies
メソッドでエラーが発生することがあります。
こちらの記事で検証していました。
チャンネルのトピックを設定しました: change_topic
、チャンネルの説明を設定しました
などのリプライできないメッセージに対してエラーが出ているとのことです。
この場合に、エラーを投げないようにしました。
実装
まずはGASのソースコードです。
ソースコード
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 firstExecInThisChannel = 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') {
firstExecInThisChannel = true;
}
}
const chNum = (parseInt(PropertiesService.getScriptProperties().getProperty('last_channel_no')) + 1) % channelInfo.length;
console.log('chNum:', chNum);
const ch = channelInfo[chNum];
console.log(ch);
let timestamp;
let first;
if (firstExecInThisChannel) {
timestamp = 0;
first = '1';
} else {
timestamp = ssCtrl.getLastTimestamp(ch, 1);
first = (parseFloat(timestamp) - 2592000).toString();
}
console.log('first:', first);
const tsArray = ssCtrl.getThreadTS(ch, first);
console.log('tsArray.length:', tsArray.length);
if (tsArray != '1') {
const threadMessages = slack.requestThreadMessages(ch, tsArray, timestamp);
ssCtrl.saveChannelHistory(channelInfo[chNum], threadMessages, memberList);
ssCtrl.sortSheet(ch);
}
PropertiesService.getScriptProperties().setProperty('last_channel_no', chNum);
}
function SetProperties() {
PropertiesService.getScriptProperties().setProperty('slack_api_token', 'YOUR_SLACK_API_TOKEN');
PropertiesService.getScriptProperties().setProperty('folder_id', 'YOUR_DRIVE_FOLDER_ID');
PropertiesService.getScriptProperties().setProperty('last_channel_no', -1);
}
function FindOrCreateFolder(folder, folderName) {
Logger.log(typeof folder);
let itr = folder.getFoldersByName(folderName);
if (itr.hasNext()) {
return itr.next();
}
let newFolder = folder.createFolder(folderName);
newFolder.setName(folderName);
return newFolder;
}
function FindOrCreateSpreadsheet(folder, fileName) {
let it = folder.getFilesByName(fileName);
if (it.hasNext()) {
let file = it.next();
return SpreadsheetApp.openById(file.getId());
} else {
let ss = SpreadsheetApp.create(fileName);
folder.addFile(DriveApp.getFileById(ss.getId()));
return ss;
}
}
function DownloadData(url, folder, savefilePrefix, token) {
const options = {
"headers": { 'Authorization': 'Bearer ' + token }
};
let response = UrlFetchApp.fetch(url, options);
let fileName = savefilePrefix + "_" + url.split('/').pop();
let fileBlob = response.getBlob().setName(fileName);
console.log("Download:", url, "=>", fileName);
let itr = folder.getFilesByName(fileName);
if (itr.hasNext()) {
folder.removeFile(itr.next());
}
return folder.createFile(fileBlob);
}
function UnescapeMessageText(text, memberList) {
return (text || '')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/&/g, '&')
.replace(/<@(.+?)>/g, function ($0, userID) {
let name = memberList[userID];
return name ? "@" + name : $0;
});
}
let SlackAccessor = (function () {
function SlackAccessor(apiToken) {
this.APIToken = apiToken;
}
const MAX_HISTORY_PAGINATION = 10;
const HISTORY_COUNT_PER_PAGE = 1000;
let p = SlackAccessor.prototype;
p.requestAPI = function (path, params = {}) {
let url = "https://slack.com/api/" + path + "?";
let qparams = [];
for (let k in params) {
qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
}
url += qparams.join('&');
const headers = {
'Authorization': 'Bearer ' + this.APIToken
};
console.log("==> GET", url);
const options = {
'headers': headers,
};
let response = UrlFetchApp.fetch(url, options);
let data = JSON.parse(response.getContentText());
if (data.error) {
console.log(data);
console.log(params);
throw "GET " + path + ":" + data.error;
} else {
return data;
}
};
p.requestMemberList = function () {
let response = this.requestAPI('users.list');
let memberNames = {};
response.members.forEach(function (member) {
memberNames[member.id] = member.name;
console.log("memberNames[" + member.id + "] =", member.name);
});
return memberNames;
};
p.requestChannelInfo = function () {
let 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 = '1') {
let _this = this;
let messages = [];
const options = {
'oldest': oldest,
'count': HISTORY_COUNT_PER_PAGE,
'channel': channel.id
};
let loadChannelHistory = function (oldest) {
if (oldest) {
options['oldest'] = oldest;
}
let response = _this.requestAPI('conversations.history', options);
messages = response.messages.concat(messages);
return response;
};
let resp = loadChannelHistory();
let 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, tsArray, oldest) {
let allMessages = [];
let _this = this;
let loadThreadHistory = function (options, oldest) {
if (oldest) {
options['oldest'] = oldest;
}
Utilities.sleep(1250);
let response = _this.requestAPI('conversations.replies', options);
return response;
};
tsArray = tsArray.reverse();
tsArray.forEach(ts => {
try {
if (oldest === void 0) { oldest = '1'; }
let options = {
'oldest': oldest,
'ts': ts,
'count': HISTORY_COUNT_PER_PAGE,
'channel': channel.id
};
let messages = [];
let resp = loadThreadHistory(options);
messages = resp.messages.concat(messages);
let 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();
allMessages = allMessages.concat(messages);
console.log("channel(id:" + channel.id + ") =", channel.name, "ts =", ts, "=> loaded replies.");
} catch (e) {
console.error(`Error fetching thread messages for ts ${ts}: ${e}`);
}
});
return allMessages;
};
return SlackAccessor;
})();
let 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;
const COL_LINK = 5;
const COL_TIME = 6;
const COL_REPLY_COUNT = 7;
const COL_IS_REPLY = 8;
const COL_JSON = 9;
const COL_MAX = COL_JSON;
const COL_WIDTH_DATE = 130;
const COL_WIDTH_TEXT = 800;
const COL_WIDTH_URL = 400;
let p = SpreadsheetController.prototype;
p.findOrCreateSheet = function (sheetName) {
let sheet = null;
let sheets = this.ss.getSheets();
sheets.forEach(function (s) {
let name = s.getName();
if (name == sheetName) {
sheet = s;
return;
}
});
if (sheet == null) {
sheet = this.ss.insertSheet();
sheet.setName(sheetName);
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) {
let sheetName = this.channelToSheetName(channel);
return this.findOrCreateSheet(sheetName);
};
p.sortSheet = function (channel) {
let sheet = this.getChannelSheet(channel);
let lastRow = sheet.getLastRow();
let lastCol = sheet.getLastColumn();
sheet.getRange(1, 1, lastRow, lastCol).sort(COL_TIME);
};
// 最後に記録したタイムスタンプ取得
p.getLastTimestamp = function (channel, isReply) {
let sheet = this.getChannelSheet(channel);
let lastRow = sheet.getLastRow();
if (lastRow > 0) {
let rowOfLastUpdate = 0;
for (let rowNo = lastRow; rowNo >= 1; rowNo--) {
if (parseInt(sheet.getRange(rowNo, COL_IS_REPLY).getValue()) == isReply) {
rowOfLastUpdate = rowNo;
break;
}
}
if (rowOfLastUpdate === 0) {
return '1';
}
console.log('last timestamp row:', rowOfLastUpdate);
console.log('last timestamp:', sheet.getRange(rowOfLastUpdate, COL_TIME).getValue());
return sheet.getRange(rowOfLastUpdate, COL_TIME).getValue();
}
return '1';
};
// スレッドが存在するものを取得
p.getThreadTS = function (channel, firstTs) {
let sheet = this.getChannelSheet(channel);
let lastRow = sheet.getLastRow();
if (lastRow > 0) {
console.log('lastRow > 0');
let firstRow = 0;
for (let i = 1; i <= lastRow; i++) {
let ts = sheet.getRange(i, COL_TIME).getValue();
if (ts > firstTs) {
firstRow = i;
break;
}
}
let tsArray = [];
if (firstRow == 0) {
return '1';
}
for (let i = firstRow; i <= lastRow; i++) {
if (!(sheet.getRange(i, COL_REPLY_COUNT).isBlank())) {
let ts = sheet.getRange(i, COL_TIME).getValue();
tsArray.push(ts.toFixed(6).toString());
}
}
console.log("getThreadTS firstTs:", firstTs);
console.log("getThreadTS tsArray:", tsArray);
return tsArray;
}
return '1';
};
// ダウンロードフォルダの確保
p.getDownloadFolder = function (channel) {
let sheetName = this.channelToSheetName(channel);
return FindOrCreateFolder(this.folder, sheetName);
};
// 取得したチャンネルのメッセージを保存する
p.saveChannelHistory = function (channel, messages, memberList, token) {
console.log("saveChannelHistory:", this.channelToSheetName(channel));
let _this = this;
let sheet = this.getChannelSheet(channel);
let lastRow = sheet.getLastRow();
let currentRow = lastRow + 1;
let downloadFolder = this.getDownloadFolder(channel);
let record = [];
for (let msg of messages) {
let date = new Date(+msg.ts * 1000);
console.log("message:", date);
if ('subtype' in msg && msg.subtype === 'thread_broadcast') {
continue;
}
let row = [];
let formattedDate = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
row[COL_DATE - 1] = formattedDate;
row[COL_USER - 1] = memberList[msg.user] || msg.username;
row[COL_TEXT - 1] = UnescapeMessageText(msg.text, memberList);
let url = "";
let 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 {
let file = DownloadData(url, downloadFolder, formattedDate, token);
let 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 && msg.ts != msg.thread_ts) {
row[COL_IS_REPLY - 1] = 1;
}
row[COL_JSON - 1] = JSON.stringify(msg);
record.push(row);
}
if (record.length > 0) {
let range = sheet.insertRowsAfter(lastRow || 1, record.length)
.getRange(lastRow + 1, 1, record.length, COL_MAX);
range.setValues(record);
}
downloadFolder.setTrashed(true);
};
return SpreadsheetController;
})();
コード内の SetProperties()
関数にSlackのAPIトークンとGoogleDriveのフォルダIDを入れます。
function SetProperties() {
PropertiesService.getScriptProperties().setProperty('slack_api_token', 'YOUR_SLACK_API_TOKEN');
PropertiesService.getScriptProperties().setProperty('folder_id', 'YOUR_DRIVE_FOLDER_ID');
PropertiesService.getScriptProperties().setProperty('last_channel_no', -1);
}
SlackのAPIトークン
こちらから取得します。
「App Name」は好きな名前を、「Pick a workspace~」はメッセージを取得したいワークスペースを選択します。
個々から「OAuth & Permissions」に移動します。
「Scopes」の「User Token Scopes」で必要なものを選んでいきます。
必要なのは以下です。
- channels:history
- channels:read
- files:read
- users:read
追加後は「install to "ワークスペース名"」を押すとトークンを取得できます。
これで完了です!
フォルダID
今回の場合は以下の構成のSlackLog_Save
のIDとなります。
sample.
│ Slack_log
│ └─SlackLog_Save
│ └─Slack_log_SS ← SpreadSheet
│
└─app ← Google App Script
これで完了です!
後は定期的に動かすだけ
GASのトリガーを設定します。私の場合は「月に1度、午前0時に実行」としました。
トリガーで自動化する前には実行してConsoleを確認してください。
参考
いくつかの記事をもとにしてソースコードをアップデートしました。