【2021最新版】SlackメッセージログをSlack APIとGASで自動保存する方法【コピペOK】

「以前までできていた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ワークスペース管理者であれば誰でも取得できます。

大まかな流れは以下の通り。

  1. Slackのウェブページでトークン取得
  2. GoogleDriveでフォルダ作成
  3. Google Apps Scriptコピペ、実行
  4. ドライブ確認
  5. 自動保存の設定

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を作成していきます。

  1. コピペする
  2. トークン、フォルダ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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/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)へ書き込むわけですから、承認が必要となります。

  1. 「権限を確認」をクリック
  2. 自身のアカウント選択
  3. 左下の「詳細」をクリック
  4. 安全ではなページに移動
  5. 「許可」をクリック

詳しくは、以下の記事を参考にしてください。

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をありがとうございます。

おまけ:よく質問されるのですが、総合的な社内管理はGoogleWorkspace一択です。まずはGoogleWorkspaceのBusiness Starterプランから始めてみましょう。
Google Workspace

-Slack(スラック)

© 2021 bestcloud