読者です 読者をやめる 読者になる 読者になる

Slack上で日程調整するアプリをGASで書いてみる

GAS JavaScript Slack

最近 GAS(Google Apps Script) や SlackBot の存在を知って、慣れていけばいろいろ捗りそうな気がしたので触ってみた時のメモ。

今は特に GAS で自動化したいようなものは無かったので、SlackBot と連携して簡易的な日程調整、出席管理アプリを作って遊んでみました。
JavaScript 自体も全然触ったことがなかったので、書いてみて少しは勉強になったかなと思います。
尚、実用性は皆無だと思われ(ry。

簡単にアプリの紹介

Slack 上で日程調整ができるアプリです。
特定のチャンネルから特定のワードを付けてメッセージを投稿すると、Google Spread Sheet と連携して出席状況の管理などを行ってくれます。

イベントの登録

新しくイベント(予定)を追加するには以下の形式でメッセージを投稿します。

新: [イベント名] [日付(M/d)] ... [日付(M/d)]

こんな感じ。

f:id:norikone:20160219182520p:plain:h95

そうすると、GAS 側で指定したフォルダに、イベント名の Spread Sheet を作成してくれます。

f:id:norikone:20160219182749p:plain:h120

参加可能日の提出

参加可能日を提出する方法は以下の4種類です。

出: [イベント名]                              #全ての日OK
出: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけOK
欠: [イベント名]                              #全ての日NG
欠: [イベント名] [日付(M/d)] ... [日付(M/d)]   #指定した日だけNG

こんな感じ。

f:id:norikone:20160219182548p:plain:h95

Spread Sheet にフラグが入ります。

f:id:norikone:20160219182938p:plain:h120

参加状況の確認

参加状況の確認方法は以下の2種類です。

確認: [イベント名]   #人数だけ表示
詳細: [イベント名]   #ユーザ名も表示

こんな感じ。

f:id:norikone:20160219183951p:plain:w350

リマインド

以下の入力で、未提出のイベントがあるユーザに DM でリマインドができます。

リマインド:

こんな感じ。

f:id:norikone:20160219184942p:plain:h80

また、GAS のトリガーとしてリマインドする関数を指定すれば、一定時間ごとにリマインドしてくれるようになります。
GAS プロジェクトを開いて、

[リソース] -> [現在のプロジェクトのトリガー] -> [トリガー追加リンクをクリック]

表示されたダイアログの [実行] に [remind()] を指定して、[イベント] の項目に任意のリマインドタイミングを指定します。

備忘録とかコードとか

以下、今回行った流れ。

GAS プロジェクトを用意

Google Drive の適当な場所に新規 GAS プロジェクトを作成します。

[Google Drive] -> [新規] -> [その他] -> [Google Apps Script]

SlackApp ライブラリ導入

Slack Bot を GAS で簡単に書くためのライブラリを導入します。
下の記事で紹介されています。

qiita.com

Slack API Token の取得

APIへアクセスするためのトークンを取得します。 下のリンクを開いて表示されるページの下の方に、自分が所属しているSlackチームやユーザ名、トークンが表示されています。
まだトークンを生成していない場合は、Slack Bot を作成するチームのトークンを [Create token] で生成します。

Slack Web API | Slack

Slack API Token を GAS プロジェクトに保存

上で取得したトークンを、先程作成したGAS プロジェクトのプロパティに格納します。
GAS プロジェクトを開いて、

[ファイル] -> [プロジェクトのプロパティ] -> [スクリプトのプロパティ]

に、今回は "token" というプロパティ名で登録しました。

Slack Outgoing WebHooks 設定

Slack の特定のチャンネルに特定の投稿があった際に、Slack から GAS へリクエストを渡してあげる設定です。
Trigger Word と呼ばれる項目に設定した値で始まる文字列の投稿があった際に、投稿データが指定した URL に POST されるようになります。

App Directory | Slack

上記 URL の検索窓に「outgoing」とか入力すると [Outgoing WebHooks] のメニューが出てくると思います。
クリックするとページの下の方に、所属している Slack チームが表示されるので 、ここから Slack Bot を作成するチームの Outgoing WebHooks を設定していきます。

[Install] -> [Add Outgoing WebHooks Integration]

で設定画面にたどり着きます。

「Integration Settings」の以下の項目を設定します。

  • Channel
    ここで指定したチャンネルに Trigger Word で始まる投稿があると、指定した URL にデータを POST するようになります。
    適当なチャンネルを指定します。
  • Trigger Word
    ここで指定した文字列で始まる投稿が指定した URL に POST されるようになります。
    今回作成したアプリでは Trigger Word として以下の6つを設定します。
    新:,出:,欠:,確認:,詳細:,リマインド:
  • URL
    上で「指定したURL」と書いていた部分がここです。
    今回は、後述する GAS アプリの URL をここに入力します。
  • Token
    Slack から GAS にデータが POST される際に、ここに記載されているトークンが付加されます。
    不正なアクセスでないかどうかを確かめるために GAS 側でこのトークンをチェックします。
    今回は、ここに記載されている値を "verifyToken" というプロパティ名で、先程 "token" を設定した時と同じように、スクリプトのプロパティに保存しました。

設定が完了したら [Save Setting] で保存します。

コードを書く

あとは実際に GAS でコードを書いていきます。
GAS では、POST リクエストがあった場合 doPost() が呼ばれるようになっています。
今回は、Outgoing Web Hooks によって Slack への投稿データが doPost() に入ってきます。
doPost(request) のように、第一引数を指定してあげれば投稿データを受け取る事ができます。

以下、コードをまとめてそのままここに掲載しておきます。
色々試しながらやっていたので冗長コードが入ってたりします(放置)。
1行目の FOLDER_ID には、このアプリで使用するシートを保存しておく Google Drive フォルダの ID を指定します。
フォルダ ID は、ブラウザで該当フォルダを開いた状態の時にアドレスバーで確認することも可能です。

var FOLDER_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXXX";
var DATE_REGEXP = "^[0-1]?[0-9]\.[0-3]?[0-9]$";
var DATE_FORMAT = "M/d";
var MENTION = "@";
var UNIT = "名";
var START_ROW = 1;
var START_COL = 1;
var ATTEND_FLAG = 1;
var ABSENT_FLAG = 0;
var COL_IS_DATE = true;

var prop =  PropertiesService.getScriptProperties().getProperties();
var slackApp = SlackApp.create(prop.token);

function doPost(request) {

  if (prop.verifyToken != request.parameter.token) {
    throw new Error("invalid token.");
  }
  
  var queryTokens = request.parameter.text.split(":")[1].trim().replace(/ /g, " ").split(" ");
  queryTokens = removeOnlySpaceToken(queryTokens);
  try{
    switch(request.parameter.trigger_word){
      case "新:":
        tokenValidation(queryTokens,DATE_REGEXP,2)
        register(queryTokens);
        response(request.parameter.channel_id,"新しいイベントを追加しました",":new:");
        break;
      case "出:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,true,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "欠:":
        tokenValidation(queryTokens,DATE_REGEXP,1)
        submit(queryTokens,false,request.parameter.user_name);
        response(request.parameter.channel_id,"出席登録が完了しました",":ok:");
        break;
      case "確認:":
        show(queryTokens,false,request);
        break;
      case "詳細:":
        show(queryTokens,true,request);
        break;
      case "リマインド:":
        remind();
        response(request.parameter.channel_id,"リマインドが完了しました",":information_source:");
        break;
    }
  }catch(e){
    response(request.parameter.channel_id,e,":ng:");
  }
  
  return null;
}

function register(queryTokens){  
  var folder = DriveApp.getFolderById(FOLDER_ID); 
  var fileId = createSheet(folder,queryTokens[0]);
  var spreadSheet = SpreadsheetApp.openById(fileId);
  var sheet = spreadSheet.getSheets()[0];
  var userList = slackApp.usersList()["members"];
  
  userList.some(function(v, i){
    if (v.name=="slackbot") userList.splice(i,1);    
  });
  
  queryTokens.shift(); 
  var options = {
    rowHeader: userList,
    rowMember: "name",
    colHeader: queryTokens,
  };
  setHeaders(sheet,options);
  
  return null;
}

function submit(queryTokens,isAttend,userName){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  var position = getPosition(sheet,userName);
  if(!position){
    throw new Error("ユーザが存在しません、名前が変更されている可能性があります");
  }
  
  queryTokens.shift(); 
  setValues(sheet,queryTokens,position,isAttend);
  
  return null;
}

function show(queryTokens,detail,request){
  var sheet = getAttendanceSheet(queryTokens[0]);
  var data = sheet.getDataRange().getValues();
  
  var sb = new StringBuffer("===============================");
  sb.append("\n【イベント名】 ").append(queryTokens[0]);
  var state = createSubmitStateData(sheet);
  sb.append("\n【提出者】   ").append(state.submitted.length).append(UNIT);
  sb.append("\n【未提出者】  ").append(state.unsubmitted.length).append(UNIT).append(" ");
  if(detail){
    for(var i=0 ; i<state.unsubmitted.length ; i++){
      sb.append(" ").append(MENTION).append(state.unsubmitted[i]);
    }
  }
  sb.append("\n===============================");
  
  for(date in state.dateState){
    sb.append("\n【").append(date).append("】").append(" ").append(state.dateState[date].length).append(UNIT).append(" ");
    if(detail){
      for(var j=0; j<state.dateState[date].length ; j++){
        sb.append(" ").append(MENTION).append(state.dateState[date][j]);
      }
    }
    sb.append("\n- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -");
  }
  
  response(request.parameter.channel_id,sb.toString(),":information_source:");
  
  return null;
}

function createSubmitStateData(sheet){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  var submittedUsers = [];
  var unsubmittedUsers = [];
  var dateState = {};
  
  if(COL_IS_DATE){
    for(var i=0 ; i<rows ; i++){
      var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
      var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
      
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
      
      for(var j=0 ; j<cols ; j++){
        var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+j+1).getValue(),"JST",DATE_FORMAT);
        if(!dateState[date]){
          dateState[date] = [];
        }
        var attendFlag = sheet.getRange(START_ROW+i+1,START_COL+j+1).getValue();
        if(attendFlag){
          dateState[date].push(user);
        }
      }
    }
  }else{
    for(var i=0 ; i<cols ; i++){
      var filled = sheet.getRange(START_ROW+rows+1,START_COL+1+i).getValue() == rows;
      if(filled){
        submittedUsers.push(user);
      }else{
        unsubmittedUsers.push(user);
      }
    }
  }
  
  return {submitted:submittedUsers, unsubmitted:unsubmittedUsers, dateState:dateState};
}

