GASとHARMOSとSlackではじめる勤怠Bot

これはmikanのアドベントカレンダー 3日目の記事です。

2日目は@satoshin21からEvolution of mikan iOS 2022でした。今のmikanのiOSアプリの全体像とこれからについてわかる内容だったので、iOSエンジニアの方はみていただけると嬉しいです。

はじめに

こんにちは。 mikanでAndroidエンジニアをしております、zukkeyといいます。

今回は、社員数が増えてきたこともあって勤怠システムのHRMOSを利用するようにしたが、自分たちで使いやすいように勤怠用のSlack Botを作ってほしいなぁ、という依頼があった際に業務の合間に密かに作っていたので、GASとHARMOSとSlackではじめる勤怠Botの作り方について紹介していこうと思います。

対象となる読者の方はこちらを想定しています

  • GASでSlack Botを作ってみたい方
  • HRMOS APIを使ってよしなに自分のBotを作ってみたい方

GASとは

まずはじめに、GASとはなんなのかということについてです。

GASとは、Google Apps Scriptの略称で、Googleによって開発されたアプリケーション開発のためのGoogleワークスペースプラットフォームです。

どういうことができるかというと、HTML、CSSJavaScriptを使って開発を行うことができます。定期実行も容易に行うことができ、Google Spread Sheetも併用して自動化することもできます。

HRMOSとは

HRMOSは、株式会社ビズリーチの採用管理システム、タレントマネジメントシステムです。

弊社では勤怠管理に利用させていただいております。

HRMOS勤怠では、APIを利用することができ、自分たちでカスタムしたい場合はドキュメントが用意されています。

今回は、このGASとSlackとHRMOS APIを組み合わせて勤怠Botを作成していきます。

HRMOS APIを利用するための準備

まず初めに、HRMOS APIを利用するために準備が必要です。

管理画面より、API KEY の作成を行ってください。

システム管理者でないとできないので、システム管理者に問い合わせましょう。

GASの準備

Google スプレッドシートにアクセスして、空白を選択し無題のスプレッドシートを新規作成します。

作成がおわると、次のような画面にきます。

拡張機能を選択して、Apps Scriptを選択します。

選択した後に、次のような画面にくるとGASの準備は完了です。あとはコードを書いていくだけになります。

一応試しに、console.log("Hello, World!") と入力してみて、実行を行ってみましょう。

次の画面が出たら、初めての実行は成功です。

勤怠情報を取得しよう

Botを作っていく前に、せっかくHRMOS APIも利用できるようになったので、GASとHRMOS APIを使用して勤怠情報を取得する部分を作成しましょう。

勤怠情報取得までの流れとしては、次のとおりです。

  1. 管理画面で発行したAPI KEYのSecret Keyをスクリプト プロパティに保存する
  2. Secret Keyを使用してBasic認証を行ってTokenの発行を行う
  3. 発行したTokenを用いて、会社に所属するユーザーの一覧を取得する

管理画面で発行したAPI KEYのSecret Keyをスクリプト プロパティに保存する

まずは、Secret Keyのような値をベタで書くこともできますが、セキュリティ上好ましくないということでスクリプトプロパティを利用します。

スクリプトプロパティとは、GASで1つのドキュメントに対して、key-value方式で文字列を保存することができる仕組みです。

コードで設定できる方法もありますが、今回は手動で設定してしまいます。

GAS上の歯車のアイコンを選択します。

遷移後、一番下にいくとスクリプトプロパティの項目を確認できると思います。

次に、スクリプトプロパティを追加を選択します。

プロパティにHRMOS_API_KEYと入れて、値には管理画面で発行したAPI KEYのSecret Keyを入れるようにしてください。

最後に、スクリプトプロパティを保存を選択します。

これでスクリプトプロパティの保存は完了です。

Secret Keyを使用してBasic認証を行ってTokenの発行を行う

次は、HRMOS APIv1/authentication/token を利用してTokenの取得を行いましょう。

まず初めに、先ほどスクリプトプロパティに保存した値を読み取る部分から行っていきましょう。 スクリプトプロパティの値を読み取るには、PropertiesServiceを利用します。

GASでエディタに戻って、次のように記載します。

function getToken() {
  const ps = PropertiesService.getScriptProperties()
  const apiKey = ps.getProperty('HRMOS_API_KEY')
  console.log(apiKey)
}

