「以前までできていたSlackのメッセージログ保存ができなくなった」
「Slackでメッセージを保存したい」
この記事は、そんな方へ向けて書いています。
先取り結論
- Slackレガシートークンは使えなくなった。Slack APIとGoogle Apps Scriptで作成する
- SlackのトークンとGoogleアカウントがあればコピペでOK
- Slackのトークンは、管理者であれば誰でも手に入る
- トリガー設定により定期的な実行が可能
目次
これまでのSlackログ保存方法はエラーが出る
これまでSlackのレガシートークンを使用した保存方法を解説してきました。
-
コピペのみ。Slackメッセージログを自動で保存する方法
「スクリプトを書かずにSlackのメッセージを定期的に保存したい」 「考えるのが面倒なので、コピペでSlackバックアップの仕組みを作りたい」 この記事はそんな方へ向けて書いています。 今回は、完全に ...
現状、Slackのレガシートークンは使用できなくなりました。
そこで、Slack APIを使った新しい保存方法を解説します。
(最新版)Slackメッセージログ保存方法
保存に必要なものは以下の通り。
- Slack API用のOAuthトークン
- Googleアカウント
OAthトークンは、Slackの情報を外部サービス(今回はGoogle)に読み込ませるためのパスワードのようなものです。
Slackワークスペース管理者であれば誰でも取得できます。
大まかな流れは以下の通り。
- Slackのウェブページでトークン取得
- GoogleDriveでフォルダ作成
- Google Apps Scriptコピペ、実行
- ドライブ確認
- 自動保存の設定
SlackでOAuth Token取得
まずはSlackにログインした状態で以下のリンクへアクセスし、SlackのAPI Tokenを取得します。
「Create New App」をクリックしてください。WEBアプリを作成するわけではありませんが、トークンの取得に必要な作業です。
「From scratch」をクリック。
新しく作成するアプリ名と、ワークスペースを決定します。
アプリ名はわかりやすいように「slack_log」などとしておきましょう。
ワークスペースが複数ある場合は、メッセージを保存したいワークスペースを選択してください。
その後、「Create App」をクリック。
無事アプリを作成できたら、そのまま「OAuth & permissions」をクリック。
「Scopes」の欄にある「Add an OAuth Scope」をクリックしてください。
ここで、以下の4つのAuth Scopeを追加してください。
- channels:history
- channels:read
- files:read
- users:read
検索ボックスに入力すると、候補が出てきます。
ちなみにこれは、Slackから外部サービスへの読み取りにおいて、何の読み込みを許可するかを設定しています。
このように追加できればOK。
そしてページ内上部「OAuth & permissions」の「Install to Workspace」をクリックしましょう。
Slack logがSlackワークスペースにアクセスする権限を許可しましょう。
「許可する」をクリック。
すると、このような画面が表示されます。
「OAuth Tokens for your Workspace」の下に、「User OAuth Token」が表示されます。
※基本的に「xoxp-」のような文字から始まります。
後ほど使用します。メモ帳などにこのトークンを控えておきましょう。
GoogleDriveにフォルダ作成
次に、Google Driveにアクセスし、Slackメッセージログ保存用のフォルダを作成していきます。
以下のリンクからGoogle Driveにアクセスし、左上の「新規」から「フォルダ」をクリック。
フォルダ名は自由です。
「Slacklog」としてみました。
マイドライブから、先程作成したフォルダに入りましょう。
「マイドライブ>SlackLog(フォルダ名)」と表示されている状態で、フォルダIDを控えましょう。
https://drive.google.com/drive/u/0/folders/XXXXXXXXXXXX
上記「XXXXXXXXX」の部分です。
こちらも、メモ帳等に控えておいてください。
Google Apps Scriptにコピペ
いよいよ実際にGoogle Apps Scriptを作成していく作業に入ります。
コピペでいけるように解説しているので安心してください。
まずは以下のリンクから「Google Apps Script」にアクセスし、「新しいプロジェクト」をクリック。
新規プロジェクト作成時に、Google Apps Scriptのページが開けないエラーが多発しています。注意しましょう。
-
「ページが応答しません」Google Apps Scriptが開けない場合の対処法3つ
「Google Apps Scriptが開けない」 「新規スクリプトファイルで毎回同じエラーが起きる」 この記事は、そんな方へ向けて書いています。 先取り結論 少し待つ>ページを離れる>再読み込み 全 ...
無事、Scriptファイルを開けたら、まずは書いてある部分を全て消してください。
function myFunction(){
}
この部分です。全て消してOKです。
真っ白な状態から2ステップでScriptを作成していきます。
- コピペする
- トークン、フォルダID書き換え
まずはScriptコピペしていきます。以下のテキストを最初から最後まで全てコピーしてください。
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(/</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 () {
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());
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;
})();
今回はインデントの見栄えは無視してください。
無事、このようにコピペできたら、続いてトークン部分、フォルダID部分を書き換えていきます。
76〜80行目あたりに「'XXXXXXX'」と書かれている箇所があります。
77行目の「XXX」をSlackのトークンに、78行目の「XXX」をフォルダIDに書き換えてください。
※間違えるので、必ずコピペしましょう。
これで、Scriptの完成です!
Google Apps Scriptを実行
最後に、作成したGoogle Apps Scriptを実行します。
「デバッグ」の右側が「Run」になっている状態で「実行」をクリックしましょう。
Google Apps Scriptがそれ以外のサービス(Spreadsheet)へ書き込むわけですから、承認が必要となります。
- 「権限を確認」をクリック
- 自身のアカウント選択
- 左下の「詳細」をクリック
- 安全ではなページに移動
- 「許可」をクリック
詳しくは、以下の記事を参考にしてください。
-
GAS実行時に「このアプリはGoogleで確認されていません」と出る原因と対処法
「Google Apps Scriptを実行しようとしたら、危険サイトのような警告が出た」 「確認されていません、以降の進め方がわからない」 「アプリを承認していいのかどうかわからない」 この記事は、 ...
実行結果
実行ログ部分に「実行完了」と出ればOKです。
※Google Apps Scriptには「Scriptの実行時間は6分以内」という6分ルールが存在します。チャンネルやログの量によっては6分を超えてエラーが出る可能性があります。
先程指定したフォルダを確認してみましょう。
「SlackLog_Save」というフォルダが作成されているはずです。
その中にSlack_Log_SSというSpreadsheetが作成されているので、開いてください。
メッセージログが保存されていますね。
このように、シートがチャンネル名ごとに分かれ、メッセージログが保存されています。
メッセージログを自動保存するには
最後に、メッセージを自動で定期的に保存する方法を解説します。
先程のScriptファイルから、「トリガー(時計のマーク)」をクリックしましょう。
「新しいトリガーを作成します」をクリックします。
トリガーとは、そのプログラムを実行させる「きっかけ」のことを指します。
時間や日付、Googleフォームの送信をトリガーとして設定することができます。
- イベントのソースを選択:時間主導型
- 時間ベースのトリガーのタイプ:日付ペースのタイマー
- 時刻:午前4時〜5時
以上のように設定しましょう。
設定後に「保存」をクリック。
上記のようにトリガーが追加されたら、自動保存の設定完了です。
お疲れ様でした。
こちらの記事はryota-moさんのGithubを参考にさせていただきました。
この場を借りてお礼させていただきます。有益なScriptをありがとうございます。