function getPosition(sheet,target){
  var data = sheet.getDataRange().getValues();
  for(var i=0 ; i<data.length ; i++){
    for(var j=0 ; j<data[i].length ; j++){
      if(data[i][j] == target){
        return {row:i+1 ,col:j+1};
      }
    }
  }
  
  return null;
}

function setHeaders(sheet,options){
  var rowHeader = options.rowHeader || [1];
  var rowMember = options.rowMember || null;
  var colHeader = options.colHeader || [1];
  var colMember = options.colMember || null;
  var bgcolor = options.bgcolor || "#f5deb3";
  
  sheet.getRange(START_ROW,START_COL)
  .setValue(rowHeader.length + "," + colHeader.length);
  
  for(var i=0 ; i<rowHeader.length; i++){
    sheet.getRange(START_ROW+i+1,START_COL)
    .setValue(rowMember ? rowHeader[i][rowMember] : rowHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+i+1,START_COL+colHeader.length+1)
    .setFormulaR1C1("=COUNT(R[0]C[-"+colHeader.length+"]:R[0]C[-1])");
  }

  for(var i=0 ; i<colHeader.length; i++){
    sheet.getRange(START_ROW,START_COL+i+1)
    .setValue(colMember ? colHeader[i][colMember] : colHeader[i])
    .setBackgroundColor(bgcolor);
    
    sheet.getRange(START_ROW+rowHeader.length+1,START_COL+i+1)
    .setFormulaR1C1("=SUM(R[-"+rowHeader.length+"]C[0]:R[-1]C[0])");
  }
  
  return null;
}