これを実行して、下記のように先ほどスクリプトプロパティに設定した値が表示されていればOKです。

例では、hogehogefugafugaと指定したので、その値がログに表示できていることを確認できました。

次に、Basic認証を行ってHRMOS勤怠のAPIを使用するためのTokenを発行する部分を実装していきます。

Tokenを発行するためのエンドポイントは、https://ieyasu.co/api/company_url/v1/authentication/token であることが、ドキュメントより確認できます。

ここで、company_urlは、企業ごとに異なります。

次のようなHRMOS勤怠にログインする画面をみたときに、表示されているURLの中にあります。 例で挙げると、次のとおりです。

https://f.ieyasu.co/{ここに書かれています}/login

これも、API KEYの場合と同様にして、 HRMOS_COMPANY_URL というキー名でスクリプトプロパティに保存しておきましょう。先程と同じ手順になるので、ここは省略します。

エンドポイントが何かわかったので、GASで通信する部分について実装していきます。

UrlFetchAppというクラスを使うことで、容易に通信することが可能です。

UrlFetchApp.fetch(url, params)で、urlにはエンドポイントを指定し、paramsにオプションとしてリクエストのメソッドの種類やヘッダーなどの詳細情報を含めることができます。

何を指定できるかはドキュメントにも書いてありますが、次のとおりです。

これらを参考にして、実際にコードを書いていきます。これを実行してコンソールにログを出すようにすると、トークンが発行できていることがわかります。

function getToken() {
  const ps = PropertiesService.getScriptProperties()
  const apiKey = ps.getProperty('HRMOS_API_KEY')
  const endPoint = 'https://ieyasu.co/api/' + ps.getProperty('HRMOS_COMPANY_URL') + '/v1/authentication/token'
  const headerInfo = token => ({
    'Content-Type': 'application/json',
    'Authorization': 'Basic ' + token
  })
  const options = {
    method: 'get',
    headers: headerInfo(apiKey),
    "muteHttpExceptions": true,
  }
  const response = UrlFetchApp.fetch(endPoint, options)
  const json = JSON.parse(response.getContentText())
  return json.token
}

Headerはドキュメントにあるとおりに指定します。

muteHttpExceptionsをtrueにすると、HTTPResponseを返してくれるようになるので指定しましょう。

getContentTextで得られる値は、jsonになっているので、JSON.parseで処理可能なオブジェクトに変更します。

これで、Tokenの発行を行うことができました。

発行したTokenを用いて、会社に所属するユーザーの一覧を取得する

Tokenを発行したので、次に会社に所属するユーザーの一覧を取得するようにします。

ユーザーの取得を行う必要があるのは、打刻登録のAPIを利用するのにユーザーのIDを必要とするからです。

先程とほとんど変わらず、エンドポイントが変わることと、headerInfo部分の Auth部分が先程実装したTokenを利用する形になります。

ユーザーを取得するためのエンドポイントは、 https://ieyasu.co/api/company_url/v1/users であることが、ドキュメントより確認できます。

コードを書いていきます。これを実行してコンソールにログを出すことで、ユーザーの情報を取得できるようになっていると思います。

function getUserInfo() {
  const hrmosToken = getToken()
  const ps = PropertiesService.getScriptProperties()
  const endPoint = 'https://ieyasu.co/api/' + ps.getProperty('HRMOS_COMPANY_URL') + '/v1/users'
  const headerInfo = token => ({
    'Content-Type': 'application/json',
    'Authorization': 'Token ' + token
  })
  const options = {
    method: 'get',
    headers: headerInfo(hrmosToken),
    "muteHttpExceptions": true,
  }
  const response = UrlFetchApp.fetch(endPoint, options)
  const responseBody = JSON.parse(response)
  console.log(responseBody)
}

取得するページを指定したり、取得件数の制限があったりするので取得したい情報に合わせて変更してみてください。

SlackアプリとGASで連携しよう

Slackアプリの作成部分に関しては、いろんな方が書かれていることもあるので、必要な部分だけ記載してざっくりといきます。

Slack Botを作成するには、Slack appを作る必要があります。こちらより、Create an appで作成しましょう。

Create an app > From scratch を選択します。

App Name を決めて、Pick a workspace to develop your app in のところで、導入したいworkspaceを選んでください。

Slack Appの方はいくつか設定が必要です。下記の項目を有効にしてください。

