slackからの自動打刻システムをサーバレスで作る

slackからの自動打刻システムをサーバレスで作る

slackのメッセージを送ると勤怠管理システムに打刻ができる仕組みを作りました。
構成図は以下の通りです。

「開始」や「終了」のメッセージを送ると、その時刻で打刻が完了する仕組みです。
slackとAWSで完結するのものでも良いかとも思いましたが、スプレッドシートへの記録を挟むことでメンバーみんなが確認しやすかったり、無料で利用できるというのもあったためGAS・スプレッドシートも利用しました。

【作成の手順】
1 slackに送った勤怠情報をスプレッドシートに記録させる
2 lambdaから勤怠管理システムに打刻する
3 GASからLambdaを起動する

slackに送った勤怠情報をスプレッドシートに記録させる

slackのout going webhookと、GASのWebアプリケーションとして導入を利用して連携させる。
まずはスプレッドシートとGASの設定をする。
Google ドライブからスプレッドシートを開き、ツール > スクリプトエディタからGASプロジェクトを作成し、以下のようにコードを保存する。

function doPost(e) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('シート1');
  sheet.getRange(1,1).setValue(e);
}

「Webアプリケーションとして導入」を選択して、URLを叩いたとき、POSTリクエストの場合はdoPost、GETリクエストの場合はdo
Getという関数が呼ばれます。

slackからGASの関数を呼び出せられるように、公開 > ウェブアプリケーションとして導入を選択する。

アクセスできるユーザーで「全員(匿名ユーザーを含む)」を選択し、「導入」をクリックする。

表示された「現在のウェブ アプリケーションの URL:」をどこかに控えておいてください。

次にslackのoutgoing Webhookの設定をします。
https://slack.com/apps/A0F7VRG6Q-outgoing-webhook
上記URLから「設定を追加」します。
インテグレーションの設定から、お好きな「チャンネル」を選択し、「引き金となる言葉」に「開始, 終了」と入力、「URL」には先程GASで生成したウェブ アプリケーションの URLを入力してください。
入力が完了したら保存をします。
「トークン」の項目に入っている文字列は、後で使うのでどこかに控えておきます。
ここまで設定をして、slackの該当チャンネルで「開始」と送ると・・・

A1セルに値が入っているのがわかります!これで連携成功です!

A1セルの中には、slackから送られた内容を含むevent Objectが記録されているので、その情報をもとに必要な内容を抜き出します。

年、月、日、時間・・・などちょっとしつこい感じで記録しようと思います・・・・

/**
 * POSTリクエストの場合の処理
 **/
function doPost(e) {
  var json_params = JSON.stringify(e);
  //jsonを配列に変換
  var arr_params = JSON.parse(json_params);
  var token = arr_params['parameter']['token'];
  var slack_token = 'XXXXXXXXXXXXXXXXXXXXXXXX';//outgoing webhookで取得したトークンを入力する
  
  //Slackからのリクエストの場合の処理
  if (token == slack_token) {
    var stamping_json_data = stamping_from_slack(arr_params);
    return;
  }

}

/**
 * Slackからの打刻をスプレッドシートに記録する
 **/
function stamping_from_slack(arr_params){
  var sheet        = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet');//スプレッドシートのシート名を「sheet」に変更する
  var timestamp    = arr_params['parameter']['timestamp'];
  var datetime     = new Date();
  var year         = datetime.getFullYear();
  var month        = ('0' + (datetime.getMonth() + 1)).slice(-2);
  var day          = ('0' + datetime.getDate()).slice(-2);
  var hour         = ('0' + datetime.getHours()).slice(-2);
  var minute       = ('0' + datetime.getMinutes()).slice(-2);
  var command      = arr_params['parameter']['trigger_word'];
  var user_name    = arr_params['parameter']['user_name'];
  var slackuser_id = arr_params['parameter']['user_id'];
  
  //user_idからメールアドレスを取得する
  var email = getUserEmail(slackuser_id);
  
  //従業員IDを取得する
  var employee_id = getEmployeeId(email);
  
  //スプレッドシート入力
  var last_row_num    = sheet.getLastRow();
  var last_column_num = sheet.getLastColumn();
  var updated_row     = sheet.getRange(last_row_num + 1, 1, 1, last_column_num);
  var insert_data     = updated_row.getValues();
  
  insert_data[0][0]  = datetime;
  insert_data[0][1]  = year;
  insert_data[0][2]  = month;
  insert_data[0][3]  = day;
  insert_data[0][4]  = hour;
  insert_data[0][5]  = minute;
  insert_data[0][6]  = timestamp;
  insert_data[0][7]  = user_name;
  insert_data[0][8]  = slackuser_id;
  insert_data[0][9]  = email;
  insert_data[0][10] = command;
  insert_data[0][11] = employee_id;
  
  updated_row.setValues(insert_data);
  
  //連想配列を生成する
  var arr_data = {};
  var keys = sheet.getRange(1, 1, 1, last_column_num).getValues()[0];
  keys.forEach(function(key, index){
              arr_data[key] = insert_data[0][index];
              });
  var json_data = JSON.stringify(arr_data);
  return json_data;
}


