エンジニア

SlackでAKASHI連携やってみた(ハンズラボ自作、オチあり)

こんにちは。弊社では、AKASHIというクラウド勤怠管理システムを使用しています。
表題の「SlackでAKASHI連携」は他社でも取り入れていますが、あえて弊社の開発理由・成果物を発信してみます。
リリース予定は2023年3月でした。勘の良い方なら、表題の「オチ」に気付かれているかもしれませんね。
尚、本プロジェクトは弊社の有志7人(+社内の有識者)で遂行したものです。

1. 開発を開始した経緯

コロナ前は出退社時にカードでピッとしていましたが、リモートワークが開始した段階でスマホ打刻に切り替わりました。

打刻自体はスマホでポチッとするだけでとても簡単ですが、普段使用しているSlackで操作できたらもっと楽だよね、という意見から有志でチームを組んで開発することにしました。

⒉ 要件

勤怠管理の都合などを考慮して、必須要件は以下のようになりました。

  • 報告の種類

 「出勤します」「出社します」:出勤としてAKASHIに自動打刻
 「退勤します」:退勤としてAKASHIに自動打刻
 「直行します」「直帰します」:AKASHI打刻はせず、手動打刻するようにSlackで返信
 「中抜けします」「中抜けから戻ります」:AKASHI打刻はせず、手動打刻するようにSlackで返信

  • その他の要件

  報告はSlack Workflow Builderを使用し、Google Spread Sheetに転記する
  報告時にコメントを入れられるようにする(コメントに「出勤・退勤」の文字が入る可能性があるため考慮する)
  AKASHIの自動打刻に失敗したら手動打刻を促すようSlackで返信をする
  AKASHIのトークンを毎週自動更新する
  何らかのエラーが出たら専用のSlackチャンネルに通知させる

⒊ 構成

上記要件を満たすために構成を検討しました。
SlackとAKASHIを繋ぐ候補としてGoogle App Script(以下GAS)やAWS Lambadaが挙げられましたが、メンテナンス性を考慮してGASを採用しました。

使用した技術

・Slack Workflow Builder(報告用のワークフロー)
・Slack App Directory(特定の単語を検知してGASに連携)
・Google Apps Script(打刻用:Google SpreadSheetのユーザー情報を元にAKASHIと連携。以下GAS)
・AKASHI API(GASからAKASHIを操作するために使用)
Google Apps Script GitHub アシスタント(GASのコードをGithubで管理するための拡張機能)


⒋ コード

では、コードです。上記の図の通り、「打刻用」「トークン更新用」の2種類です。
弊社の環境で動くものであり、あくまでもご参考です。

打刻用GAS

Slack App Directoryとの連携に必要なトークン(verification_token, slack_bot_token)は スクリプトプロパティで定義しています。
発言によって返信をしてくれます。

そして苦戦した点として、一回の発言に対して何度もスクリプトが動いてしまうという問題が発生しました。メンバー内で検討や調査を重ねた結果、以下の2つの方法で解決できました。
①event idを取得して複数回実行しないようにスキップ処理を入れる(「キャッシュ関連処理」部分)
②slackに返す時の”headers”にX-Slack-No-Retryを入れる

// プロジェクトのプロパティ>スクリプトのプロパティからtoken取得
const verification_token = PropertiesService.getScriptProperties().getProperty("verification_token");
const bot_token = PropertiesService.getScriptProperties().getProperty("slack_bot_token");

