Slackのトーク履歴を定期的に自動保存する方法

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/db623a22-758c-2206-f132-ca6ee62c5c2a.png)

特徴は次の通りです。

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

## 起きやすい `thread_not_found` 対策

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

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

https://dev.classmethod.jp/articles/slackapi-conversations-replies-thread-not-found/

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

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

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

<details><summary>ソースコード</summary>

```javascript: 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;
})();




```
</details>

コード内の `SetProperties()` 関数にSlackのAPIトークンとGoogleDriveのフォルダIDを入れます。


```javascript
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トークン

こちらから取得します。

https://api.slack.com/apps

「**Create New App**」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/a28f1be0-3690-dbf4-da37-1a9d87db946e.png)


「**From scratch**」を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/196fec8f-1f9f-7b0c-6c40-afdc70b8c127.png)

「**App Name**」は好きな名前を、「**Pick a workspace～**」はメッセージを取得したいワークスペースを選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/a5223a2d-2e4c-8263-e1fb-c5e3faec26a2.png)

個々から「**OAuth & Permissions**」に移動します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/6dc87e5c-a9b7-e426-25be-5d64282db565.png)

「**Scopes**」の「**User Token Scopes**」で必要なものを選んでいきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/55900663-49e5-738e-7c62-9add13c915a1.png)

必要なのは以下です。

:::note
- channels:history
- channels:read
- files:read
- users:read
:::

追加後は「**install to "ワークスペース名"**」を押すとトークンを取得できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/647c4026-dadf-e860-6f4c-35b79c2cdafd.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/c090b6a7-6e8a-c15d-b994-bf17b2ce4c73.png)

これで完了です！

### フォルダID

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

```bash:bash
sample.
│  Slack_log
│  └─SlackLog_Save
│    └─Slack_log_SS ← SpreadSheet
│
└─app ← Google App Script
```
画像の黒く塗りつぶした部分です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/722c6f61-ee87-bb2a-cc46-f0473bb393ec.png)

これで完了です！

### 後は定期的に動かすだけ

GASのトリガーを設定します。私の場合は「月に1度、午前0時に実行」としました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/b6beb2c5-1fab-6010-03d8-cf6e791a7a54.png)

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

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

https://best-cloud.jp/slack-message-log-auto-save-gas/#SlackOAuth_Token

https://qiita.com/sampo-cure/items/6b57d8503e37a0d2d1f6

https://qiita.com/momomo_rimoto/items/e9103ac8f858c805f9f0