/**
 * SlackIDからメールアドレスを取得する
 **/
function getUserEmail(slackuser_id) {
  var ids = {
    XXXXXXXXX:'XXXX@anti-pattern.co.jp',
 };
  return ids[slackuser_id];
}


/**
 * メールアドレスから勤怠管理システムのアカウントIDを取得する
 **/
function getEmployeeId(email) {
  var employee_ids = {
    'XXXX@anti-pattern.co.jp':'00000',
  };
  return employee_ids[email];
}

slack_token →outgoing webhookで取得したトークンを入力します。
WebhookURLは、知っていれば誰でもリクエストを送ることができてしまうので、このトークンの文字列を判定することでslackからのリクエスト以外は処理を行わないようにできます。

ここで改めてslackのチャンネルで「開始」と送るとこのようにうまく記録できました!

lambdaから勤怠管理システムに打刻する

次にlambdaの設定
puppeteerというChromeのヘッドレスブラウザを使い、スクレイピングをする
lambdaにはLayersと関数というものがある。
関数には実行したい処理を書きます。

Layerには関数で使うライブラリやモジュールを登録できます。 一つの関数に5つLayerを登録でき、一つのLayerを他の関数に登録することもできます。

今回、関数にはスクレイピングの処理を書くindex.jsと、パスワード情報などを保管するconfig.jsonの2つを設定。
また、関数内で利用するchrome-aws-lambda、puppeteer-core、requestの3つのモジュールが入ったLayerを一つ紐付ける。

lambdaでは関数とすべてのレイヤーの解凍後の合計サイズが、解凍後のデプロイパッケージのサイズ制限 250 MB を超えることはできません。nodejsで利用するpuppeteerは普段ブラウザにChromiumを使っていますが、Chromiumを内包したpuppeteerは容量が大きすぎるため、Chromiumを含まないpuppeteer-coreを利用します。

lambdaでpuppeteerを利用する方法は以下の記事がとてもわかりやすかったのでおすすめです↓
AWS LambdaでPuppeteerを動かす

モジュールが入ったzipファイルをローカルで作成する際は、nodejsという名前のディレクトリにしなければうまく動きません。
また、Layer登録時と、関数で利用するランタイムにNode10を利用するとうまく動かない問題があるようですので、Node8を利用してください。

関数にはこのようなコードを登録します。

const puppeteer = require('puppeteer-core');
const chromium  = require('chrome-aws-lambda');
const request   = require('request');
const {SITE_ID, SITE_PW, GAS_URL, SLACK_URL, SHEET_URL, BASE_URL} = require('./config.json');
exports.handler = async (event, context) => {
  
  let browser       = null;
  let page          = null;
  const year        = event.year;
  const month       = event.month;
  const day         = event.day;
  const date        = year + '-' + month + '-' + day;
  const hour        = event.hour;
  const minute      = event.minute;
  const time        = hour + ':' + minute;
  const command     = event.command == '開始' ? 'clock_in_at': 'clock_out_at';
  const employee_id = event.employee_id;
  const next_month  = month == '12' ? (Number(year) + 1)  + '/1' : year + '/' + (Number(month) + 1);
try {
    
    browser = await puppeteer.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath,
      headless: chromium.headless,
    });
    
    page = await browser.newPage();
    
    await page.goto(BASE_URL + next_month +'/employees/' + employee_id);
    
    await page.focus('input[name=email]');
    await page.type('input[name=email]',SITE_ID);
    await page.focus('input[name=password]');
    await page.type('input[name=password]',SITE_PW);