そして、WorkspaceにSlackアプリをインストールしましょう。

ざっくりと準備はこんな感じです。利用するAPIに応じてScopeの設定が必要になる点が注意です。

弊社の勤怠Botの要件は次のとおりでした。

  • 出勤や退勤などの特定の単語で打刻ができること
  • 打刻が失敗したら何かわかるといい
  • 打刻が成功した場合は、リアクションがついてくれると嬉しい

これらの要件を満たす為には、下記フローが必要でした。

  1. Event Subscriptionsを有効化して、特定のチャンネルに投稿された特定のワードで打刻を行う
  2. 打刻が失敗した場合は、対象のSlackメッセージのスレッドにBotからのエラーメッセージを表示する
  3. 打刻が成功した場合は、対象のSlackメッセージにリアクションを付与する

それぞれに分けて説明していきます。

Event Subscriptionsを有効化して、特定のチャンネルに投稿された特定のワードで打刻を行う

まずは、Slackでメッセージを送信した後にSlackからGAS側にリクエストを送信し、GAS側でSlackからのリクエストかどうか検証する部分から確認していきましょう。

Slack App側で、Event Subscriptionsを有効化すると、doPost関数で受け取ることができます。

doPost関数は次のように実装します。forwardMessage関数は後ほど説明します。

function doPost(e) {
  const ps = PropertiesService.getScriptProperties();
  const json = JSON.parse(e.postData.getDataAsString());
  if (ps.getProperty("VERIFICATION_TOKEN") != json.token) {
    throw new Error("invalid token.");
  }
  if (json.type == "url_verification") {
    return ContentService.createTextOutput(json.challenge)
  }
  const token = ps.getProperty("BOT_TOKEN")
  const text = json.event.text
  const eventType = json.event.type
  const user = json.event.user
  const ts = json.event.ts
  forwardMessage(token, eventType, user, text, ts)
}

例のごとく、スクリプトプロパティに保存した値を利用します。

Slackからのリクエスト情報として、getDataAsStringでjson形式のデータを取得することができます。

受け取ったJsonの中に、tokenがあり、これはSlackからのものであることを検証する為に利用します。

VERIFICATION_TOKENは、Slackアプリ側の Basic Information > App Credentials > Verification Token にて表示されている値を、ここではスクリプトプロパティに保存して読み取っています。

ContentService.createTextOutput(json.challenge)では、Slack のEvents API ドキュメントのURL verification handshakeに記載されているとおり、最初にイベントリクエストURLを確認する必要があるので、その確認に利用しています。

ContentService.createTextOutputは、GAS側で用意されている関数でテキストを返してくれます。

BOT_TOKENは、Slack アプリ側の OAuth & Permissions > OAuth Tokens for Your Workspace > Bot User OAuth Token の値をスクリプトプロパティに保存して読み取っています。

このようにして、まずはGAS側にSlackからの応答であることを確認する部分を最初に実装してしまいます。

次に、特定ワードの検証部分についてです。

特定ワードの検証部分については、forwardMessage関数に記載しているので、そちらをみていきましょう。

function forwardMessage(token, eventType, user, text, ts) {
  const triggerWord1 = "出勤"
  const triggerWord2 = "退勤"
  const triggerWord3 = "休憩"
  const triggerWord4 = "休憩戻り"
  const triggerWord5 = "ヘルプ"
  if (eventType == "message" && text === triggerWord1) {
    registerStamp(user, 1, "in", ts)
  } else if (eventType == "message" && text === triggerWord2) {
    registerStamp(user, 2, "out", ts)
  } else if (eventType == "message" && text === triggerWord3) {
    registerStamp(user, 7, "bi", ts)
  } else if (eventType == "message" && text === triggerWord4) {
    registerStamp(user, 8, "bo", ts)
  } else if (eventType == "message" && text === triggerWord5) {
    postToSlack(token, "<@" + user + "> さん\nこちらがヘルプのNotionです \n https://hogehogefugafuga", ts)
  }
}

registerStampとpostToSlackは後ほど説明するので、割愛します。

メッセージの検証はとても単純で、ベタで決めている文字列と、Slackからのテキストを比較して、条件分岐しているだけです。

もっと汎用性を高くするのであればスプレッドシートに保存して、特定の列にある言葉のリストのどれかにマッチしているか、というようなことをやるといいのかなと考えてはいるのですが、今回は簡易的に済ませました。

