Slack + GAS + Spreadsheetで作る備品管理システム

背景

GASを始めて触って以降、「なんだか色々できるんだなあ」とぼんやり思っていたんですけど調べてみるとホントに色々できることに気づきました。お金のない学生にはうれしい。

Slack + GAS + スプレッドシートを使った某システムの制作依頼を受けたのでGASの勉強がてら作ってみたところ、備品購入&管理システムに応用できることに気づいたので作ってみました。

参考にしたページたち↓。圧倒的感謝です…!!!

作りたいもの

Slackの特定のチャンネルで、備品の購入履歴と保管場所および領収書(無くても可)を投稿すると、その内容を自動でスプレッドシートに保存し、画像はGoogle Driveのフォルダに保存するシステム。

どうカテゴライズするか迷ったのですが、たぶん備品管理システムだと思われます。あとついでに、初めて投稿する人用にBotが投稿用テンプレートを投稿しておいてくれるようにしてあります。

図示すると↓の感じです。

Demo

システム要件的なもの

システム的には↓の感じ。

systemFlow

  • 1日に1度、Slackの「備品購入チャンネル」を確認
  • 新規投稿がある場合にその内容をスプレッドシートに保存(テンプレートに沿わない投稿は無視する)
  • 画像があればそれ(ら)を画像フォルダに保存
  • ワークスペースの容量を圧迫しないよう、上記と同じタイミングでその日に投稿された投稿を全て削除
  • 初めて購入履歴を投稿をする人用に、1日に1度、BOTがテンプレート文章をチャンネルに投稿

と、こういった感じの機能を組み込んでいきます。

作り方手順

  1. 適当なスプレッドシートを生成する
  2. スプレッドシートのスクリプトエディタから、新しいスクリプトを生成する(購入情報の保管用)
  3. 後ほど紹介するコードを書き込む
  4. Slack APIの新しいアプリケーションを作成してワークスペースに追加する
  5. SlackのIncoming Webhookを設定する
  6. コードを実行するトリガを設定する(自分の場合は1日1度、午前3~4時)
  7. Google Drive上に備品申請のテンプレート送信用スクリプト新規作成し、最後に紹介するコードをコピペ、トリガーの設定をする

Slackの投稿履歴を1日分遡って投稿内容と画像を保存するGASコード

いきなりだけど、GASコードの完成品。

// グローバル変数化
const botToken = PropertiesService.getScriptProperties().getProperty('Bottokenのプロパティ');
const userToken = PropertiesService.getScriptProperties().getProperty('userTokenのプロパティ');
const Schannel = PropertiesService.getScriptProperties().getProperty('slackのチャンネルIDのプロパティ');

// 取得したSlackのメッセージをスプレッドシートに保存(これが本体)
function setSlackLog() {
  try{
    // Slackのメッセージを取得して逆順に
    var response = JSON.parse(getSlackLog());
    var messages = response.messages.reverse();
    var fileInfo = JSON.parse(getFileInfo());
    // スプレッドシートの情報取得
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sheet = ss.getSheets()[0];
    var lastrow = sheet.getLastRow();
    // Google driveのフォルダ(領収書フォルダ)IDを設定する
    var driveUrl = PropertiesService.getScriptProperties().getProperty('DriveURL');
    var recpfolder = DriveApp.getFolderById(driveUrl);

    var cont = 0;
    var timestamp = [];

    for ( var i = 0; i < messages.length; i++ ){
    // コメント削除のためのタイムスタンプを記録
    timestamp[i] = messages[i].ts;
      var data = messages[i].text.split(",");
      if (data[0] == '【備品購入】'){
        if (messages[i].files){
          // fileがアップロードされてたらfileのダウンロードurlを記録
          Logger.log("\nupload\n");
          var fileUrl = fileInfo.files[cont].url_private_download;
          var cont = cont + 1;
          Logger.log("\nファイルurl\n" +fileUrl);
          DownloadData(fileUrl, recpfolder);
        }
        // ユーザーIDからリアルの名前とディスプレイ名を取ってくる
        var userID = messages[i].user;
        var userNameDatas = JSON.parse(getSlackUserName(userID));
        var realName = userNameDatas.user.real_name;
        var displayedName = userNameDatas.user.profile.display_name;
        var names = realName + " / " + displayedName;
        // 各情報の書き込み
        sheet.getRange(lastrow + i + 1, 1).setValue(names)
        for ( var j = 0; j < data.length - 1; j++ ){
          sheet.getRange(lastrow + i + 1, j + 2).setValue(data[j+1]);
        }
      }
      else
        lastrow -= 1;
    }
    // タイムラインを渡して削除する
    for ( j = 0; j < messages.length; j++){
      deleteComment(timestamp[j]);
    }
  }catch(e){
    Logger.log(e);
  }
}

// Slackからメッセージ取得
function getSlackLog() {
  //  conversation.history(slack apiのURL)
  var requestUrl = 'https://slack.com/api/conversations.history?';
  var payload = {
    // Slack Token
    'token': botToken,
    // Channel ID
    'channel': Schannel,
    // 25時間分のメッセージ取得
    'oldest': parseInt( new Date() / 1000 ) - (60 * 60 * 25)
  }
  var param = [];
  for (var key in payload) {
    param.push(key + '=' + payload[key]);
  }
  requestUrl += param.join('&');
  // URLを送ってslackの履歴をもらう
  return UrlFetchApp.fetch(requestUrl);
}

// ユーザーネームを取得する
function getSlackUserName(user_id) {
  // ユーザー名を取るためのAPI_URL
  var requestUrl = 'https://slack.com/api/users.info?';
  var payload = {
    'token': botToken,
    'user': user_id
  }
  var param = [];
  for (var key in payload) {
    param.push(key + '=' + payload[key]);
  }
  requestUrl += param.join('&');
  return UrlFetchApp.fetch(requestUrl);
}