const input_element = await page.$('input[type=submit]');
    await input_element.click();
    await page.waitFor(5000);
let calendar = await page.$('[data-date="' + date + '"]');
    await calendar.click();
    await page.waitFor(2000);
await page.focus('input[name=' + command + ']');
    await page.keyboard.press('Backspace');
    await page.keyboard.press('Backspace');
    await page.keyboard.press('Backspace');
    await page.keyboard.press('Backspace');
    await page.keyboard.press('Backspace');
await page.type('input[name=' + command + ']', time);
    
    const remove_break_buttons = await page.$$('.break-records-editor__remove-break-button');
    for (let i = 0; i < remove_break_buttons.length; i++) {
      await remove_break_buttons[0].click();
    }
if (command == 'clock_out_at') {
      //勤務時間を取得する
      const work_time_tag = await page.$('span.time-range-input__diff');
      let work_time       = await (await work_time_tag.getProperty('textContent')).jsonValue();
      work_time = work_time.match(/^\d+/g);
      
      //勤務時間が6時間以上の場合は休憩時間を入力する
      if (work_time >= 6) {
        const add_break_button = await page.$('div.break-records-editor > .sw-button');
        await add_break_button.click();
        
        await page.focus('input[name=break_record_0_clock_in_at]');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.type('input[name=break_record_0_clock_in_at]', (Number(hour) - 4) + ':' + minute);
        
        await page.focus('input[name=break_record_0_clock_out_at]');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.keyboard.press('Backspace');
        await page.type('input[name=break_record_0_clock_out_at]', (Number(hour) - 3) + ':' + minute);
      }
    }
    
    const save_button = await page.$('.work-record-edit-modal__footer-control.sw-button-primary');
    //const save_button = await page.$('.work-record-edit-modal__footer-control.sw-button');//デバッグ用の閉じるボタン
    console.log('セーブ前');
    await save_button.click();
    
  } catch (e) {
    
    //スプレッドシートに記録
    var options = {
      url: GAS_URL,
      method: 'POST',
      headers: 'Content-Type:application/json',
      json: {
        message: '',
        event: ''
      }
    }
    
    //eventを文字列にしてtextにつなげる
    var event_json = JSON.stringify(event);
options['json']['message'] = String(e).replace(/:|\\\'/g, '、') + ' email→' + event.email + ' timestamp→' + event.timestamp;
    options['json']['event'] = event_json;
    
    request(options, function (error, response, body) {});
//slackに通知
    var slack_options = {
      url: SLACK_URL,
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      json: {
        username: 'freee打刻',
        icon_emoji: ':alarm_clock:'
      }
    }
    slack_options['json']['text'] = '打刻が失敗しました![email→' + event.email + '] エラーログを確認してください。' + SHEET_URL;
    request(slack_options, function (error, response, body) {});
    await page.waitFor(5000);
return context.fail(e);
    
  } finally {
    if (browser !== null) {
      await browser.close();
    }
  }
return event.user_name;
};

※github:https://github.com/amiamiyamamoto/slack_stamp

{
  "SITE_ID": "勤怠管理システムにログインするID",
  "SITE_PW": "勤怠管理システムにログインするパスワード",
  "GAS_URL": "https://script.google.com/※GASのWebhookURL",
  "SLACK_URL": "https://hooks.slack.com/services/※slackのincomingWebhookのURL",
  "SHEET_URL": "https://docs.google.com/spreadsheets/スプレッドシートのURL",
  "BASE_URL" : "https://勤怠管理システムURL"
}

ご利用の勤怠管理システムに合わせて作成してください!

関数の登録ができたら、GASから呼び出しができるよう、API Gatewayの登録を行います。
↓こちらのページがとても分かりやすかったので、おすすめです!
ゼロから作りながら覚えるAPI Gateway環境構築

lambdaやAPIゲートウェイは初期設定がリソース少なめに設定されてます。
lambdaだったら、起動時間上限が少なかったり、API Gatewayは呼び出し上限回数が少なかったりなどなどです。
うまく動かない場合でも、設定が制限されていないか確認してみてくださいね。

GASからLambdaを起動する

最後にGASコードからLambdaに勤怠情報を送る処理と、エラーを受信する処理を記述して終わりです。