function doPost(e) {
  console.log("処理を開始します");

  // Events APIからのPOSTを取得
  // 受け取れるデータ→https://api.slack.com/events/message.channels
  const data = JSON.parse(e.postData.getDataAsString());
  console.log(data);

  //ログ確認用
  console.log("リクエスト全体の中身は:",e);
  const req_data = JSON.parse(e.postData.contents);
  // console.log("リクエストの中身は:",req_data);
  console.log("確認したいリクエストの中身は:",e.postData.contents.event);

  // // Events APIからのPOSTであることを確認
  if (verification_token !== data.token) {
    throw new Error("invalid token.");
  }

  // Events APIを使用する初回、URL Verificationのための記述
  if (data.type == "url_verification") {
    return ContentService.createTextOutput(data.challenge);
  }

  // 特定チャンネルまたは特定ワークフローからの発信でない場合は終了
  if (data.event.channel !== "SLACK_CHANNEL_ID"
      || data.event.username !== "テスト用_出勤報告") {
    console.log("特定のチャンネルまたはワークフロー外の発信だったため、処理を終了します")
    return;
  }

  // キャッシュ関連処理
  const event_id = data.event_id;
  const cache = CacheService.getScriptCache();
  const cached = cache.get(event_id);
  if (cached) {
    console.log(`既に処理済みのイベントなためスキップします (eventId: ${event_id})`);
    return;
  } else {
     cache.put(event_id, true, 21600);
  }

  // ---------------------------------------------------------------------
  // 事前作業
  // ---------------------------------------------------------------------
  // text部分を抽出
  const input_text = data.event.text
  console.log("input_text: " + input_text);

  // slack IDをinput_textから抽出する
  const slack_user_id = input_text.substr(2, input_text.indexOf(">")-2)

  // 変数を定義
  let mention = "<@" + slack_user_id + ">"
  let type = null
  let base_message = ""
  let last_message = ""
  let response_text = ""

  // ---------------------------------------------------------------------
  // 打刻種別の判定
  // ---------------------------------------------------------------------
  // input_textの内容をもとに、typeとbase_messageを決める
  if (input_text.match(/^\<@[a-zA-Z0-9]*\> 在宅勤務開始します/) !== null) {
    type = 11
    base_message = "AKASHI打刻処理(出勤):"
    last_message = "おはようございます!"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 出社しました/) !== null) {
    type = 11
    base_message = "AKASHI打刻処理(出勤):"
    last_message = "出社お疲れ様です!"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 勤務終了します/) !== null) {
    type = 12
    base_message = "AKASHI打刻処理(退勤):"
    last_message = "お仕事お疲れ様でした!"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 直行します/) !== null) {
    // type = 21
    base_message = ":okiwotukete:"
    last_message = " (お手数ですが、スマホで打刻をお願いします)"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 直帰します/) !== null) {
    // type = 22
    base_message = ":otsukaresama:"
    last_message = " (お手数ですが、スマホで打刻をお願いします)"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 休日に変更します/) !== null) {
    base_message = ":oyasumi:"
    last_message = " (お手数ですが、AKASHIでの申請をお願いします)"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 中抜けします/) !== null) {
    base_message = ":ittera:"
    last_message = " (お手数ですが、AKASHIでの申請をお願いします)"

  } else if (input_text.match(/^\<@[a-zA-Z0-9]*\> 中抜けから戻りました/) !== null) {
    base_message = ":okaeri:"
    last_message = " (お手数ですが、AKASHIでの申請をお願いします)"

  } else {
    console.log("想定外の文字列を受け取ったため処理を終了します。input_text: " + input_text)
    return
  }

  // ---------------------------------------------------------------------
  // 打刻処理
  // ---------------------------------------------------------------------
  if (type) {
  // 打刻処理をするtypeの場合
    try {
      // 発話ユーザーのAKASHI APIトークンを取得し、打刻処理を実施
      akashi_response = doStamp(getAkashiAPIToken(slack_user_id), type);
      console.log(akashi_response)
      response_text = base_message + "打刻が正常に終了しました。" + last_message

    // 打刻のエラー処理
    } catch (e) {
      console.log(e)
      response_text = base_message + "ごめんなさい、打刻に失敗しました。お手数ですがAKASHIで直接打刻をお願いします。"

      // notice_aso にも ID 付きで通知する
      callWebApi(bot_token, "chat.postMessage",{
        channel: "SLACK_CHANNEL_ID",     // チャンネルID
        text: slack_user_id + " , " + response_text,
      });
    }
  
  // 打刻処理をしないtypeの場合
  } else {
    console.log("打刻が必要ない処理です。input_text: " + input_text);
    response_text = base_message + last_message
  }

  // ---------------------------------------------------------------------
  // Slackへの通知
  // ---------------------------------------------------------------------
  // Slackに結果を投稿する
  // Docs: https://api.slack.com/methods/chat.postMessage
  const api_response = callWebApi(bot_token, "chat.postMessage",{
    channel: "SLACK_CHANNEL_ID",     // メッセージ送信先のチャンネルID()
    text: mention + "\n" + response_text,
    thread_ts: data.event.ts
  });
  console.log(`slack api response: ${api_response}`);
  console.log("処理を終了します");
  return;
}