function setValues(sheet,tokens,position,isAttend){
  var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
  var rows = Number(edge[0]);
  var cols = Number(edge[1]);
  
  if(COL_IS_DATE){
    for(var i=0 ; i<cols ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW,START_COL+1+i).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row,position.col+1+i).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }else{
    for(var i=0 ; i<rows ; i++){
      var date = Utilities.formatDate(sheet.getRange(START_ROW+1+i,START_COL).getValue(), "JST", DATE_FORMAT);    
      if(tokens.indexOf(date) >= 0 || tokens.length == 0){
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ATTEND_FLAG : ABSENT_FLAG);
      }else{
        sheet.getRange(position.row+1+i,position.col).setValue(isAttend ? ABSENT_FLAG : ATTEND_FLAG);
      }
    }
  }
  
  return null;
}

function getAttendanceSheet(sheetName){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFilesByName(sheetName);
  if(!files.hasNext()){
    throw new Error("入力されたイベントは存在しません");
  }
  var spreadSheet = SpreadsheetApp.open(files.next());
  var sheet = spreadSheet.getSheets()[0];
  
  return sheet;
}

function createSheet(targetFolder,sheetName){
  var files = targetFolder.getFiles();
  while (files.hasNext()) {
    var file = files.next();
    if(file.getName() == sheetName){
      throw new Error("そのイベント名は既に存在しています");
    }
  }
  
  var fileId = SpreadsheetApp.create(sheetName).getId();
  var file = DriveApp.getFileById(fileId);
  targetFolder.addFile(file);
  DriveApp.getRootFolder().removeFile(file);
  
  return fileId;
}