特定ワードの検証はこれだけで済むので、次は打刻部分についてみていきましょう。

registerStamp関数にまとめています。詳細は次のとおりです。コード中の...は省略を示します。

function registerStamp(user, stampType, emojiName, ts) {
  const hrmosToken = getToken()
  const ps = PropertiesService.getScriptProperties()
  const endPoint = 'https://ieyasu.co/api/' + ps.getProperty('HRMOS_COMPANY_URL') + '/v1/stamp_logs'
  const headerInfo = token => ({
    'Content-Type': 'application/json',
    'Authorization': 'Token ' + token
  })
  const sheetId = ps.getProperty('SHEET_ID')
  const spreadById = SpreadsheetApp.openById(sheetId)
  const spreadsheet = spreadById.getSheetByName("勤怠管理")
  const values = spreadsheet.getRange("A2:B46").getValues()
  const searchIndex = values.findIndex(value => value[1] == user)
  const targetIndex = searchIndex + 2
  const targetRange = "D" + targetIndex
  spreadsheet.getRange(targetRange).setValue(emojiName)
  const userId = values[searchIndex]
  const query = {
    user_id: userId,
    stamp_type: stampType
  }
  const options = {
    method: 'post',
    headers: headerInfo(hrmosToken),
    payload: JSON.stringify(query),
    "muteHttpExceptions": true,
  }
  const response = UrlFetchApp.fetch(endPoint, options)
  ...
}

打刻部分は、HRMOS APIの/v1/stamp_logsを利用します。

以前説明しているgetToken関数で、HRMOSのトークンを取得するようにして、例のごとく打刻登録用のエンドポイントと、通信を行うのに必要なパラメーターを設定していきます。

今回私が作成しているBotでは、先に説明している所属企業の社員のuser idをスプレッドシート側にまとめるようにしているので、SpreadsheetApp.openByIdで指定したスプレッドシー トを利用して、getSheetByNameにて対象のシートから必要な情報を取得するようにしています。

A列にUser Idを入れているので、searchIndexではSlackのメッセージを送ったuser idとシートに保存しているuser idを比較して対象の位置を割り出し、targetIndexでスプレッドシート上のヘッダー分を+2して実際のリストのindexを取るようにしています。

targetIndexを用いて、そのユーザーの現在の勤怠情報を保持しておきたいので、スプレッドシートに記録する部分をsetValueにて行っています。

あとは、HRMOSドキュメントの定義に合わせて、queryを作ります。

user_idとstamp_typeがrequiredになっているので、それらをforwardMessageでメッセージごとに指定した値を渡してあげるようにします。

あとは、ユーザー一覧を取得した時と同じようにUrlFetchApp.fetchで通信するだけです。

レスポンスを受け取っているので、成功か失敗かに応じて分岐させます。

function registerStamp(user, stampType, emojiName, ts) {
  ...
  const response = UrlFetchApp.fetch(endPoint, options)
  const botToken = ps.getProperty("BOT_TOKEN")
  const responseCode = response.getResponseCode()
  const responseBody = response.getContentText()
  const retryCounterTarget = "E" + targetIndex
  if (responseCode === 200) {
    // 成功した時の処理
  } else {
    // 失敗した時の処理
  }
}

getResponseCodeで、レスポンスコードを取得できます。とりあえずドキュメントに記載がある通り、200であれば成功なので、それだけの分岐を行うようにしています。

打刻が成功した場合は、対象のSlackメッセージにリアクションを付与する

こんな感じに投稿したメッセージ対してリアクションをつけるようにするところを説明していきます。

Slackメッセージで、リアクションをつける場合は、reactions.add APIを利用します。

注意点としましては、作成したSlack App側にScopeの設定がされていないとつきません。 まだ未設定の場合は、reactions:write を指定するようにしてください。

打刻が成功した場合なので、前の節の成功した時の処理のところに、次のaddReactionToSlackを呼ぶようにします。

function addReactionToSlack(token, emojiName, ts) {
  const ps = PropertiesService.getScriptProperties()
  const channelId = ps.getProperty("SLACK_CHANNEL_ID")
  const payload = {
    "token": token,
    "name": emojiName,
    "channel": channelId,
    "timestamp": ts
  }
  const options = {
    "method": "post",
    "payload": payload
  }
  UrlFetchApp.fetch("https://slack.com/api/reactions.add", options);
}