// ---------------------------------------------------------------------
// 関数たち
// ---------------------------------------------------------------------
// Slackに投稿
function callWebApi(token, apiMethod, payload) {
  const response = UrlFetchApp.fetch(
    `https://www.slack.com/api/${apiMethod}`,
    {
      method: "post",
      contentType: "application/x-www-form-urlencoded",
      headers: { 
        "Authorization": `Bearer ${token}`,
        "X-Slack-No-Retry": 1 
      },
      payload: payload,
    }
  );
  return response;
}

// AKASHI APIの実行
// 引数はAKASHIのAPIトークン、戻り値は結果オブジェクト
function doStamp(akashi_api_token, stamp_type) {
  const stamp_akashiapi_url = "https://atnd-awj.ak4.jp/api/cooperation/xxxxxx/stamps"
  api_url = stamp_akashiapi_url + "?token=" + akashi_api_token + "&type=" + stamp_type;

  var option = {
    "Content-Type": "application/json",
    "method": "post",
    "timezone": "+09:00"
  };

  return JSON.parse(UrlFetchApp.fetch(api_url, option));

}

// SlackユーザーIDからAKASHIのAPIトークン特定して返す
// 引数:Slackのユーザー名、戻り値:AKASHIのAPIトークン
function getAkashiAPIToken(slack_user_id) {
  // スプレッドシートの情報
  const spread_sheet = "https://docs.google.com/spreadsheets/d/xxxx";
  const sheet_name = "従業員マスタ";
  // スプレッドシートオブジェクト
  var spreadSheet = SpreadsheetApp.openByUrl(spread_sheet);
  // シートオブジェクト
  var sheet = spreadSheet.getSheetByName(sheet_name);

  // 従業員マスタからSlackメンバーIDをキーにAKASHIのトークンを特定する
  // 従業員マスタが以下の構成であることが前提。マスタの構成が変わったら修正すること
  // SlackメンバーID:2列目に記載
  // AKASHIのトークン:5列目に記載
  for (i = 2; i <= sheet.getLastRow(); i++) {
    if (slack_user_id === sheet.getRange(i, 2).getValue()){
      return sheet.getRange(i, 5).getValue()
    }
  }
}

AKASHIトークン更新

akashi_token_api_url, employee_master_url, slack_urlは スクリプトプロパティで定義しています。

// AKASHIのトークンを更新するスクリプト
// 従業員マスタに記載された従業員のAKASHIトークンを更新する。
// 正常終了時:正常終了メッセージをSlackに通知する
// 異常終了時:異常が発生した従業員名をSlackに通知する

// 事前準備:スクリプトプロパティに以下の3つを設定すること。
// akashi_token_api_url:AKASHIのトークン再発行APIのURL
// employee_master_url :従業員マスタ(Googleスプレッドシート)のリンク
// slack_url           :通知先SlackのWebhook URL

