yotiky Tech Blog

とあるエンジニアの備忘録

Notion - Updateログを適当にまとめてSlackに通知する

TL;DR

  • Notion の All Updates は細かすぎて見るに堪えないので、適当にまとめてSlackに通知する方法
  • SlackにすべてのUpdateを通知する
  • Google Apps Script (GAS) でSlackのログを取得して、メッセージの内容からPage単位にまとめてSlackに通知し直す

f:id:yotiky:20220304002032p:plain:w250

目次

手順

SlackにすべてのUpdateを通知する

Updateの通知をしたいページの右上メニューから、Slackに接続する。

f:id:yotiky:20220303234903p:plain:w150

大量の通知が届くので専用の一時チャンネルを作るのがおすすめ。

f:id:yotiky:20220303235132p:plain:w300

NotionでSlackに接続するとサブページにも適用される。 トップノードの全ページに設定しておくとAll Updatesと同じメッセージが届くはず。

Notionの設定は以上。

SlackでOAuth Token取得

こちらの「SlackでOAuth Token取得」を参考にPermissionの設定まで行う。

次にこちらの「Slackアプリのインストール」までを参考にインストールを行う。

生成されたUser OAuth Tokenと、Bot User OAuth Tokenをコピーしておく。

f:id:yotiky:20220304000417p:plain

GASの実装

スクリプトも先の2つのページから流用させて頂いた。感謝。

GASで新しいプロジェクトを作成してスクリプトをコピペする。

/*
Copyright (c) 2020 ryota-mo
Released under the MIT license
https://github.com/YukinobuKurata/YouTubeMagicBuyButton/blob/master/MIT-LICENSE.txt
*/

function ChannelLogToSlack() {
  SetProperties();
  const LOG_CHANNEL_NAME = "notion-updates";
  const NOTIFY_CHANNEL_NAME = "notion-updates";
  const MOST_RECENT_HOURS = 6;

  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]';
  }
  const API_BOT_TOKEN = PropertiesService.getScriptProperties().getProperty('slack_api_bot_token');
  if (!API_BOT_TOKEN) {
    throw 'You should set "slack_api_bot_token" property from [File] > [Project properties] > [Script properties]';
  }

  let token = API_TOKEN;
  let botToken = API_BOT_TOKEN;

  let slack = new SlackAccessor(token, botToken);

  let timestamp = parseInt(new Date() / 1000) - (60 * 60 * MOST_RECENT_HOURS);

  // メンバーリスト取得
  const memberList = slack.requestMemberList();
  // チャンネル情報取得
  const channelInfo = slack.requestChannelInfo();

  // チャンネルにメッセージ内容を取得
  for (let ch of channelInfo) {
    if (ch.name == CHANNEL_NAME) {
      console.log(ch.name)
      let messages = slack.requestMessages(ch, timestamp);
      PostLogSummary(slack, NOTIFY_CHANNEL_NAME, messages, memberList);
    }
  };
}

function SetProperties() {
  PropertiesService.getScriptProperties().setProperty('slack_api_token', 'XXXXX');
  PropertiesService.getScriptProperties().setProperty('slack_api_bot_token', 'XXXXX');
}

// 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;
    });
};

function RegExpMessage(text) {
  //var pattern = /(?:edited|deleted|created).*(https.+)\?.*/g;
  var pattern = /https[^?]+/g;
  var result = text.match(pattern);
  return result;
}

// Slack へのアクセサ
var SlackAccessor = (function () {
  function SlackAccessor(apiToken, apiBotToken) {
    this.APIToken = apiToken;
    this.APIBotToken = apiBotToken;
  }

  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;
  };

  // 特定チャンネルにメッセージ送信
  p.postMessage = function (channel, message) {
    const url = 'https://slack.com/api/chat.postMessage';
    var options = {
      "method": "post",
      "contentType": "application/x-www-form-urlencoded",
      "payload": {
        "token": this.APIBotToken,
        "channel": channel,
        "text": message
      }
    };

    UrlFetchApp.fetch(url, options);
  }

  return SlackAccessor;
})();

function PostLogSummary(slack, channel, messages, memberList) {
  console.log("PostLogSummary: " + messages.length);

  const COL_KEY = 0;
  const COL_TEXT = 1;

  var record = [];

  for (let msg of messages) {
    if (msg.subtype != "bot_message") {
      continue;
    }
    var unescaped = UnescapeMessageText(msg.text, memberList);
    var regexpMsgs = RegExpMessage(unescaped);
    if (regexpMsgs == null) {
      continue;
    }

    var regexpMsg = regexpMsgs[0];

    var row = [];
    row[COL_KEY] = regexpMsg;
    row[COL_TEXT] = unescaped;

    var i = record.findIndex(r => r[0] === regexpMsg);
    if (i < 0) {
      record.push(row);
    }
    else {
      record[i] = row;
    }
  };

  var msg = 'まとめ (' + record.length + '件)';
  if (record.length > 0) {
    record.forEach(function (v) {
      msg += '\n' + v[COL_TEXT];
    });
  }
  console.log(msg);
  slack.postMessage(channel, msg);
};

コピーしておいたTokenをSetPropertiesメソッドの中の2箇所に貼り付ける。

先頭の方にあるCHANNEL_NAMENOTIFY_CHANNEL_NAMEにNotionで接続した一時チャンネル名とまとめたUpdate情報を通知したいチャンネル名を設定する。

MOST_RECENT_HOURS に過去何時間分のログをまとめるか設定する。

GASの実行

実行する。 新しいログがなかったので過去24時間分のログをまとめた。

f:id:yotiky:20220304002010p:plain:w300 f:id:yotiky:20220304002032p:plain:w300

生のUpdateログは65件あったが、まとめた結果23件となった。

スケジューリング

GASのトリガーを作成し、まとめるログの時間を合わせることで定期的にサマリーを通知することができるようになる。

参考