// 過去24時間のコメント(画像含む)全削除
function deleteComment(TS) {
  var requestUrl = 'https://slack.com/api/chat.delete?';
  var payload = {
    'token': userToken,
    'channel': Schannel,
    // timestamp
    'ts': TS
  }
  param = [];
  for (var key in payload) {
    param.push(key + '=' + payload[key]);
  }
  requestUrl += param.join('&');
  return UrlFetchApp.fetch(requestUrl);
}

// Google Driveに保存する
function DownloadData(url, folder){
  var options = {
    "headers": {'Authorization': 'Bearer '+ botToken}
  };
  var response = UrlFetchApp.fetch(url, options);
  var fileName = 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());
  }
  folder.createFile(fileBlob);
}

// FILEリストを取得
function getFileInfo() {
  var requestUrl = 'https://slack.com/api/files.list?';
  var payload = {
    'token': userToken,
    'channel': Schannel,
    "headers": {'Authorization': 'Bearer '+ userToken}
  }
  param = [];
  for (var key in payload) {
    param.push(key + '=' + payload[key]);
  }
  requestUrl += param.join('&');
  return UrlFetchApp.fetch(requestUrl);
}

多分上のコードだけを読むと「なんのこっちゃ?」ってなると思います。本来は読んでわかるコードを書くべきですね。すまん。

以下、説明です。

環境(グローバル)変数

スクリプトの1~3行目に不思議な変数が定義されています。これらは各種メソッドを実行する際に必要なトークンやAPI Keyです。外部に流れると悪用されかねないデータを含んだ変数です

これらはスクリプトエディタのファイル -> プロジェクトのプロパティ -> スクリプトのプロパティから設定でき、スクリプト内で自由に参照できます。

自由に参照できなくすることも可能です。大切な情報を環境変数化することで、外部への情報の流出を大いに防ぐことが出来ます。

  • botTokenとuserToken Slack APIを実行する際に必要なtoken
  • Schannel このアプリケーションを使うSlackチャンネルのID

setSlackLog関数

全体像は下図を参照してください。大まかには、Slackの投稿を読み込んで、条件分岐で処理を変更するというものです。Slackはフリープランだとワークスペースの容量に5GB上限があるので、記録した情報は最後に全て削除することにしました。

how2fork

  • メッセージ情報を逆順にする理由(プログラム4、5、6行目辺り)

後述のgetSlackLog関数にてConversations メソッドを用いるのですが渡ってくるJSONデータは反時系列順になっており、一つ目の配列のデータが最新の投稿に関するものになっています。

したがって、(他にも方法はありますが)購入した物品順(時系列順)に記録する場合にはこの半時系列順への並べ替えが必要になります。

  • lastrow -= 1する理由(プログラム30、54行目)

変数lastrowはスプレッドシートのどの行に情報を書き込むか決める必要な変数です。 今回書いたコードではBOTの投稿など、購入履歴に関係ない投稿も読み込んでいるので、そういった投稿を除外して書き込む行を調整するために用いています。

getSlackLog関数

Slack APIの中の一つ、Conversation.historyメソッドを使いました。公式のドキュメントは以下の通りです。

Slack APIのトークンなどが必要ですが、Slackの投稿についてのほぼ全ての情報を得ることが出来ます。

getSlackUserName関数

前述のConversationsメソッドだけでは申請者の本名を含むユーザー名を得られません。そのため、Usersメソッドを用いてuser_idを取ってくる必要があります。

getFileInfo関数

ここが一番のハマリポイントでした。Conversationsメソッドの説明を見る限り、

messages.files[何個目の配列か].url_private_download

で、Slackに投稿されたファイルのダウンロードURLを取れる、と思ったのですが上手くいきませんでした。

深夜作業してたのでどっか間違えたのか、本当にURLを持ってこれないのか分かりませんでした。この変更に当初3時間くらいかかってしまった

  • 【追記】(2020/9/8)

上記の問題ですが、変数messagesに変数responseの時系列を逆にしたデータを格納してしまったが為に、messages.filesに入っているデータを個別に引っ張り出すことが出来なくなってしまうことが原因だと分かりました。

領収書などの画像(添付ファイル)の情報を時系列順に抜き出すには、今回定義したgetFileInfo関数(Files.listメソッド)を使用するなどしてエラーを回避することが必要です。追記は以上です。

Slackのワークスペースに1日1度Botからテンプレートを送信するGASコード

// slackに送信
function sendSlack(){
  var postUrl = "Slack Incoming webhookで設定されるURL";
  var username = '通知時のユーザー名';
  var icon = ':piggy:';//通知時に表示されるアイコン
  var mes = "送信したいメッセージ。*太字にもできます*";
  var jsondata =
      {
        "username" : username,
        "icon_emoji" : icon,
          "type": "mrkdwn",
          "text": mes
      }
  var payload = JSON.stringify(jsondata);
  var options =
      {
        "method" : "post",
        "contentType" : "application/json",
        "payload" : payload
      }
  UrlFetchApp.fetch(postUrl,options);
}

あとはGASのコード実行トリガーを1日1度、適当な時間を決めて設定すればシステムの完成です。

まとめ+反省点

お金が無くてもここまで出来るんだなあ…とGASの可能性に感動しました。

頑張れば備品情報のアップデート機能も付けれるけど今回は保留。また元気が溜まったらやると思います。

グローバル変数を使うとか,環境変数を設定すればもっと簡素で安心ななコードになることにブログ書いてて気づいた。改良の余地しかない。

【追記:2020-8-30】改善しました。

One Comment. ...APIの書き方がまだ良く分からん。教えてえろい人!