/**
 * POSTリクエストの場合の処理
 **/
function doPost(e) {
  var json_params = JSON.stringify(e);
    //jsonを配列に変換
  var arr_params = JSON.parse(json_params);
  var token = arr_params['parameter']['token'];
  var slack_token = 'XXXXXXXXXXXXXXXXXXXXXXXX';//outgoing webhookで取得したトークンを入力する
  
  //Slackからのリクエストの場合の処理
  if (token == slack_token) {
    var stamping_json_data = stamping_from_slack(arr_params);    
    var response = sendLambda(stamping_json_data);
    return;
  }
  
  //lambdaからのリクエストの場合
  if ('lambdaからのリクエストの場合の判定を書く') {
    var error_log_sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('error_log');
    var last_low = error_log_sheet.getLastRow();
    error_log_sheet.getRange(last_low + 1, 1).setValue('打刻が失敗しました:' + json_params);
    return;
  }


}

/**
 * Slackからの打刻をスプレッドシートに記録する
 **/
function stamping_from_slack(arr_params){
  var sheet        = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('sheet');//スプレッドシートのシート名を「sheet」に変更する
  var timestamp    = arr_params['parameter']['timestamp'];
  var datetime     = new Date();
  var year         = datetime.getFullYear();
  var month        = ('0' + (datetime.getMonth() + 1)).slice(-2);
  var day          = ('0' + datetime.getDate()).slice(-2);
  var hour         = ('0' + datetime.getHours()).slice(-2);
  var minute       = ('0' + datetime.getMinutes()).slice(-2);
  var command      = arr_params['parameter']['trigger_word'];
  var user_name    = arr_params['parameter']['user_name'];
  var slackuser_id = arr_params['parameter']['user_id'];
  
  //user_idからメールアドレスを取得する
  var email = getUserEmail(slackuser_id);
  
  //従業員IDを取得する
  var employee_id = getEmployeeId(email);
  
  //スプレッドシート入力
  var last_row_num    = sheet.getLastRow();
  var last_column_num = sheet.getLastColumn();
  var updated_row     = sheet.getRange(last_row_num + 1, 1, 1, last_column_num);
  var insert_data     = updated_row.getValues();
  
  insert_data[0][0]  = datetime;
  insert_data[0][1]  = year;
  insert_data[0][2]  = month;
  insert_data[0][3]  = day;
  insert_data[0][4]  = hour;
  insert_data[0][5]  = minute;
  insert_data[0][6]  = timestamp;
  insert_data[0][7]  = user_name;
  insert_data[0][8]  = slackuser_id;
  insert_data[0][9]  = email;
  insert_data[0][10] = command;
  insert_data[0][11] = employee_id;
  
  updated_row.setValues(insert_data);
  
  //連想配列を生成する
  var arr_data = {};
  var keys = sheet.getRange(1, 1, 1, last_column_num).getValues()[0];
  keys.forEach(function(key, index){
              arr_data[key] = insert_data[0][index];
              });
  var json_data = JSON.stringify(arr_data);
  return json_data;
}


/**
 * SlackIDからメールアドレスを取得する
 **/
function getUserEmail(slackuser_id) {
  var ids = {
    XXXXXXXXX:'XXXX@anti-pattern.co.jp',
 };
  return ids[slackuser_id];
}


/**
 * メールアドレスから勤怠管理システムのアカウントIDを取得する
 **/
function getEmployeeId(email) {
  var employee_ids = {
    'XXXX@anti-pattern.co.jp':'00000',
  };
  return employee_ids[email];
}

/* 
* 打刻情報をlambdaへpostする関数
*/
function sendLambda(message){
  
  var headers = {
    'x-api-key': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'//API GatewayのPAIキーを記載する
  };
  var api_gateway_url = 'https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';//API GatewayのURLを記載する

   var options =
   {
     "method" : "post",
     "payload" : message,
     "headers" : headers,
     "muteHttpExceptions" : true,
   };
  
  var res = UrlFetchApp.fetch(api_gateway_url, options);
  return res;
}

おわりに

勤務開始、終了の時間を記録する仕組みはできましたが、細かいところは粗いままです。勤務終了を忘れていたらアラートを上げるなどの機能も付けられたらいいかもなーと思います。