// outgoingで呼ばれる関数
function doPost(e) {

  // 従業員マスタのURLを取得
  const employeeMasterUrl = PropertiesService.getScriptProperties().getProperty("employee_master_url");

  // エラーになった人一覧用のリスト
  let errUsers = [];
  let message;

  // スプレッドシートを読み込む
  // スプレッドシートオブジェクト
  let spreadSheet = SpreadsheetApp.openByUrl(employeeMasterUrl);
  // シートオブジェクト
  let sheet = spreadSheet.getSheetByName("従業員マスタ");
  // スプレッドシート内の氏名の列
  const spUserName = 3;
  // スプレッドシート内のAKASHIトークンの列
  const spAkashiTokenCol = 5;

  // スプレッドシートからトークンと社員名を取得する
  for (i = 2; i <= sheet.getLastRow(); i++) {
    try {
      // トークン更新
      sheet.getRange(i, spAkashiTokenCol).setValue(getNewAkashiToken(sheet.getRange(i, spAkashiTokenCol).getValue()));
    } catch (e) {
      // エラーログの出力
      console.log(e)
      // エラーが発生したユーザーを格納
      errUsers.push(sheet.getRange(i, spUserName).getValue());
      continue;
    }
  }

  // slackに通知する
  if (errUsers.length == 0) {
    message = "トークンの更新が正常終了しました";
  } else {
    message = "<!here> \nトークン更新に失敗したユーザーがいます。エラーの詳細はGCPのコンソールから確認してください:\n".concat(errUsers.join("\n"));
  }
  // slack通知用のメッセージ
  sendToSlack(message);
}

// AKASHIのAPIトークンを更新してトークン文字列を返す
function getNewAkashiToken(akashiAPIToken) {

  // AKASHIのAPIのURL(トークン更新用)
  const tokenAkashiAPIURL = PropertiesService.getScriptProperties().getProperty("akashi_token_api_url");
  // APIのURL生成
  let apiURL = tokenAkashiAPIURL + "?token=" + akashiAPIToken;

  let option = {
    "Content-Type": "application/json",
    "method": "post"
  };

  // POST実行し、結果をオブジェクト型に変換
  return JSON.parse(UrlFetchApp.fetch(apiURL, option)).response.token;
}

// Slackにメッセージを送る 引数:メッセージ、スレッド投稿用タイムスタンプ
function sendToSlack(message) {
  // SlackのWebhook URL
  const slackURL = PropertiesService.getScriptProperties().getProperty("slack_url");;

  // Slack用のオプション
  let slackOption = {
    "text": message
  };

  let payload = JSON.stringify(slackOption);

  // API叩くオプション
  let options = {
    "method": "POST",
    "contentType": "application/json",
    "payload": payload
  };
  UrlFetchApp.fetch(slackURL, options);
}

Slackで出勤報告をするとAKASHIに自動打刻され、返信が返ってきます。
(ASOは本プロジェクトの名称です。アイコンはデザインが得意なメンバーが作成してくれました。)

⒌ オチ

本プロジェクトは2022年12月に開始し、2023年2月にテスト完了、2023年3月21日から社内利用開始予定でした。

しかし!!!でも!BUT!!

3月13日に公式リリースの発表がありました。

実現したかったのこれやん!!!

と、紆余曲折ありましたが、いつもと違うメンバーで開発したり、あまり触れる機会のないGASの勉強ができたりととても良いプロジェクトであったと胸を張って言えます。

⒍ 最後に

本プロジェクトメンバーから嬉しい声を頂いたので掲載します。

Aさんの感想「
ユーザーの求めるものは何か、業務が効率的に進むのはどんな機能が必要か、などを時間を取って社内で話せる機会があったのは恵まれていると思います。また結果的に公式が出したツールを使うことになったのですが、公式で出たということは我々の「需要のあるサービスを探す」嗅覚は間違っていなかったという裏付けにもなりましたし、AKASHIAPIとの連携の勉強ができました。また、何より自分が知りたかったGASの勉強が、リーダーと、楽しみながら助けてもらいながらできたのは大きかったです。さらにGoogle Chrome拡張でGithub連携することもリーダーから教わり、効率的に作業が進みました。メンバーやリーダーの優秀さが垣間見れたのも、良い経験だったと思います。ありがとうございました!
」

弊社では有志でこのような楽しい活動もしています。というご紹介でした。

一覧に戻る