function remind(){
  var folder = DriveApp.getFolderById(FOLDER_ID);
  var files = folder.getFiles();
  
  while (files.hasNext()) {
    var file = files.next();
    var spreadSheet = SpreadsheetApp.open(file);
    var sheet = spreadSheet.getSheets()[0];
    
    var edge = sheet.getRange(START_ROW,START_COL).getValue().split(",");
    var rows = Number(edge[0]);
    var cols = Number(edge[1]);
    
    if(COL_IS_DATE){
      for(var i=0 ; i<rows ; i++){
        var filled = sheet.getRange(START_ROW+i+1,START_COL+cols+1).getValue() == cols;
        var user = sheet.getRange(START_ROW+i+1,START_COL).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }else{
      for(var i=0 ; i<cols ; i++){
        var filled = sheet.getRange(START_ROW+rows+1,START_COL+i+1).getValue() == rows;
        var user = sheet.getRange(START_ROW,START_COL+i+1).getValue();
        if(!filled){
          var sb = new StringBuffer("以下のイベントへの出席登録が完了していません!");
          sb.append("\n【イベント名】  ").append(file.getName());
          response(MENTION+user,sb.toString(),":warning:");
        }
      }
    }
  }
  
  return null;
}

function response(target,text,icon){
  slackApp.chatPostMessage(target, text, {
    username : "出席管理人",
    icon_emoji : icon 
  });
  
  return null;
}

function removeOnlySpaceToken(tokens){
  var result = [];
  for(var i=0 ; i<tokens.length ; i++){
    if(tokens[i].match(/\S/g)){
      result.push(tokens[i]);
    }
  }
  
  return result;
}

function tokenValidation(tokens,regexp,minLength){
  if(tokens.length < minLength){
    throw new Error("フォーマットが正しくありません");
  }
  for(var i=1 ; i<tokens.length ; i++){
    if(!tokens[i].match(regexp)){
      throw new Error("フォーマットが正しくありません");
    }
  }
  
  return false;
}

var StringBuffer = function(string) {
    this.buffer = [];

    this.append = function(string) {
        this.buffer.push(string);
        return this;
    };

    this.toString = function() {
        return this.buffer.join('');
    };

    if (string) {
        this.append(string);
    }
};

アプリを公開

GAS プロジェクトを開き、

[公開] -> [ウェブアプリケーションとして導入] -> [アプリケーションにアクセスできるユーザー] -> [全員(匿名ユーザーを含む)]
に設定して、[導入] をクリックします。

すると、ダイアログが表示されるので、[現在のウェブアプリケーションのURL] という欄に記載されている URL を、Outgoing WebHooks 設定ページの URL 欄にコピペします。

これで動くようになりました。

おわり

ということで、GAS を使って簡単なアプリを作成してみました。
仕様もコードも荒削りではありますが、飽きたので 一応動くものが出来たので、この辺で手を止めておきます。