tokenで渡すのは、Slack BotのTokenです。xoxb- から始まる値を指定してください。

SLACK_CHANNEL_IDは、Slackのチャンネルの詳細のAboutの一番下に記載されています。SLACK_CHANNEL_IDというキー名でスクリプトプロパティに保存しているので、そこから読み取るようにしています。

timestampは対象のメッセージより取得できるので、doPostから渡してくるようにします。

nameには、付けたい好きなSlack絵文字の名前を指定するようにしてください。今回のBotでは勤怠に使用しているリアクションのアイコンの名前を指定するようにしています。

それぞれ設定し終えたら、UrlFetchApp.fetchで通信するだけです。今のところ実装が雑になってしまっているのですが、ここもエラーハンドリングする場合はresponseを受け取って分岐させるようにしましょう。

打刻が失敗した場合は、対象のSlackメッセージのスレッドにBotからのエラーメッセージを表示する

こんな感じに投稿したメッセージ対してエラー内容を表示して投稿するところを説明していきます。

Slackメッセージで、メッセージを送信したい場合は、chat.postMessage APIを利用します。

注意点としましては、作成したSlack App側にScopeの設定がされていないと投稿できません。 まだ未設定の場合は、chat:write を指定するようにしてください。

今回は、打刻が失敗した場合なので、前の前の節の失敗した時の処理のところに、次のpostToSlackを呼ぶようにします。

function postToSlack(token, message, ts) {
  const ps = PropertiesService.getScriptProperties()
  const channelId = ps.getProperty("SLACK_CHANNEL_ID")
  const payload = {
    "token": token,
    "text": message,
    "channel": channelId,
    "thread_ts": ts
  }
  const options = {
    "method": "post",
    "payload": payload
  }
  UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
}

前の節と同様、tokenで渡すのは、Slack BotのTokenです。xoxb- から始まる値を指定してください。

SLACK_CHANNEL_IDは、Slackのチャンネルの詳細のAboutの一番下に記載されています。SLACK_CHANNEL_IDというキー名でスクリプトプロパティに保存しているので、そこから読み取るようにしています。

thread_tsでは、タイムスタンプを指定しています。指定することでスレッドに対してメッセージを送信することができるようになります。

今後の改善点と注意点

この勤怠Botの仕組みとしましては、いくつか改善点があります。

一つは、打刻の単語判定部分です。今は固定の文字列で分岐しているので、使いたい言葉を設定してそれぞれの言葉に対して設定できるようにする、といったことができると、より自分好みに打刻することができるようになると思います。

単純に、スプレッドシートに記録しておきそこに含まれているかどうかだけで判定できるようになると思いますので、空き時間が出来次第やっていこうかなと思います。 もう一つは、エラー周りです。

現在は、エラーログをGCP上に記録されるように送っているのですが、詳細がわかりづらいことと、他にはエラーが起きた際に利用者へよりわかりやすいメッセージを出す、といったことがあって良いのかなと思っています。

これも時間をとってどこかでやりたいなと思います。

他に、勤怠Botの改善点でもありつつ、注意点があります。

勤怠Botの今の仕組みの注意点として、HRMOS Tokenの更新の仕組みが必要です。やろうやろうと後回しになってしまっていてまだ実装していないのですが、更新しないとある特定の期間で動かなくなってしまいます。

これは次回更新時にきちんと更新されるように、実装しようと思います。

おわりに

今回は、業務の合間に依頼があったので、GASとHRMOS APIとSlackを連携して作る勤怠Botの紹介でした。

以前はBotを作成するとなるとHerokuを使ったりサーバー立てたりとかいろいろと面倒だったのですが、GASで作ってみるととても簡単に作れるので、非常におすすめです。

今回は勤怠Botとしての実装の紹介だったので、HRMOSのパターンでの紹介でしたが、他のAPIでも同様にして、さまざまな自動化を行うことが容易に可能です。

公式ドキュメントをみて実装していけば、誰でも簡単に自動化が可能だと思いますので、ぜひぜひやってみてください。

さいごに、mikanでは、一緒に英語学習をより良いものにしていくべくサービスを作っていく仲間を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!他の職種の方もぜひぜひ、ご応募ください〜〜

03. Android エンジニア

現在デザイナーを特に募集中です!ぜひぜひご応募いただけますと嬉しいです〜〜!

04. デザイナー