6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メッセージを定期的に取得するGAS

定期的にSlackのワークスペース内の全てのメッセージをSpreadSheetに記録します。

image.png

特徴は次の通りです。

  • チャンネルごとにシートをつくる
  • 定期実行時に記録されていないメッセージだけ記録する

起きやすい thread_not_found 対策

実はSlackのAPI操作に関するコードは調べると色々出てきますが、conversations.repliesメソッドでエラーが発生することがあります。

こちらの記事で検証していました。

チャンネルのトピックを設定しました: change_topicチャンネルの説明を設定しました などのリプライできないメッセージに対してエラーが出ているとのことです。

この場合に、エラーを投げないようにしました。

実装

まずはGASのソースコードです。

ソースコード
code.gs
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(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&quot;/g, '"')
        .replace(/&amp;/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トークン

こちらから取得します。

Create New App」を選択します。
image.png

From scratch」を選択します。
image.png

App Name」は好きな名前を、「Pick a workspace~」はメッセージを取得したいワークスペースを選択します。
image.png

個々から「OAuth & Permissions」に移動します。
image.png

Scopes」の「User Token Scopes」で必要なものを選んでいきます。
image.png

必要なのは以下です。

  • channels:history
  • channels:read
  • files:read
  • users:read

追加後は「install to "ワークスペース名"」を押すとトークンを取得できます。
image.png

image.png

これで完了です!

フォルダID

今回の場合は以下の構成のSlackLog_SaveのIDとなります。

bash
sample.
│  Slack_log
│  └─SlackLog_Save
│    └─Slack_log_SS ← SpreadSheet
│
└─app ← Google App Script

画像の黒く塗りつぶした部分です。
image.png

これで完了です!

後は定期的に動かすだけ

GASのトリガーを設定します。私の場合は「月に1度、午前0時に実行」としました。
image.png

トリガーで自動化する前には実行してConsoleを確認してください。

参考

いくつかの記事をもとにしてソースコードをアップデートしました。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?