ふぁメモ

主に技術系のメモをしたいけどやっぱり適当日記。たまにPHPコード載ってるけどメモ書き程度のスクリプトなのでそのまま使っちゃダメ。

Slack2gmailを改造しようの巻

Slackログのエクスポート

Slackのログの保存って結構面倒です。

昔はIRCクライアントで接続できたので適当なbotやクライアントの設定でログが取れたんですが、なくなっちゃいましたし。

公式でやるにはEnterprise Gridプランを契約してログが管理人に読まれることを常に明示するか、他ユーザーの了承を得ている証明か当局の命令書をSlack社に提出しなければならないとか… プライバシーに関わるとはいえそこまで…っていう。

でもAPIアクセスだとトークン発行したユーザーが参加しているチャンネルなら普通に読めます。 無料版だと1万postを上限に過去のやりとりが消えていくので定期的なログ保存ツールは割と重宝されます。

探すと「slack-dump」みたいな(似たような)名前のツールがいくつか見つかります。

私が愛用しているslack2gmail

ここのfork先の

こちらの方のGoogle Apps Scriptですね。

でも昔から使ってるやつは大丈夫なんですけど、新規に導入しようとすると動かないんですよ。 というかレガシートークンが発行停止になっててもう取得できないっていう。

改造しよう

Permissions

リンク先ではOAuth認証を実装しないとダメみたいなことが書いてありましたが、なんてことはなく、パーミッションを設定してOAuth Access Tokenを使えば改造不要で利用できました。

なんだ、動くじゃん。

Slack側が旧スクリプトに寛容なのかGASがうまいことやってくれてるのかわかりませんが。

共有チャンネルとユーザー名

でもふと気付くと一部のチャンネルが取れていないんです。

他のワークスペースのチャンネル&ユーザーを招待した共有チャンネルってやつですね。

共有チャンネルは channels.history では取得できなくて conversations.history を使う必要があるみたいです。 テストしてみたら出力されている内容は互換性がある感じです。

じゃあ置き換えるだけですね。おっけー。

しかし名前が出ない。

もともとSlackのAPIでは名前はユーザIDが返ってくるのであらかじめ users.list にアクセスしてユーザIDと名前の対照リストを取得した上で置換するって手筈なんですが、共有チャンネルの別のスペースの参加者のぶんは取れないわけです。(ちゃんとドキュメント読んでないのであくまで想像です)

しかしレスポンスの中身をよく見ると、発言したユーザーの情報は user_profile っていうプロパティの中にいろいろ入ってます。 ここから名前を拾えば大丈夫そうです。

しかしそのユーザーが発言するまで取得できません。 取得前に他のユーザーから呼びかけられていたりしますと、名前が紐付けられないわけです。 じゃあ2回走査すればいいかというと、その日は全く発言がなかったりする場合だってあるわけです。

まあ話の流れでわかりそうなので妥協で…というのもナンなので、取得する代わりにデータとして設定しておけるようにしましょう。 新しい人がでてきたらその都度IDと名前をデータに追加していく方向で。

泥臭いですがとりあえず実用的になりました。

スレッド

また一部の発言が取れていないことに気付くわけです。

スレッドにだけ返信したヤツが入っていません。

スレッドは conversaisons.replies にチャンネルIDとスレッドタイムスタンプを投げて取得するらしいです。なんて面倒な。 conversasions.history にオプション1つつけて取得できるようにしてくれてもいいのに。

まあスレッドがあると時系列順ではわかりにくくなってしまうので別々のほうが都合がいいのかな。 てゆーか日付跨ぐ場合どうしよう。とりあえず親発言のタイムスタンプ準拠でいいか。

容量超過時

Gmailのメールにぶち込むと容量制限だか行数制限だかをくらうので、元スクリプトでは設定文字数を超えたぶんは破棄できるようになっていますが、破棄されたぶんは取得できません。

単に最後に取得したメッセージのタイムスタンプを保存して次回APIに投げてるだけなので、破棄されるポイントのタイムスタンプを保存して次回続きから取得できるように改造しましょう。

今思いつきましたが分割して続けて送信するってのもアリですね。まあそれは次回に回すとしましょう。

トークンもプロパティに保存しよう

公開にあたってソースにAPIトークンが載ってると事故りそうなので、今作業中。

でも前述のユーザIDリストは面倒なのでソースに直書きのままです。

…って、standaloneのGASって入力プロンプト出せないんかい。

じゃあ諦めてそのまま公開しよう。

成果物

なにぶん素人の改造なもんでアレな部分が散見されますが、とりあえずの現状報告です。

既知の不具合というか仕様:1日以上前のスレッドについた返信は追えない。

Googleドライブからコピーできるようにしたかったけどなんかうまくいかない。 slack2gmail_kai.gs

// Slackログをメールで送信
// base: http://qiita.com/tf-oikawa/items/578d45ed7dcb81d4e2f2

// OAuth Access Token(Slack App の Add features and functionality → Permissions を要確認:
//  usergroups:read, channels:history, channels:read, files:read, groups:history, groups:read, mpim:history, mpim:read, team:read, usergroups:read, users:read, im:read, im:history あたり)

// 一度実行してプロパティに保存されたら空欄OK
var API_TOKEN = '';

// メール送信先
// 一時実行してプロパティに保存されたら空欄OK
var MAIL_TO = '';

// メール件名プレフィクス
var MAIL_TITLE_PREFIX = '[SlackLog]';

// 取り込み最大量(1000件 x 10ページ)
var MAX_HISTORY_PAGINATION = 10;
var HISTORY_COUNT_PER_PAGE = 1000;

// 1メールあたりの最大文字数
var MAX_MAIL_LENGTH = 100000;

// 最大文字数を超えた場合、次回起動時に 1: そこから再開 2:前日分から再開 0: 超えた分は破棄する(前回最終取得時刻分より再開)
// ※再開するタイムスタンプはファイル→プロジェクトプロパティ→スクリプトのプロパティに保存されていて変更可能
var RESUME_TYPE = 2;

// 共有チャンネル用のユーザー名定義(新しく発言した人がいたら[memberdefines]メールが届くので都度追加する)
var presetMemberNames = {
// 'UXXXXXXX': '△△△',
// 'UXXXXXXX': '■■■',
};

//プライベートチャンネルの読み出し 1:する 0:しない
var ENABLE_PRIVATE = 1;

//グループチャット(MPIM)の読み出し 1:する 0:しない
var ENABLE_MPIM = 1;

//DM(IM)の読み出し 1:する 0:しない
var ENABLE_IM = 1;

//参加していないオープンチャンネルの読み出し(1:する 0:しない)
var OPEN_ALL = 0;

// プロパティのインポート/エクスポート 1:する 0:しない (プロパティのexportの文字列を移動先でimportに登録する)
var IMPORT_ENABLE = 0;
var EXPORT_ENABLE = 0;

//dim
var additionalMemberNames = {};
var prop = {};

function StoreLogsDelta() {
    var logger = new SlackChannelHistoryLogger();
    logger.run();
};

var SlackChannelHistoryLogger = (function () {
    function SlackChannelHistoryLogger() {
        this.memberNames = presetMemberNames;
    }

    SlackChannelHistoryLogger.prototype.requestSlackAPI = function (path, params) {
        if (params === void 0) {
            params = {};
        }
        var url = "https://slack.com/api/" + path + "?";
        var qparams = [];
        for (var k in params) {
            qparams.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
        }
        url += qparams.join('&');
        Logger.log("==> GET " + url);
        var headers = {
            'Authorization': 'Bearer ' + API_TOKEN
        };
        var options = {
            'method': 'GET',
            'headers': headers
        };
        try {
            var resp = UrlFetchApp.fetch(url,options);
        }catch(e){
            Logger.log("==> Failed");
            return false;
        }
        if (resp.getContentText().length == 0){
            Utilities.sleep(2 * 1000);
            try {
                var resp = UrlFetchApp.fetch(url,options);
            }catch(e){
                Logger.log("==> Failed");
                return false;
            }
        }
        var data = JSON.parse(resp.getContentText());
        if (data.error) {
            throw "GET " + path + ": " + data.error;
        }
        return data;
    };
    SlackChannelHistoryLogger.prototype.run = function () {
        var _this = this;
        var p = PropertiesService.getScriptProperties().getProperties();
        if (p != null) {
            _this.prop = p;
        } else {
            _this.prop = {};
        }

        if (_this.prop['import'] === undefined){
            _this.prop['import'] = 0;
        }

        if (_this.prop['export'] === undefined){
            _this.prop['export'] = 0;
        }

        if ( (IMPORT_ENABLE) && (_this.prop['import'] !== undefined) && (_this.prop['import'] !== 0) ){
            var import = _this.prop['import'];
            _this.prop = JSON.parse(import);
            _this.prop['import'] = 0;
        }

        if (API_TOKEN == ''){
            if (_this.prop['slack_api_token'] !== undefined){
                API_TOKEN = _this.prop['slack_api_token'];
            }
        }
        _this.prop['slack_api_token'] = API_TOKEN;

        if (MAIL_TO == ''){
            if (_this.prop['mail_address'] !== undefined){
                MAIL_TO = _this.prop['mail_address'];
            }
        }
        _this.prop['mail_address'] = MAIL_TO;

        var usersResp = this.requestSlackAPI('users.list');
        usersResp.members.forEach(function (member) {
            _this.memberNames[member.id] = member.name;
        });
        var teamInfoResp = this.requestSlackAPI('team.info');
        this.teamName = teamInfoResp.team.name;

        var options3 = {};
        options3['types'] = 'public_channel';
        if (ENABLE_PRIVATE){
            options3['types'] += ',private_channel';
        }
        if (ENABLE_MPIM){
            options3['types'] += ',mpim';
        }
        if (ENABLE_IM){
            options3['types'] += ',im';
        }
        var channelsResp = this.requestSlackAPI('conversations.list',options3);

        var title = "";
        var body = "";
        for (var _i = 0, _a = channelsResp.channels; _i < _a.length; _i++) {
            var ch = _a[_i];
            if (ch.is_archived) {
                continue;
            }
            if (ch.is_im){
                ch.name = 'DM:' + ch.user;
                if (_this.memberNames[ch.user]){
                    ch.name = 'DM:' + _this.memberNames[ch.user];
                }
            }
            else if (ch.is_mpim){
                ch.name = 'Group:' + ch.name;
            }
            else if (ch.is_private){
                ch.name = 'Private:' + ch.name;
            }
            else if ((!ch.is_member) && (!OPEN_ALL)) {
                continue;
            }
            else {
                ch.name = 'Public:' + ch.name;
            }

            body = this.importHistoryDelta(ch);
            if (body != "") {
                if (body.length > MAX_MAIL_LENGTH) {
                    body = body.substr(0, MAX_MAIL_LENGTH);
                    body += "\n--- string length over " + MAX_MAIL_LENGTH + " ---\n";
                }
                // send mail
                title = MAIL_TITLE_PREFIX + "[" + ch.name + "] " + _this.formatDate(new Date());
                MailApp.sendEmail({
                    to: MAIL_TO,
                    subject: title,
                    body: body
                });
            }
        }

        if (Object.keys(additionalMemberNames).length){
            body = '';
            Object.keys(additionalMemberNames).forEach(function (key) {
                if (body){
                    body += ",\n";
                }
                body += "\t'" + key + "': '" + additionalMemberNames[key] + "'";
            });
            body += "\n";
            // send mail
            title = MAIL_TITLE_PREFIX + "[memberdefines] " + _this.formatDate(new Date());
            MailApp.sendEmail({
                to: MAIL_TO,
                subject: title,
                body: body
            });
        }

        if (EXPORT_ENABLE){
            _this.prop['export'] = JSON.stringify(_this.prop);
        }

        PropertiesService.getScriptProperties().setProperties(_this.prop);
    };
    SlackChannelHistoryLogger.prototype.importHistoryDelta = function (ch) {
        var _this = this;
        Logger.log("importHistoryDelta " + ch.name + " (" + ch.id + ")");
        var oldestKey = ch.name + '-oldest';
        var oldest = 1;
        if (_this.prop[oldestKey] != null) {
            oldest = _this.prop[oldestKey];
        }
        var options = {};
        if (oldest != null) {
            options['oldest'] = oldest;
        }

        var todayString = _this.formatDate(new Date());
        var messages = this.loadMessagesBulk(ch, options);
        if (messages === false){
            return false;
        }

        //insert threads
        var threadIds = [];
            messages.forEach(function (msg) {
            if (msg.thread_ts){
                if (threadIds.indexOf(msg.thread_ts) === -1){
                    threadIds.push(msg.thread_ts);
                }
            }
        });
        var lastGetReplies_ts = 0;
        var previous_ts = 0;
        for(var i=0; i<threadIds.length; i++){
            var options2 = {};
            options2['channel'] = ch.id;
            options2['ts'] = threadIds[i];
            var resp2 = _this.requestSlackAPI('conversations.replies', options2);
            if (resp2){
                //スレッドの最初のメッセージは既に存在しているものなので削除すべきだが繋がりがわかりにくくなるので残す
                //var first = resp2.messages.shift();
                resp2.messages[0].additional_header = "=================== thread ===================" + "\n#thread.ts: " + _this.formatTs(threadIds[i]);
                messages = messages.concat(resp2.messages);
                previous_ts = threadIds[i];
            }
            else {
                //rate limitになった場合は最後に取得できたスレッドIDを保存
                lastGetReplies_ts = previous_ts;
                break;
            }
        };

        var dateStringToMessages = {};
        messages.forEach(function (msg) {
            msg.is_thread = false;
            if (msg.thread_ts){
                var date = new Date(+msg.thread_ts * 1000);
                if (msg.ts != msg.thread_ts){
                    if ((msg.subtype) && (msg.subtype == 'thread_broadcast')){
                    }
                    else {
                        msg.is_thread = true;
                    }
                }
            }
            else {
                var date = new Date(+msg.ts * 1000);
            }
            var dateString = _this.formatDate(date);
            if (!dateStringToMessages[dateString]) {
                dateStringToMessages[dateString] = [];
            }
            dateStringToMessages[dateString].push(msg);
            if ((msg.ts != 0)&&(!msg.is_thread)){
                _this.prop[oldestKey] = msg.ts;
            }
        });

        var body = "";
        if (dateStringToMessages != null && dateStringToMessages.length != 0) {
            var yesterday_last_ts = 0;
            var previous_ts = 0;
            var last_msg_ts = 0;
            var break_flag = false;
            var debug_info = "";
            var start_info = "";
            if (oldest != null) {
                start_info = "#start msg.ts: " + _this.formatTs(oldest) + "\n";
            }

            for (var dateString in dateStringToMessages) {
                body += "=================== date:[" + dateString + "] ===================\n\n";
                dateStringToMessages[dateString].forEach(function (msg) {
                    if (break_flag){ return true; }
                    var date = new Date(+msg.ts * 1000);
                    var user = msg.username ? msg.username : msg.user;
                    if (_this.memberNames[user]) {
                        user = _this.memberNames[user];
                    }
                    else if (msg.user_profile){
                        _this.memberNames[user] = msg.user_profile.display_name + '(' + user + ')';
                        additionalMemberNames[user] = msg.user_profile.display_name;
                        user = msg.user_profile.display_name + '(' + user + ')';
                    }
                    if (msg.additional_header){
                        body += msg.additional_header + "\n\n";
                    }
                    body += '[' + Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss') + '] ' +
                        'from:' + user + "\n" +
                        _this.unescapeMessageText(msg.text) + "\n";
                    if (msg.attachments) {
                        var attachments = JSON.stringify(msg.attachments);
                        body += 'attachments:' + _this.unescapeMessageText(attachments) + "\n";
                    }
                    if (msg.files) {
                        var files = JSON.stringify(msg.files);
                        body += 'files:' + _this.unescapeMessageText(files) + "\n";
                    }
                    body += "\n";

                    if (!msg.is_thread){
                        last_msg_ts = msg.ts;
                    }

                    if ((body.length > (MAX_MAIL_LENGTH - 100))|| ( (lastGetReplies_ts) && (last_msg_ts) && (lastGetReplies_ts < last_msg_ts))) {
                        if ((RESUME_TYPE == 1)&&(previous_ts != 0)){
                            _this.prop[oldestKey] = previous_ts;
                        } else if ((RESUME_TYPE == 2)&&(yesterday_last_ts != 0)){
                            _this.prop[oldestKey] = yesterday_last_ts;
                        }
                        if (body.length > MAX_MAIL_LENGTH){
                            debug_info += "\n#break: over MAX_MAIL_LENGTH (" + body.length + ")\n";
                        }
                        //rate limitでスレッドが途中までしか取得できていない場合、本文もそこまでで打ち切る
                        if ( (lastGetReplies_ts) && (lastGetReplies_ts < last_msg_ts)){
                            debug_info += "\n#break: lastGetReplies_ts=" + _this.formatTs(lastGetReplies_ts) + "\n";
                        }
                        break_flag = true;
                        return true;
                    }

                    debug_info = "#last msg.ts: " + _this.formatTs(last_msg_ts) + "\n";
                    debug_info += "#previous_ts: " + _this.formatTs(previous_ts) + "\n";
                    debug_info += "#yesterday_last_ts: " + _this.formatTs(yesterday_last_ts) + "\n\n";

                    if (!msg.is_thread){
                        previous_ts = msg.ts;
                    }

                });
                yesterday_last_ts = previous_ts;
                if (break_flag){
                    break;
                }
            }
        }
        if (body.length){
            body = start_info + body + debug_info;
        }
        return body;
    };
    SlackChannelHistoryLogger.prototype.formatDate = function (dt) {
        return Utilities.formatDate(dt, Session.getScriptTimeZone(), 'yyyy-MM-dd');
    };
    SlackChannelHistoryLogger.prototype.formatTs = function (dt) {
        if (!dt){
            return dt;
        } else {
            return dt + ' ' + Utilities.formatDate(new Date(+dt*1000), Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm:ss');
        }
    };
    SlackChannelHistoryLogger.prototype.loadMessagesBulk = function (tr, options) {
        var _this = this;
        if (options === void 0) {
            options = {};
        }
        var messages = [];
        options['count'] = HISTORY_COUNT_PER_PAGE;
        options['channel'] = tr.id;

        var loadSince = function (oldest) {
            if (oldest) {
                options['oldest'] = oldest;
            }
            // order: recent-to-older
            var apiPath = 'conversations.history';
            var resp = _this.requestSlackAPI(apiPath, options);
            if (resp === false){
                return false;
            }
            messages = resp.messages.concat(messages);
            return resp;
        };
        var resp = loadSince();
        if (resp === false){
            return false;
        }
        var page = 1;
        while (resp.has_more && page <= MAX_HISTORY_PAGINATION) {
            resp = loadSince(resp.messages[0].ts);
            page++;
        }
        // oldest-to-recent
        return messages.reverse();
    };
    SlackChannelHistoryLogger.prototype.unescapeMessageText = function (text) {
        var _this = this;
        return (text || '')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&quot;/g, '"')
            .replace(/&amp;/g, '&')
            .replace(/<@(.+?)>/g, function ($0, userID) {
                var name = _this.memberNames[userID];
                return name ? "@" + name : $0;
            });
    };
    return SlackChannelHistoryLogger;
})();

他にも…

昔作ったSlackの半年か1年以上前のファイルリスト生成してダウンロードしてからサーバーから削除するPHPスクリプトとかも割と需要ありそうな気がするけど、多用途個人ライブラリに依存してる部分があるのでちょっと改造しないと公開できないだな…。

履歴

  • 2020.06.24 公開
  • 2020.06.29 スレッドが多い時にslack APIのrate limitでエラー終了してしまうのを対策
  • 2020.07.10 groups.*もディスコンと知ったのでconversations.listのtypes=mpimに修正、スレッドが日を跨いだときの処理がおかしかったので修正
  • 2020.07.31 im(DM)も保存できるようにする
  • 2020.11.16 スレッドが伸びたので3日前の取得からやり直したい、みたいなことがよくあるので開始tsをメッセージ文頭に表示するようにした
  • 2021.01.17 プロパティのimport/export機能が杜撰だったので修正
  • 2021.05.20 アクセストークンをtoken=~のクエリパラメータにしているとinvalid_authが出るようになったのでヘッダに埋め込むよう変更